Files

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)
}
}