Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a StandardizeDocumentationComments rule #959

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
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