Skip to content
19 changes: 18 additions & 1 deletion Documentation/RuleDocumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Use the rules below in the `rules` block of your `.swift-format`
configuration file, as described in
[Configuration](Configuration.md). All of these rules can be
[Configuration](Documentation/Configuration.md). All of these rules can be
applied in the linter, but only some of them can format your source code
automatically.

Expand Down Expand Up @@ -43,6 +43,7 @@ Here's the list of available rules:
- [OrderedImports](#OrderedImports)
- [ReplaceForEachWithForLoop](#ReplaceForEachWithForLoop)
- [ReturnVoidInsteadOfEmptyTuple](#ReturnVoidInsteadOfEmptyTuple)
- [StandardizeDocumentationComments](#StandardizeDocumentationComments)
- [TypeNamesShouldBeCapitalized](#TypeNamesShouldBeCapitalized)
- [UseEarlyExits](#UseEarlyExits)
- [UseExplicitNilCheckInConditions](#UseExplicitNilCheckInConditions)
Expand Down Expand Up @@ -440,6 +441,22 @@ Format: `-> ()` is replaced with `-> Void`

`ReturnVoidInsteadOfEmptyTuple` rule can format your code automatically.

### StandardizeDocumentationComments

Reformats documentation comments to a standard structure.

Format: Documentation is reflowed in a standard format:
- All documentation comments are rendered as `///`-prefixed.
- Documentation comments are re-wrapped to the preferred line length.
- The order of elements in a documentation comment is standard:
- Abstract
- Discussion w/ paragraphs, code samples, lists, etc.
- Param docs (outlined if > 1)
- Return docs
- Throw docs

`StandardizeDocumentationComments` rule can format your code automatically.

### TypeNamesShouldBeCapitalized

`struct`, `class`, `enum` and `protocol` declarations should have a capitalized name.
Expand Down
160 changes: 153 additions & 7 deletions Sources/SwiftFormat/Core/DocumentationComment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ public struct DocumentationComment {

/// The documentation comment of the parameter.
///
/// Typically, only the `briefSummary` field of this value will be populated. However, for more
/// complex cases like parameters whose types are functions, the grammar permits full
/// descriptions including `Parameter(s)`, `Returns`, and `Throws` fields to be present.
/// Typically, only the `briefSummary` field of this value will be populated. However,
/// parameters can also include a full discussion, although special fields like
/// `Parameter(s)`, `Returns`, and `Throws` are not specifically recognized.
public var comment: DocumentationComment
}

Expand Down Expand Up @@ -75,6 +75,13 @@ public struct DocumentationComment {
/// `Throws:` prefix removed for convenience.
public var `throws`: Paragraph? = nil

/// A collection of _all_ body nodes at the top level of the comment text.
///
/// If a brief summary paragraph was extracted from the comment, it will not be present in this
/// collection. Any special fields extracted (parameters, returns, and throws) from `bodyNodes`
/// will be present in this collection.
internal var allBodyNodes: [Markup] = []

/// Creates a new `DocumentationComment` with information extracted from the leading trivia of the
/// given syntax node.
///
Expand All @@ -88,8 +95,9 @@ public struct DocumentationComment {
}

// Disable smart quotes and dash conversion since we want to preserve the original content of
// the comments instead of doing documentation generation.
let doc = Document(parsing: commentInfo.text, options: [.disableSmartOpts])
// the comments instead of doing documentation generation. For the same reason, parse
// symbol links to preserve the double-backtick delimiters.
let doc = Document(parsing: commentInfo.text, options: [.disableSmartOpts, .parseSymbolLinks])
self.init(markup: doc)
}

Expand All @@ -106,6 +114,9 @@ public struct DocumentationComment {
remainingChildren = markup.children.dropFirst(0)
}

// Capture all the body nodes before filtering out any special fields.
allBodyNodes = remainingChildren.map { $0.detachedFromParent }

for child in remainingChildren {
if var list = child.detachedFromParent as? UnorderedList {
// An unordered list could be one of the following:
Expand All @@ -129,8 +140,11 @@ public struct DocumentationComment {

extractSimpleFields(from: &list)

// If the list is now empty, don't add it to the body nodes below.
guard !list.isEmpty else { continue }
// Add the list if non-empty, then `continue` so that we don't add the original node.
if !list.isEmpty {
bodyNodes.append(list)
}
continue
}

bodyNodes.append(child.detachedFromParent)
Expand Down Expand Up @@ -344,3 +358,135 @@ private struct SimpleFieldMarkupRewriter: MarkupRewriter {
return Text(String(nameAndRemainder[1]))
}
}

extension DocumentationComment {
/// Returns a trivia collection containing this documentation comment,
/// formatted and rewrapped to the given line width.
///
/// - Parameters:
/// - lineWidth: The expected line width, including leading spaces, the
/// triple-slash prefix, and the documentation text.
/// - joiningTrivia: The trivia to put between each line of documentation
/// text. `joiningTrivia` must include a `.newlines` trivia piece.
/// - Returns: A trivia collection that represents this documentation comment
/// in standardized form.
func renderForSource(lineWidth: Int, joiningTrivia: some Collection<TriviaPiece>) -> Trivia {
// The width of the prefix is 4 (`/// `) plus the number of spaces in `joiningTrivia`.
let prefixWidth =
4
+ joiningTrivia.map {
if case .spaces(let n) = $0 { return n } else { return 0 }
}.reduce(0, +)

let options = MarkupFormatter.Options(
orderedListNumerals: .incrementing(start: 1),
preferredLineLimit: .init(maxLength: lineWidth - prefixWidth, breakWith: .softBreak)
)

var strings: [String] = []
if let briefSummary {
strings.append(
contentsOf: briefSummary.formatForSource(options: options)
)
}

if !bodyNodes.isEmpty {
if !strings.isEmpty { strings.append("") }

let renderedBody = bodyNodes.map {
$0.formatForSource(options: options)
}.joined(separator: [""])
strings.append(contentsOf: renderedBody)
}

// Empty line between discussion and the params/returns/throws documentation.
if !strings.isEmpty && (!parameters.isEmpty || returns != nil || `throws` != nil) {
strings.append("")
}

// FIXME: Need to recurse rather than only using the `briefSummary`
switch parameters.count {
case 0: break
case 1:
// Output a single parameter item.
let list = UnorderedList([parameters[0].listItem(asSingle: true)])
strings.append(contentsOf: list.formatForSource(options: options))

default:
// Build the list of parameters.
let paramItems = parameters.map { $0.listItem() }
let paramList = UnorderedList(paramItems)

// Create a list with a single item: the label, followed by the list of parameters.
let listItem = ListItem(
Paragraph(Text("Parameters:")),
paramList
)
strings.append(
contentsOf: UnorderedList(listItem).formatForSource(options: options)
)
}

if let returns {
let returnsWithLabel = returns.prefixed(with: "Returns:")
let list = UnorderedList([ListItem(returnsWithLabel)])
strings.append(contentsOf: list.formatForSource(options: options))
}

if let `throws` {
let throwsWithLabel = `throws`.prefixed(with: "Throws:")
let list = UnorderedList([ListItem(throwsWithLabel)])
strings.append(contentsOf: list.formatForSource(options: options))
}

// Convert the pieces into trivia, then join them with the provided spacing.
let pieces = strings.map {
$0.isEmpty
? TriviaPiece.docLineComment("///")
: TriviaPiece.docLineComment("/// " + $0)
}
let spacedPieces: [TriviaPiece] = pieces.reduce(into: []) { result, piece in
result.append(piece)
result.append(contentsOf: joiningTrivia)
}

return Trivia(pieces: spacedPieces)
}
}

extension Markup {
func formatForSource(options: MarkupFormatter.Options) -> [String] {
format(options: options)
.split(separator: "\n", omittingEmptySubsequences: false)
.map { $0.trimmingTrailingWhitespace() }
}
}

extension Paragraph {
func prefixed(with str: String) -> Paragraph {
struct ParagraphPrefixMarkupRewriter: MarkupRewriter {
/// The list item to which the rewriter will be applied.
let prefix: String

mutating func visitText(_ text: Text) -> Markup? {
// Only manipulate the first text node (of the first paragraph).
guard text.indexInParent == 0 else { return text }
return Text(String(prefix + text.string))
}
}

var rewriter = ParagraphPrefixMarkupRewriter(prefix: str)
return self.accept(&rewriter) as? Paragraph ?? self
}
}

extension DocumentationComment.Parameter {
func listItem(asSingle: Bool = false) -> ListItem {
let summary = comment.briefSummary ?? Paragraph()
let label = asSingle ? "Parameter \(name):" : "\(name):"
let summaryWithLabel = summary.prefixed(with: label)
return ListItem(
[summaryWithLabel] + comment.allBodyNodes.map { $0 as! BlockMarkup }
)
}
}
Loading