Skip to content

Commit 9b153a9

Browse files
authored
Merge pull request #3230 from PhantomInTheWire/feat/docCommentValue
Add docCommentValue property to Trivia
2 parents 93ecd93 + 875deac commit 9b153a9

File tree

3 files changed

+482
-0
lines changed

3 files changed

+482
-0
lines changed

Release Notes/604.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Swift Syntax 604 Release Notes
2+
3+
## New APIs
4+
5+
- `Trivia` has a new `docCommentValue` property.
6+
- Description: Extracts sanitized comment text from doc comment trivia pieces, omitting leading comment markers (`///`, `/**`).
7+
- Pull Request: https://github.com/swiftlang/swift-syntax/pull/2966
8+
9+
10+
## API Behavior Changes
11+
12+
## Deprecations
13+
14+
## API-Incompatible Changes
15+
16+
## Template
17+
18+
- *Affected API or two word description*
19+
- Description: *A 1-2 sentence description of the new/modified API*
20+
- Issue: *If an issue exists for this change, a link to the issue*
21+
- Pull Request: *Link to the pull request(s) that introduces this change*
22+
- Migration steps: Steps that adopters of swift-syntax should take to move to the new API (required for deprecations and API-incompatible changes).
23+
- Notes: *In case of deprecations or API-incompatible changes, the reason why this change was made and the suggested alternative*
24+
25+
*Insert entries in chronological order, with newer entries at the bottom*
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
var currentLineComments: [String] = [] // Reset line comments when encountering a block comment
18+
var isInsideDocLineCommentSection = false
19+
var consecutiveNewlines = 0
20+
21+
for piece in pieces {
22+
switch piece {
23+
case .docBlockComment(let text):
24+
if let processedComment = processBlockComment(text) {
25+
comments = [processedComment]
26+
}
27+
currentLineComments = []
28+
consecutiveNewlines = 0
29+
case .docLineComment(let text):
30+
if isInsideDocLineCommentSection {
31+
currentLineComments.append(text)
32+
} else {
33+
currentLineComments = [text]
34+
isInsideDocLineCommentSection = true
35+
}
36+
consecutiveNewlines = 0
37+
38+
case .newlines(let n), .carriageReturns(let n), .carriageReturnLineFeeds(let n):
39+
consecutiveNewlines += n
40+
41+
if consecutiveNewlines != 1 {
42+
processSectionBreak()
43+
consecutiveNewlines = 0
44+
}
45+
default:
46+
processSectionBreak()
47+
consecutiveNewlines = 0
48+
}
49+
}
50+
51+
/// Strips /** */ markers and removes any common indentation between the lines in the block comment.
52+
func processBlockComment(_ text: String) -> Substring? {
53+
var lines = text.dropPrefix("/**").dropSuffix("*/")
54+
.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
55+
56+
// If the comment content starts on the same line as the `/**` marker or ends on the same line as the `*/` marker,
57+
// it is common to separate the marker and the actual comment using spaces. Strip those spaces if they exists.
58+
// If there are non no-space characters on the first / last line, then the comment doesn't start / end on the line
59+
// with the marker, so don't do the stripping.
60+
if let firstLine = lines.first, firstLine.contains(where: { $0 != " " }) {
61+
lines[0] = firstLine.drop { $0 == " " }
62+
}
63+
if let lastLine = lines.last, lastLine.contains(where: { $0 != " " }) {
64+
lines[lines.count - 1] = lastLine.dropLast { $0 == " " }
65+
}
66+
67+
// Find the lowest indentation that is common among all lines in the block comment. Do not consider the first line
68+
// because it won't have any indentation since it starts with /**
69+
let indentation = lines.dropFirst()
70+
.map { $0.prefix(while: { $0 == " " || $0 == "\t" }) }
71+
.reduce(nil as Substring?) { (acc: Substring?, element: Substring) in
72+
guard let acc else {
73+
return element
74+
}
75+
return commonPrefix(acc, element)
76+
}
77+
78+
guard let firstLine = lines.first else {
79+
// We did not have any lines. This should never happen in practice because `split` never returns an empty array
80+
// but be safe and return `nil` here anyway.
81+
return nil
82+
}
83+
84+
var unindentedLines = [firstLine] + lines.dropFirst().map { $0.dropPrefix(indentation ?? "") }
85+
86+
// If the first line only contained the comment marker, don't include it. We don't want to start the comment value
87+
// with a newline if `/**` is on its own line. Same for the end marker.
88+
if unindentedLines.first?.allSatisfy({ $0 == " " }) ?? false {
89+
unindentedLines.removeFirst()
90+
}
91+
if unindentedLines.last?.allSatisfy({ $0 == " " }) ?? false {
92+
unindentedLines.removeLast()
93+
}
94+
95+
// We canonicalize the line endings to `\n` here. This matches how we concatenate the different line comment
96+
// pieces using \n as well.
97+
return Substring(unindentedLines.joined(separator: "\n"))
98+
}
99+
100+
/// Processes a section break, which is defined as a sequence of newlines or other trivia pieces that are not comments.
101+
func processSectionBreak() {
102+
// If we have a section break, we reset the current line comments.
103+
if !currentLineComments.isEmpty {
104+
comments = currentLineComments.map { $0[...] }
105+
currentLineComments = []
106+
}
107+
isInsideDocLineCommentSection = false
108+
}
109+
110+
// If there are remaining line comments, use them as the last doc comment block.
111+
if !currentLineComments.isEmpty {
112+
comments = currentLineComments.map { $0[...] }
113+
}
114+
115+
if comments.isEmpty { return nil }
116+
117+
let prefix = comments.allSatisfy { $0.hasPrefix("/// ") } ? "/// " : "///"
118+
return comments.map { $0.dropPrefix(prefix) }.joined(separator: "\n")
119+
}
120+
}
121+
122+
fileprivate extension StringProtocol where SubSequence == Substring {
123+
func dropPrefix(_ prefix: some StringProtocol) -> Substring {
124+
if self.hasPrefix(prefix) {
125+
return self.dropFirst(prefix.count)
126+
}
127+
return self[...]
128+
}
129+
130+
func dropSuffix(_ suffix: some StringProtocol) -> Substring {
131+
if self.hasSuffix(suffix) {
132+
return self.dropLast(suffix.count)
133+
}
134+
return self[...]
135+
}
136+
137+
func dropLast(while predicate: (Self.Element) -> Bool) -> Self.SubSequence {
138+
let charactersToDrop = self.reversed().prefix(while: predicate)
139+
return self.dropLast(charactersToDrop.count)
140+
}
141+
}
142+
143+
private func commonPrefix(_ lhs: Substring, _ rhs: Substring) -> Substring {
144+
return lhs[..<lhs.index(lhs.startIndex, offsetBy: zip(lhs, rhs).prefix { $0 == $1 }.count)]
145+
}

0 commit comments

Comments
 (0)