Skip to content

Commit b9cdf8b

Browse files
committed
Implement Trivia.docCommentValue to process doc comments
1 parent e8c3dcf commit b9cdf8b

File tree

3 files changed

+461
-0
lines changed

3 files changed

+461
-0
lines changed

Release Notes/602.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
- Pull request: https://github.com/swiftlang/swift-syntax/pull/3030
2626
- Migration stems: None required.
2727

28+
- `Trivia` has a new `docCommentValue` property.
29+
- Description: Extracts sanitized comment text from doc comment trivia pieces, omitting leading comment markers (`///`, `/**`).
30+
- Pull Request: https://github.com/swiftlang/swift-syntax/pull/2966
31+
2832
## API Behavior Changes
2933

3034
## Deprecations
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
extension Trivia {
14+
/// The contents of the last doc comment piece with any comment markers removed and indentation whitespace stripped.
15+
public var docCommentValue: String? {
16+
var comments: [Substring] = []
17+
18+
/// Keep track of whether we have seen a line or block comment trivia piece.
19+
var hasBlockComment = false
20+
21+
var currentLineComments: [Substring] = []
22+
var isInsideDocLineCommentSection = true
23+
var consecutiveNewlines = 0
24+
25+
for piece in pieces {
26+
switch piece {
27+
case .docBlockComment(let text):
28+
if let processedComment = processBlockComment(text) {
29+
if hasBlockComment {
30+
comments.append(processedComment)
31+
} else {
32+
hasBlockComment = true
33+
comments = [processedComment]
34+
}
35+
}
36+
currentLineComments = [] // Reset line comments when encountering a block comment
37+
consecutiveNewlines = 0
38+
case .docLineComment(let text):
39+
if isInsideDocLineCommentSection {
40+
currentLineComments.append(text[...])
41+
} else {
42+
currentLineComments = [text[...]]
43+
isInsideDocLineCommentSection = true
44+
}
45+
consecutiveNewlines = 0
46+
case .newlines(1), .carriageReturns(1), .carriageReturnLineFeeds(1):
47+
consecutiveNewlines += 1
48+
if consecutiveNewlines > 1 {
49+
processSectionBreak()
50+
}
51+
default:
52+
processSectionBreak()
53+
consecutiveNewlines = 0
54+
}
55+
}
56+
57+
/// Strips /** */ markers and removes any common indentation between the lines in the block comment.
58+
func processBlockComment(_ text: String) -> Substring? {
59+
var lines = text.dropPrefix("/**").dropSuffix("*/")
60+
.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
61+
62+
// If the comment content starts on the same line as the `/**` marker or ends on the same line as the `*/` marker,
63+
// it is common to separate the marker and the actual comment using spaces. Strip those spaces if they exists.
64+
// If there are non no-space characters on the first / last line, then the comment doesn't start / end on the line
65+
// with the marker, so don't do the stripping.
66+
if let firstLine = lines.first, firstLine.contains(where: { $0 != " " }) {
67+
lines[0] = firstLine.drop { $0 == " " }
68+
}
69+
if let lastLine = lines.last, lastLine.contains(where: { $0 != " " }) {
70+
lines[lines.count - 1] = lastLine.dropLast { $0 == " " }
71+
}
72+
73+
var indentation: Substring? = nil
74+
// Find the lowest indentation that is common among all lines in the block comment. Do not consider the first line
75+
// because it won't have any indentation since it starts with /**
76+
for line in lines.dropFirst() {
77+
let lineIndentation = line.prefix(while: { $0 == " " || $0 == "\t" })
78+
guard let previousIndentation = indentation else {
79+
indentation = lineIndentation
80+
continue
81+
}
82+
indentation = commonPrefix(previousIndentation, lineIndentation)
83+
}
84+
85+
guard let firstLine = lines.first else {
86+
// We did not have any lines. This should never happen in practice because `split` never returns an empty array
87+
// but be safe and return `nil` here anyway.
88+
return nil
89+
}
90+
91+
var unindentedLines = [firstLine] + lines.dropFirst().map { $0.dropPrefix(indentation ?? "") }
92+
93+
// If the first line only contained the comment marker, don't include it. We don't want to start the comment value
94+
// with a newline if `/**` is on its own line. Same for the end marker.
95+
if unindentedLines.first?.allSatisfy({ $0 == " " }) ?? false {
96+
unindentedLines.removeFirst()
97+
}
98+
if unindentedLines.last?.allSatisfy({ $0 == " " }) ?? false {
99+
unindentedLines.removeLast()
100+
}
101+
// We canonicalize the line endings to `\n` here. This matches how we concatenate the different line comment
102+
// pieces using \n as well.
103+
return unindentedLines.joined(separator: "\n")[...]
104+
}
105+
106+
/// Processes a section break, which is defined as a sequence of newlines or other trivia pieces that are not comments.
107+
func processSectionBreak() {
108+
// If we have a section break, we reset the current line comments.
109+
if !currentLineComments.isEmpty {
110+
comments = currentLineComments
111+
currentLineComments = []
112+
}
113+
isInsideDocLineCommentSection = false
114+
}
115+
116+
// If there are remaining line comments, use them as the last doc comment block.
117+
if !currentLineComments.isEmpty {
118+
comments = currentLineComments
119+
}
120+
121+
if comments.isEmpty { return nil }
122+
123+
let hasUniformSpace = comments.allSatisfy { $0.hasPrefix("/// ") }
124+
comments = comments.map { $0.dropPrefix(hasUniformSpace ? "/// " : "///") }
125+
126+
return comments.joined(separator: "\n")
127+
}
128+
}
129+
130+
fileprivate extension StringProtocol where SubSequence == Substring {
131+
func dropPrefix(_ prefix: some StringProtocol) -> Substring {
132+
if self.hasPrefix(prefix) {
133+
return self.dropFirst(prefix.count)
134+
}
135+
return self[...]
136+
}
137+
138+
func dropSuffix(_ suffix: some StringProtocol) -> Substring {
139+
if self.hasSuffix(suffix) {
140+
return self.dropLast(suffix.count)
141+
}
142+
return self[...]
143+
}
144+
145+
func dropLast(while predicate: (Self.Element) -> Bool) -> Self.SubSequence {
146+
let dropLength = self.reversed().prefix(while: predicate)
147+
return self.dropLast(dropLength.count)
148+
}
149+
}
150+
151+
fileprivate func commonPrefix(_ lhs: Substring, _ rhs: Substring) -> Substring {
152+
return lhs[..<lhs.index(lhs.startIndex, offsetBy: zip(lhs, rhs).prefix { $0 == $1 }.count)]
153+
}

0 commit comments

Comments
 (0)