mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
203 lines
8.7 KiB
Swift
203 lines
8.7 KiB
Swift
//
|
|
// DocComments.swift
|
|
// SwiftFormat
|
|
//
|
|
// Created by Cal Stephens on 10/19/22.
|
|
// Copyright © 2024 Nick Lockwood. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
public extension FormatRule {
|
|
static let docComments = FormatRule(
|
|
help: "Use doc comments for API declarations, otherwise use regular comments.",
|
|
orderAfter: [.fileHeader],
|
|
options: ["doc-comments"]
|
|
) { formatter in
|
|
formatter.forEach(.startOfScope) { index, token in
|
|
guard [.startOfScope("//"), .startOfScope("/*")].contains(token),
|
|
let endOfComment = formatter.endOfScope(at: index)
|
|
else { return }
|
|
|
|
var commentIndices = [index]
|
|
|
|
// Check if this is a trailing comment (has non-space tokens before it on the same line)
|
|
let isTrailingComment: Bool
|
|
if let previousToken = formatter.index(of: .nonSpaceOrLinebreak, before: index) {
|
|
let commentLine = formatter.startOfLine(at: index)
|
|
let previousTokenLine = formatter.startOfLine(at: previousToken)
|
|
isTrailingComment = (commentLine == previousTokenLine)
|
|
} else {
|
|
isTrailingComment = false
|
|
}
|
|
|
|
// Only group comments if this is not a trailing comment
|
|
if token == .startOfScope("//"), !isTrailingComment {
|
|
var i = index
|
|
while let prevLineIndex = formatter.index(of: .linebreak, before: i),
|
|
case let lineStartIndex = formatter.startOfLine(at: prevLineIndex, excludingIndent: true),
|
|
formatter.token(at: lineStartIndex) == .startOfScope("//")
|
|
{
|
|
commentIndices.append(lineStartIndex)
|
|
i = lineStartIndex
|
|
}
|
|
i = index
|
|
while let nextLineIndex = formatter.index(of: .linebreak, after: i),
|
|
let lineStartIndex = formatter.index(of: .nonSpace, after: nextLineIndex),
|
|
formatter.token(at: lineStartIndex) == .startOfScope("//")
|
|
{
|
|
commentIndices.append(lineStartIndex)
|
|
i = lineStartIndex
|
|
}
|
|
}
|
|
|
|
guard let useDocComment = formatter.shouldBeDocComment(at: commentIndices, endOfComment: endOfComment) else {
|
|
return
|
|
}
|
|
|
|
// Determine whether or not this is the start of a list of sequential declarations, like:
|
|
//
|
|
// // The placeholder names we use in test cases
|
|
// case foo
|
|
// case bar
|
|
// case baaz
|
|
//
|
|
// In these cases it's not obvious whether or not the comment refers to the property or
|
|
// the entire group, so we preserve the existing formatting.
|
|
var preserveRegularComments = false
|
|
if useDocComment,
|
|
let declarationKeyword = formatter.index(after: endOfComment, where: \.isDeclarationTypeKeyword),
|
|
let endOfDeclaration = formatter._endOfDeclarationInTypeBody(atDeclarationKeyword: declarationKeyword),
|
|
let nextDeclarationKeyword = formatter.index(
|
|
after: endOfDeclaration,
|
|
where: \.isDeclarationTypeKeyword
|
|
)
|
|
{
|
|
let linebreaksBetweenDeclarations = formatter.tokens[declarationKeyword ... nextDeclarationKeyword]
|
|
.filter(\.isLinebreak).count
|
|
|
|
// If there is only a single line break between the start of this declaration and the subsequent declaration,
|
|
// then they are written sequentially in a block. In this case, don't convert regular comments to doc comments.
|
|
if linebreaksBetweenDeclarations == 1 {
|
|
preserveRegularComments = true
|
|
}
|
|
}
|
|
|
|
// Doc comment tokens like `///` and `/**` aren't parsed as a
|
|
// single `.startOfScope` token -- they're parsed as:
|
|
// `.startOfScope("//"), .commentBody("/ ...")` or
|
|
// `.startOfScope("/*"), .commentBody("* ...")`
|
|
let startOfDocCommentBody: String
|
|
switch token.string {
|
|
case "//":
|
|
startOfDocCommentBody = "/"
|
|
case "/*":
|
|
startOfDocCommentBody = "*"
|
|
default:
|
|
return
|
|
}
|
|
|
|
let isDocComment = formatter.isDocComment(startOfComment: index)
|
|
|
|
if let commentBody = formatter.token(at: index + 1),
|
|
commentBody.isCommentBody
|
|
{
|
|
if useDocComment, !isDocComment, !preserveRegularComments {
|
|
let updatedCommentBody = "\(startOfDocCommentBody)\(commentBody.string)"
|
|
formatter.replaceToken(at: index + 1, with: .commentBody(updatedCommentBody))
|
|
} else if !useDocComment || isTrailingComment, isDocComment, !formatter.options.preserveDocComments {
|
|
let prefix = commentBody.string.prefix(while: { String($0) == startOfDocCommentBody })
|
|
|
|
// Do nothing if this is a unusual comment like `//////////////////`
|
|
// or `/****************`. We can't just remove one of the tokens, because
|
|
// that would make this rule have a different output each time, but we
|
|
// shouldn't remove all of them since that would be unexpected.
|
|
if prefix.count > 1 {
|
|
return
|
|
}
|
|
|
|
formatter.replaceToken(
|
|
at: index + 1,
|
|
with: .commentBody(String(commentBody.string.dropFirst()))
|
|
)
|
|
}
|
|
|
|
} else if useDocComment, !preserveRegularComments {
|
|
formatter.insert(.commentBody(startOfDocCommentBody), at: index + 1)
|
|
}
|
|
}
|
|
} examples: {
|
|
"""
|
|
```diff
|
|
- // A placeholder type used to demonstrate syntax rules
|
|
+ /// A placeholder type used to demonstrate syntax rules
|
|
class Foo {
|
|
- // This function doesn't really do anything
|
|
+ /// This function doesn't really do anything
|
|
func bar() {
|
|
- /// TODO: implement Foo.bar() algorithm
|
|
+ // TODO: implement Foo.bar() algorithm
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
}
|
|
}
|
|
|
|
extension Formatter {
|
|
/// Whether or not the comment at this index can be a doc comment,
|
|
/// considering the following type declaration and surrounding context.
|
|
func shouldBeDocComment(
|
|
at indices: [Int],
|
|
endOfComment: Int
|
|
) -> Bool? {
|
|
guard let startIndex = indices.min(),
|
|
let nextDeclarationIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: endOfComment)
|
|
else { return false }
|
|
|
|
// Check if this is a directive like MARK or swiftformat:disable etc.
|
|
// In that case just preserve the comment as-is.
|
|
for index in indices {
|
|
if case let .commentBody(body)? = next(.nonSpace, after: index), body.isCommentDirective {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Check if this token defines a declaration that supports doc comments
|
|
var declarationToken = tokens[nextDeclarationIndex]
|
|
if declarationToken.isAttribute || isModifier(at: nextDeclarationIndex),
|
|
let index = index(after: nextDeclarationIndex, where: { $0.isDeclarationTypeKeyword })
|
|
{
|
|
declarationToken = tokens[index]
|
|
}
|
|
guard declarationToken.isDeclarationTypeKeyword(excluding: ["import"]) else {
|
|
return false
|
|
}
|
|
|
|
// For local declarations other than nested functions, use standard comments.
|
|
if declarationToken != .keyword("func"), declarationScope(at: startIndex) == .local {
|
|
return false
|
|
}
|
|
|
|
// If there are blank lines between comment and declaration, comment is not treated as doc comment
|
|
let trailingTokens = tokens[(endOfComment - 1) ... nextDeclarationIndex]
|
|
let lines = trailingTokens.split(omittingEmptySubsequences: false, whereSeparator: \.isLinebreak)
|
|
if lines.contains(where: { $0.allSatisfy(\.isSpace) }) {
|
|
return false
|
|
}
|
|
|
|
// Only comments at the start of a line can be doc comments
|
|
if let previousToken = index(of: .nonSpaceOrLinebreak, before: startIndex) {
|
|
let commentLine = startOfLine(at: startIndex)
|
|
let previousTokenLine = startOfLine(at: previousToken)
|
|
|
|
if commentLine == previousTokenLine {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Comments inside conditional statements are not doc comments
|
|
return !isConditionalStatement(at: startIndex)
|
|
}
|
|
}
|