Skip to content

Add commentValue property to Trivia for cleaned comments #2966

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Release Notes/602.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
- Pull request: https://github.com/swiftlang/swift-syntax/pull/3030
- Migration stems: None required.

- `Trivia` has a new `commentValue` property.
- Description: Extracts sanitized comment text from comment trivia pieces, omitting leading comment markers (`//`, `///`, `/*`, `*/`).
- Pull Request: https://github.com/swiftlang/swift-syntax/pull/2966

## API Behavior Changes

## Deprecations
Expand Down
143 changes: 143 additions & 0 deletions Sources/SwiftSyntax/Trivia+commentValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

extension Trivia {
/// The contents of all the comment pieces with any comments markers removed and indentation whitespace stripped.
public var commentValue: String? {
var comments: [Substring] = []

/// Keep track of whether we have seen a line or block comment trivia piece. If this `Trivia` contains both a block
/// and a line comment, we don't know how to concatenate them to form the comment value and thus default to
/// returning `nil`.
var hasBlockComment = false
var hasLineComment = false

// Determine if all line comments have a space separating the `//` or `///` comment marker and the actual comment.
lazy var allLineCommentsHaveSpace: Bool = pieces.allSatisfy { piece in
switch piece {
case .lineComment(let text): return text.hasPrefix("// ")
case .docLineComment(let text): return text.hasPrefix("/// ")
default: return true
}
}

// Strips /* */ markers and remove any common indentation between the lines in the block comment.
func processBlockComment(_ text: String, isDocComment: Bool) -> String? {
var lines = text.dropPrefix(isDocComment ? "/**" : "/*").dropSuffix("*/")
.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)

// If the comment content starts on the same line as the `/*` marker or ends on the same line as the `*/` marker,
// it is common to separate the marker and the actual comment using spaces. Strip those spaces if they exists.
// If there are non no-space characters on the first / last line, then the comment doesn't start / end on the line
// with the marker, so don't do the stripping.
if let firstLine = lines.first, firstLine.contains(where: { $0 != " " }) {
lines[0] = firstLine.drop { $0 == " " }
}
if let lastLine = lines.last, lastLine.contains(where: { $0 != " " }) {
lines[lines.count - 1] = lastLine.dropLast { $0 == " " }
}

var indentation: Substring? = nil
// Find the lowest indentation that is common among all lines in the block comment. Do not consider the first line
// because it won't have any indentation since it starts with /*
for line in lines.dropFirst() {
let lineIndentation = line.prefix(while: { $0 == " " || $0 == "\t" })
guard let previousIndentation = indentation else {
indentation = lineIndentation
continue
}
indentation = commonPrefix(previousIndentation, lineIndentation)
}

guard let firstLine = lines.first else {
// We did not have any lines. This should never happen in practice because `split` never returns an empty array
// but be safe and return `nil` here anyway.
return nil
}

var unindentedLines = [firstLine] + lines.dropFirst().map { $0.dropPrefix(indentation ?? "") }

// If the first line only contained the comment marker, don't include it. We don't want to start the comment value
// with a newline if `/*` is on its own line. Same for the end marker.
if unindentedLines.first?.allSatisfy({ $0 == " " }) ?? false {
unindentedLines.removeFirst()
}
if unindentedLines.last?.allSatisfy({ $0 == " " }) ?? false {
unindentedLines.removeLast()
}
// We canonicalize the line endings to `\n` here. This matches how we concatenate the different line comment
// pieces using \n as well.
return unindentedLines.joined(separator: "\n")
}

for piece in pieces {
switch piece {
case .blockComment(let text), .docBlockComment(let text):
if hasBlockComment || hasLineComment {
return nil
}
hasBlockComment = true
guard let processedText = processBlockComment(text, isDocComment: piece.isDocComment) else {
return nil
}
comments.append(processedText[...])
case .lineComment(let text), .docLineComment(let text):
if hasBlockComment {
Copy link
Member

@rintaro rintaro Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we allow

/// Foo Bar
/// This is a doc-comment.
// @_spi(Testing)
public fooBar() {}

resulting "Foo Bar\nThis is a doc-comment\n@_spi(Testing)", but not

/**
 * Foo Bar
 * This is a doc-comment.
 */
// @_spi(Testing)
public fooBar() {}

Not sure about this case.

return nil
}
hasLineComment = true
let prefixToDrop = (piece.isDocComment ? "///" : "//") + (allLineCommentsHaveSpace ? " " : "")
comments.append(text.dropPrefix(prefixToDrop))
default:
break
}
}

if comments.isEmpty { return nil }

return comments.joined(separator: "\n")
}
}

fileprivate extension StringProtocol where SubSequence == Substring {
func dropPrefix(_ prefix: some StringProtocol) -> Substring {
if self.hasPrefix(prefix) {
return self.dropFirst(prefix.count)
}
return self[...]
}

func dropSuffix(_ suffix: some StringProtocol) -> Substring {
if self.hasSuffix(suffix) {
return self.dropLast(suffix.count)
}
return self[...]
}

func dropLast(while predicate: (Self.Element) -> Bool) -> Self.SubSequence {
let dropLength = self.reversed().prefix(while: predicate)
return self.dropLast(dropLength.count)
}
}

fileprivate func commonPrefix(_ lhs: Substring, _ rhs: Substring) -> Substring {
return lhs[..<lhs.index(lhs.startIndex, offsetBy: zip(lhs, rhs).prefix { $0 == $1 }.count)]
}

fileprivate extension TriviaPiece {
var isDocComment: Bool {
switch self {
case .docBlockComment, .docLineComment: return true
default: return false
}
}
}
Loading