From 085f2079ab644bcc841837fa702ab294bf66dac8 Mon Sep 17 00:00:00 2001 From: zyadtaha Date: Thu, 13 Feb 2025 14:31:47 +0200 Subject: [PATCH 1/5] Add Trivia.commentValues from @adammcarter - Copied changes from PR #2578 (https://github.com/swiftlang/swift-syntax/pull/2578) - Original work by @adammcarter - Manual copy due to inaccessible original branch --- Sources/SwiftSyntax/Trivia.swift | 62 +++++ Sources/SwiftSyntax/Utils.swift | 46 ++++ Tests/SwiftSyntaxTest/TriviaTests.swift | 321 ++++++++++++++++++++++++ 3 files changed, 429 insertions(+) diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index f45f8a781f9..e3d2611a224 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -42,6 +42,68 @@ public struct Trivia: Sendable { pieces.isEmpty } + /// The string contents of all the comment pieces with any comments tokens trimmed. + /// + /// Each element in the array is the trimmed contents of a line comment, or, in the case of a multi-line comment a trimmed, concatenated single string. + public var commentValues: [String] { + var comments = [String]() + var partialComments = [String]() + + var foundStartOfCodeBlock = false + var foundEndOfCodeBlock = false + var isInCodeBlock: Bool { foundStartOfCodeBlock && !foundEndOfCodeBlock } + + for piece in pieces { + switch piece { + case .blockComment(let text), .docBlockComment(let text): + let text = text.trimmingCharacters(in: "\n") + + foundStartOfCodeBlock = text.hasPrefix("/*") + foundEndOfCodeBlock = text.hasSuffix("*/") + + let sanitized = + text + .split(separator: "\n") + .map { $0.trimmingAnyCharacters(in: "/*").trimmingAnyCharacters(in: " ") } + .filter { !$0.isEmpty } + .joined(separator: " ") + + appendPartialCommentIfPossible(sanitized) + + case .lineComment(let text), .docLineComment(let text): + if isInCodeBlock { + appendPartialCommentIfPossible(text) + } else { + comments.append(String(text.trimmingPrefix("/ "))) + } + + default: + break + } + + if foundEndOfCodeBlock, !partialComments.isEmpty { + appendSubstringsToLines() + partialComments.removeAll() + } + } + + if !partialComments.isEmpty { + appendSubstringsToLines() + } + + func appendPartialCommentIfPossible(_ text: String) { + guard partialComments.isEmpty || !text.isEmpty else { return } + + partialComments.append(text) + } + + func appendSubstringsToLines() { + comments.append(partialComments.joined(separator: " ")) + } + + return comments + } + /// The length of all the pieces in this ``Trivia``. public var sourceLength: SourceLength { return pieces.map({ $0.sourceLength }).reduce(.zero, +) diff --git a/Sources/SwiftSyntax/Utils.swift b/Sources/SwiftSyntax/Utils.swift index f87dd535ae5..eb5be2f3f4c 100644 --- a/Sources/SwiftSyntax/Utils.swift +++ b/Sources/SwiftSyntax/Utils.swift @@ -102,3 +102,49 @@ extension RawUnexpectedNodesSyntax { self.init(raw: raw) } } + +extension String { + func trimmingCharacters(in charactersToTrim: any BidirectionalCollection) -> Substring { + // TODO: adammcarter - this feels a bit dirty + self[startIndex...].trimmingAnyCharacters(in: charactersToTrim) + } + + func trimmingPrefix(_ charactersToTrim: any BidirectionalCollection) -> Substring { + self[startIndex...].trimmingAnyCharactersFromPrefix(in: charactersToTrim) + } + + func trimmingSuffix(_ charactersToTrim: any BidirectionalCollection) -> Substring { + self[startIndex...].trimmingAnyCharactersFromSuffix(in: charactersToTrim) + } +} + +extension Substring { + func trimmingAnyCharacters(in charactersToTrim: any BidirectionalCollection) -> Substring { + trimmingAnyCharactersFromPrefix(in: charactersToTrim).trimmingAnyCharactersFromSuffix(in: charactersToTrim) + } + + func trimmingAnyCharactersFromPrefix(in charactersToTrim: any BidirectionalCollection) -> Self { + dropFirst(countOfSequentialCharacters(charactersToTrim, in: self)) + } + + func trimmingAnyCharactersFromSuffix(in charactersToTrim: any BidirectionalCollection) -> Self { + dropLast(countOfSequentialCharacters(charactersToTrim, in: reversed())) + } +} + +private func countOfSequentialCharacters( + _ charactersToCount: any BidirectionalCollection, + in characters: any BidirectionalCollection +) -> Int { + var count = 0 + + for character in characters { + if charactersToCount.contains(character) { + count += 1 + } else { + break + } + } + + return count +} \ No newline at end of file diff --git a/Tests/SwiftSyntaxTest/TriviaTests.swift b/Tests/SwiftSyntaxTest/TriviaTests.swift index 6360d2f3ec5..bba027bc491 100644 --- a/Tests/SwiftSyntaxTest/TriviaTests.swift +++ b/Tests/SwiftSyntaxTest/TriviaTests.swift @@ -69,4 +69,325 @@ class TriviaTests: XCTestCase { XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .unexpectedText("f")) XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .lineComment("e")) } + + func testTriviaCommentValues() { + XCTAssertTrue(Trivia(pieces: []).commentValues.isEmpty) + + // MARK: line comment + + XCTAssertEqual( + Trivia(pieces: [.lineComment("")]).commentValues, + [""] + ) + + XCTAssertEqual( + Trivia(pieces: [.lineComment("Some line comment")]).commentValues, + ["Some line comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [.lineComment("// Some line comment")]).commentValues, + ["Some line comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .lineComment("// Some line comment"), + .lineComment("// Another"), + ]).commentValues, + [ + "Some line comment", + "Another", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .lineComment("// Some line comment"), + .lineComment("Other"), + ]).commentValues, + [ + "Some line comment", + "Other", + ] + ) + + // MARK: doc line comment + + XCTAssertEqual( + Trivia(pieces: [.docLineComment("")]).commentValues, + [""] + ) + + XCTAssertEqual( + Trivia(pieces: [.docLineComment("Some doc line comment")]).commentValues, + ["Some doc line comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [.docLineComment("/// Some doc line comment")]).commentValues, + ["Some doc line comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docLineComment("/// Some doc line comment"), + .docLineComment("/// Another"), + ]).commentValues, + [ + "Some doc line comment", + "Another", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docLineComment("/// Some doc line comment"), + .docLineComment("Other"), + ]).commentValues, + [ + "Some doc line comment", + "Other", + ] + ) + + // MARK: block comment + + XCTAssertEqual( + Trivia(pieces: [.blockComment("")]).commentValues, + [""] + ) + + XCTAssertEqual( + Trivia(pieces: [.blockComment("Some block comment")]).commentValues, + ["Some block comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [.blockComment("/* Some block comment */")]).commentValues, + ["Some block comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment("/* Some block comment"), + .blockComment("* spread on many lines */"), + ]).commentValues, + [ + "Some block comment spread on many lines" + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment("/* Some block comment"), + .blockComment("* spread on many lines"), + .blockComment("*/"), + ]).commentValues, + [ + "Some block comment spread on many lines" + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment("/* Some block comment"), + .blockComment("* spread on many lines */"), + .blockComment("/* Another block comment */"), + ]).commentValues, + [ + "Some block comment spread on many lines", + "Another block comment", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment("/* Some block comment"), + .blockComment("* spread on many lines */"), + .newlines(2), + .blockComment("/* Another block comment */"), + ]).commentValues, + [ + "Some block comment spread on many lines", + "Another block comment", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment( + """ + /* + Some block comment + spread on many lines + */ + """ + ) + ]).commentValues, + ["Some block comment spread on many lines"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .blockComment( + """ + /* + * Some block comment + * spread on many lines + */ + """ + ) + ]).commentValues, + ["Some block comment spread on many lines"] + ) + + // MARK: doc block comment + + XCTAssertEqual( + Trivia(pieces: [.docBlockComment("")]).commentValues, + [""] + ) + + XCTAssertEqual( + Trivia(pieces: [.docBlockComment("Some doc block comment")]).commentValues, + ["Some doc block comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [.docBlockComment("/** Some doc block comment */")]).commentValues, + ["Some doc block comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/** Some doc block comment"), + .docBlockComment("* spread on many lines */"), + ]).commentValues, + [ + "Some doc block comment spread on many lines" + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/** Some doc block comment"), + .docBlockComment("* spread on many lines"), + .docBlockComment("*/"), + ]).commentValues, + [ + "Some doc block comment spread on many lines" + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/** Some doc block comment"), + .docBlockComment("* spread on many lines */"), + .docBlockComment("/** Another doc block comment */"), + ]).commentValues, + [ + "Some doc block comment spread on many lines", + "Another doc block comment", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/** Some doc block comment"), + .docBlockComment("* spread on many lines */"), + .newlines(2), + .docBlockComment("/** Another doc block comment */"), + ]).commentValues, + [ + "Some doc block comment spread on many lines", + "Another doc block comment", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment( + """ + /** + Some doc block comment + spread on many lines + */ + """ + ) + ]).commentValues, + ["Some doc block comment spread on many lines"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment( + """ + /** + * Some doc block comment + * spread on many lines + */ + """ + ) + ]).commentValues, + ["Some doc block comment spread on many lines"] + ) + + // MARK: Mixing comment styles + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment( + """ + /** + * Some doc block comment + * // spread on many lines + * with a line comment + */ + """ + ) + ]).commentValues, + ["Some doc block comment // spread on many lines with a line comment"] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/** Some doc block comment"), + .docBlockComment("* spread on many lines */"), + .newlines(2), + .docLineComment("/// Some doc line comment"), + .docLineComment("// Some line comment"), + .newlines(2), + .spaces(4), + .blockComment("/* Some block comment"), + .blockComment("* spread on many lines */"), + .newlines(2), + .docBlockComment("/** Another doc block comment */"), + ]).commentValues, + [ + "Some doc block comment spread on many lines", + "Some doc line comment", + "Some line comment", + "Some block comment spread on many lines", + "Another doc block comment", + ] + ) + + XCTAssertEqual( + Trivia(pieces: [ + .docBlockComment("/* Some block comment"), + .docLineComment("// A line comment in a block"), + .docBlockComment("* spread on many lines */"), + .newlines(2), + .blockComment("/** Some doc block comment"), + .docLineComment("/// A doc line comment in a block"), + .blockComment("* spread on"), + .blockComment("* many lines */"), + ]).commentValues, + [ + "Some block comment // A line comment in a block spread on many lines", + "Some doc block comment /// A doc line comment in a block spread on many lines", + ] + ) + } } From 04f700e3d2a2631e69a51691728ee390a46c3643 Mon Sep 17 00:00:00 2001 From: zyadtaha Date: Fri, 14 Feb 2025 10:23:23 +0200 Subject: [PATCH 2/5] Enhance comment trimming and update test cases Add release note for Trivia.commentValue Address PR review Address second review --- Release Notes/602.md | 4 + Sources/SwiftSyntax/Trivia.swift | 128 ++++-- Sources/SwiftSyntax/Utils.swift | 46 -- Tests/SwiftSyntaxTest/TriviaTests.swift | 563 +++++++++++++----------- 4 files changed, 403 insertions(+), 338 deletions(-) diff --git a/Release Notes/602.md b/Release Notes/602.md index e4a072d5f0d..c809561ac73 100644 --- a/Release Notes/602.md +++ b/Release Notes/602.md @@ -2,6 +2,10 @@ ## New APIs +- `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 diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index e3d2611a224..57c6374f4eb 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -43,65 +43,109 @@ public struct Trivia: Sendable { } /// The string contents of all the comment pieces with any comments tokens trimmed. - /// - /// Each element in the array is the trimmed contents of a line comment, or, in the case of a multi-line comment a trimmed, concatenated single string. - public var commentValues: [String] { + public var commentValue: String { var comments = [String]() - var partialComments = [String]() - var foundStartOfCodeBlock = false - var foundEndOfCodeBlock = false - var isInCodeBlock: Bool { foundStartOfCodeBlock && !foundEndOfCodeBlock } + // Determine if all line comments have a single space + lazy var allLineCommentsHaveSpace: Bool = { + return pieces.allSatisfy { piece in + switch piece { + case .lineComment(let text): + return text.hasPrefix("// ") + case .docLineComment(let text): + return text.hasPrefix("/// ") + default: + return true + } + } + }() + + // Helper function to trim leading and trailing whitespace + func trimWhitespace(_ text: String) -> String { + let trimmed = text.drop(while: { $0 == " " }) + .reversed() + .drop(while: { $0 == " " }) + .reversed() + return String(trimmed) + } - for piece in pieces { - switch piece { - case .blockComment(let text), .docBlockComment(let text): - let text = text.trimmingCharacters(in: "\n") + // Helper function to trim leading and trailing newlines + func trimNewlines(_ text: String) -> String { + let trimmed = text.drop(while: { $0 == "\n" }) + .reversed() + .drop(while: { $0 == "\n" }) + .reversed() + return String(trimmed) + } + + // Helper function to process block comments + func processBlockComment(_ text: String, prefix: String, suffix: String) -> String { + var text = text + text.removeFirst(prefix.count) + text.removeLast(suffix.count) + text = trimWhitespace(text) + text = trimNewlines(text) + return text + } - foundStartOfCodeBlock = text.hasPrefix("/*") - foundEndOfCodeBlock = text.hasSuffix("*/") + // Helper function to process multiline block comments + func processMultilineBlockComment(_ text: String) -> String { + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let sanitized = - text - .split(separator: "\n") - .map { $0.trimmingAnyCharacters(in: "/*").trimmingAnyCharacters(in: " ") } - .filter { !$0.isEmpty } - .joined(separator: " ") + lines.removeFirst() - appendPartialCommentIfPossible(sanitized) + let minIndentation = + lines + .filter { !$0.isEmpty } + .map { $0.prefix { $0 == " " }.count } + .min() ?? 0 - case .lineComment(let text), .docLineComment(let text): - if isInCodeBlock { - appendPartialCommentIfPossible(text) + if let lastLine = lines.last { + if trimWhitespace(lastLine) == "*/" { + lines.removeLast() } else { - comments.append(String(text.trimmingPrefix("/ "))) + lines[lines.count - 1].removeLast(2) + lines[lines.count - 1] = trimWhitespace(lines[lines.count - 1]) } - - default: - break } - if foundEndOfCodeBlock, !partialComments.isEmpty { - appendSubstringsToLines() - partialComments.removeAll() + let unindentedLines = lines.map { line in + guard line.count >= minIndentation else { return line } + return String(line.dropFirst(minIndentation)) } - } - if !partialComments.isEmpty { - appendSubstringsToLines() + return unindentedLines.joined(separator: "\n") } - func appendPartialCommentIfPossible(_ text: String) { - guard partialComments.isEmpty || !text.isEmpty else { return } - - partialComments.append(text) - } + for piece in pieces { + switch piece { + case .blockComment(let text): + let processedText = + text.hasPrefix("/*\n") + ? processMultilineBlockComment(text) + : processBlockComment(text, prefix: "/*", suffix: "*/") + comments.append(processedText) + + case .docBlockComment(let text): + let processedText = + text.hasPrefix("/**\n") + ? processMultilineBlockComment(text) + : processBlockComment(text, prefix: "/**", suffix: "*/") + comments.append(processedText) + + case .lineComment(let text): + let prefix = allLineCommentsHaveSpace ? "// " : "//" + comments.append(String(text.dropFirst(prefix.count))) + + case .docLineComment(let text): + let prefix = allLineCommentsHaveSpace ? "/// " : "///" + comments.append(String(text.dropFirst(prefix.count))) - func appendSubstringsToLines() { - comments.append(partialComments.joined(separator: " ")) + default: + break + } } - - return comments + return comments.joined(separator: "\n") } /// The length of all the pieces in this ``Trivia``. diff --git a/Sources/SwiftSyntax/Utils.swift b/Sources/SwiftSyntax/Utils.swift index eb5be2f3f4c..f87dd535ae5 100644 --- a/Sources/SwiftSyntax/Utils.swift +++ b/Sources/SwiftSyntax/Utils.swift @@ -102,49 +102,3 @@ extension RawUnexpectedNodesSyntax { self.init(raw: raw) } } - -extension String { - func trimmingCharacters(in charactersToTrim: any BidirectionalCollection) -> Substring { - // TODO: adammcarter - this feels a bit dirty - self[startIndex...].trimmingAnyCharacters(in: charactersToTrim) - } - - func trimmingPrefix(_ charactersToTrim: any BidirectionalCollection) -> Substring { - self[startIndex...].trimmingAnyCharactersFromPrefix(in: charactersToTrim) - } - - func trimmingSuffix(_ charactersToTrim: any BidirectionalCollection) -> Substring { - self[startIndex...].trimmingAnyCharactersFromSuffix(in: charactersToTrim) - } -} - -extension Substring { - func trimmingAnyCharacters(in charactersToTrim: any BidirectionalCollection) -> Substring { - trimmingAnyCharactersFromPrefix(in: charactersToTrim).trimmingAnyCharactersFromSuffix(in: charactersToTrim) - } - - func trimmingAnyCharactersFromPrefix(in charactersToTrim: any BidirectionalCollection) -> Self { - dropFirst(countOfSequentialCharacters(charactersToTrim, in: self)) - } - - func trimmingAnyCharactersFromSuffix(in charactersToTrim: any BidirectionalCollection) -> Self { - dropLast(countOfSequentialCharacters(charactersToTrim, in: reversed())) - } -} - -private func countOfSequentialCharacters( - _ charactersToCount: any BidirectionalCollection, - in characters: any BidirectionalCollection -) -> Int { - var count = 0 - - for character in characters { - if charactersToCount.contains(character) { - count += 1 - } else { - break - } - } - - return count -} \ No newline at end of file diff --git a/Tests/SwiftSyntaxTest/TriviaTests.swift b/Tests/SwiftSyntaxTest/TriviaTests.swift index bba027bc491..61a680f97fd 100644 --- a/Tests/SwiftSyntaxTest/TriviaTests.swift +++ b/Tests/SwiftSyntaxTest/TriviaTests.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import SwiftParser import SwiftSyntax import XCTest @@ -69,325 +70,387 @@ class TriviaTests: XCTestCase { XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .unexpectedText("f")) XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .lineComment("e")) } - + func testTriviaCommentValues() { - XCTAssertTrue(Trivia(pieces: []).commentValues.isEmpty) - // MARK: line comment + // MARK: Line comment - XCTAssertEqual( - Trivia(pieces: [.lineComment("")]).commentValues, - [""] - ) + assertCommentValue("//", commentValue: "") + assertCommentValue("// Some line comment", commentValue: "Some line comment") - XCTAssertEqual( - Trivia(pieces: [.lineComment("Some line comment")]).commentValues, - ["Some line comment"] + assertCommentValue( + """ + // Some line comment + // Another + """, + commentValue: "Some line comment\nAnother" ) - XCTAssertEqual( - Trivia(pieces: [.lineComment("// Some line comment")]).commentValues, - ["Some line comment"] + assertCommentValue( + """ + // - Task + // - Subtask + // - Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ ) - XCTAssertEqual( - Trivia(pieces: [ - .lineComment("// Some line comment"), - .lineComment("// Another"), - ]).commentValues, - [ - "Some line comment", - "Another", - ] + assertCommentValue( + """ + //- Task + // - Subtask + //- Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ ) - XCTAssertEqual( - Trivia(pieces: [ - .lineComment("// Some line comment"), - .lineComment("Other"), - ]).commentValues, - [ - "Some line comment", - "Other", - ] - ) + // MARK: Doc line comment - // MARK: doc line comment + assertCommentValue("/// Some doc line comment", commentValue: "Some doc line comment") - XCTAssertEqual( - Trivia(pieces: [.docLineComment("")]).commentValues, - [""] + assertCommentValue( + """ + /// Some doc line comment + /// Another + """, + commentValue: "Some doc line comment\nAnother" ) - XCTAssertEqual( - Trivia(pieces: [.docLineComment("Some doc line comment")]).commentValues, - ["Some doc line comment"] + assertCommentValue( + """ + /// - Task + /// - Subtask + /// - Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ ) - XCTAssertEqual( - Trivia(pieces: [.docLineComment("/// Some doc line comment")]).commentValues, - ["Some doc line comment"] + assertCommentValue( + """ + ///- Task + /// - Subtask + ///- Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ ) - XCTAssertEqual( - Trivia(pieces: [ - .docLineComment("/// Some doc line comment"), - .docLineComment("/// Another"), - ]).commentValues, - [ - "Some doc line comment", - "Another", - ] - ) + // MARK: Block comment - XCTAssertEqual( - Trivia(pieces: [ - .docLineComment("/// Some doc line comment"), - .docLineComment("Other"), - ]).commentValues, - [ - "Some doc line comment", - "Other", - ] - ) + assertCommentValue("/* Some block comment */", commentValue: "Some block comment") - // MARK: block comment + assertCommentValue( + """ + /* Some block comment + * spread on many lines */ + """, + commentValue: "Some block comment\n* spread on many lines" + ) - XCTAssertEqual( - Trivia(pieces: [.blockComment("")]).commentValues, - [""] + assertCommentValue( + """ + /* Some block comment + * spread on many lines + */ + """, + commentValue: "Some block comment\n* spread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [.blockComment("Some block comment")]).commentValues, - ["Some block comment"] + assertCommentValue( + """ + /* + Some block comment + spread on many lines */ + """, + commentValue: "Some block comment\nspread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [.blockComment("/* Some block comment */")]).commentValues, - ["Some block comment"] + assertCommentValue( + """ + /* Some block comment + * spread on many lines */ + /* Another block comment */ + """, + commentValue: "Some block comment\n* spread on many lines\nAnother block comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment("/* Some block comment"), - .blockComment("* spread on many lines */"), - ]).commentValues, - [ - "Some block comment spread on many lines" - ] + assertCommentValue( + """ + /* Some block comment + * spread on many lines */ + + /* Another block comment */ + """, + commentValue: "Some block comment\n* spread on many lines\nAnother block comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment("/* Some block comment"), - .blockComment("* spread on many lines"), - .blockComment("*/"), - ]).commentValues, - [ - "Some block comment spread on many lines" - ] + assertCommentValue( + """ + /* + * Some block comment + * spread on many lines + */ + """, + commentValue: "* Some block comment\n* spread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment("/* Some block comment"), - .blockComment("* spread on many lines */"), - .blockComment("/* Another block comment */"), - ]).commentValues, - [ - "Some block comment spread on many lines", - "Another block comment", - ] + assertCommentValue( + """ + /* + Paragraph 1 + + Paragraph 2 + */ + """, + commentValue: "Paragraph 1\n\nParagraph 2" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment("/* Some block comment"), - .blockComment("* spread on many lines */"), - .newlines(2), - .blockComment("/* Another block comment */"), - ]).commentValues, - [ - "Some block comment spread on many lines", - "Another block comment", - ] + assertCommentValue( + """ + /* + /abc + */ + """, + commentValue: "/abc" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment( - """ + assertCommentValue("/* ///// abc */", commentValue: "///// abc") + + assertCommentValue( + """ /* Some block comment - spread on many lines + with another line */ - """ - ) - ]).commentValues, - ["Some block comment spread on many lines"] + """, + commentValue: "Some block comment\nwith another line" ) - XCTAssertEqual( - Trivia(pieces: [ - .blockComment( - """ - /* - * Some block comment - * spread on many lines - */ - """ - ) - ]).commentValues, - ["Some block comment spread on many lines"] + assertCommentValue( + """ + /* + Some block comment + with another line + */ + """, + commentValue: " Some block comment\n with another line" + ) + + assertCommentValue( + """ + /* + unindented line + */ + """, + commentValue: "unindented line" ) - // MARK: doc block comment + // MARK: Doc block comment - XCTAssertEqual( - Trivia(pieces: [.docBlockComment("")]).commentValues, - [""] + assertCommentValue( + """ + /** Some doc block comment */ + """, + commentValue: "Some doc block comment" ) - XCTAssertEqual( - Trivia(pieces: [.docBlockComment("Some doc block comment")]).commentValues, - ["Some doc block comment"] + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + """, + commentValue: "Some doc block comment\n* spread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [.docBlockComment("/** Some doc block comment */")]).commentValues, - ["Some doc block comment"] + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines + */ + """, + commentValue: "Some doc block comment\n* spread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/** Some doc block comment"), - .docBlockComment("* spread on many lines */"), - ]).commentValues, - [ - "Some doc block comment spread on many lines" - ] + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + /** Another doc block comment */ + """, + commentValue: "Some doc block comment\n* spread on many lines\nAnother doc block comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/** Some doc block comment"), - .docBlockComment("* spread on many lines"), - .docBlockComment("*/"), - ]).commentValues, - [ - "Some doc block comment spread on many lines" - ] + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + + /** Another doc block comment */ + """, + commentValue: "Some doc block comment\n* spread on many lines\nAnother doc block comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/** Some doc block comment"), - .docBlockComment("* spread on many lines */"), - .docBlockComment("/** Another doc block comment */"), - ]).commentValues, - [ - "Some doc block comment spread on many lines", - "Another doc block comment", - ] + assertCommentValue( + """ + /** + Some doc block comment + spread on many lines + */ + """, + commentValue: "Some doc block comment\nspread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/** Some doc block comment"), - .docBlockComment("* spread on many lines */"), - .newlines(2), - .docBlockComment("/** Another doc block comment */"), - ]).commentValues, - [ - "Some doc block comment spread on many lines", - "Another doc block comment", - ] + assertCommentValue( + """ + /** + * Some doc block comment + * spread on many lines + */ + """, + commentValue: "* Some doc block comment\n* spread on many lines" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment( - """ - /** - Some doc block comment - spread on many lines - */ - """ - ) - ]).commentValues, - ["Some doc block comment spread on many lines"] + assertCommentValue( + """ + /** + * Some doc block comment + * with a line comment + */ + """, + commentValue: "* Some doc block comment\n* with a line comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment( - """ + assertCommentValue("/** ///// abc */", commentValue: "///// abc") + + assertCommentValue( + """ /** - * Some doc block comment - * spread on many lines + Some block comment + with another line */ - """ - ) - ]).commentValues, - ["Some doc block comment spread on many lines"] + """, + commentValue: "Some block comment\nwith another line" + ) + + assertCommentValue( + """ + /** + Some block comment + with another line + */ + """, + commentValue: " Some block comment\n with another line" ) // MARK: Mixing comment styles - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment( - """ - /** - * Some doc block comment - * // spread on many lines - * with a line comment - */ - """ - ) - ]).commentValues, - ["Some doc block comment // spread on many lines with a line comment"] + assertCommentValue( + """ + /** + * Some doc block comment + * // spread on many lines + * with a line comment + */ + """, + commentValue: "* Some doc block comment\n* // spread on many lines\n* with a line comment" + ) + + assertCommentValue( + """ + /** + * Some doc block comment + * // spread on many lines + * with a line comment + */ + """, + commentValue: "* Some doc block comment\n* // spread on many lines\n* with a line comment" ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/** Some doc block comment"), - .docBlockComment("* spread on many lines */"), - .newlines(2), - .docLineComment("/// Some doc line comment"), - .docLineComment("// Some line comment"), - .newlines(2), - .spaces(4), - .blockComment("/* Some block comment"), - .blockComment("* spread on many lines */"), - .newlines(2), - .docBlockComment("/** Another doc block comment */"), - ]).commentValues, - [ - "Some doc block comment spread on many lines", - "Some doc line comment", - "Some line comment", - "Some block comment spread on many lines", - "Another doc block comment", - ] + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + + /// Some doc line comment + // Some line comment + + /* Some block comment + * spread on many lines */ + /** Another doc block comment */ + """, + commentValue: """ + Some doc block comment + * spread on many lines + Some doc line comment + Some line comment + Some block comment + * spread on many lines + Another doc block comment + """ ) - XCTAssertEqual( - Trivia(pieces: [ - .docBlockComment("/* Some block comment"), - .docLineComment("// A line comment in a block"), - .docBlockComment("* spread on many lines */"), - .newlines(2), - .blockComment("/** Some doc block comment"), - .docLineComment("/// A doc line comment in a block"), - .blockComment("* spread on"), - .blockComment("* many lines */"), - ]).commentValues, - [ - "Some block comment // A line comment in a block spread on many lines", - "Some doc block comment /// A doc line comment in a block spread on many lines", - ] + assertCommentValue( + """ + /* Some block comment + * // A line comment in a block + * spread on many lines */ + /** Some doc block comment + * /// A doc line comment in a block + * spread on + * many lines */ + """, + commentValue: """ + Some block comment + * // A line comment in a block + * spread on many lines + Some doc block comment + * /// A doc line comment in a block + * spread on + * many lines + """ ) } } + +func assertCommentValue( + _ input: String, + commentValue expected: String, + file: StaticString = #filePath, + line: UInt = #line +) { + let trivia = parseTrivia(from: input) + XCTAssertEqual(trivia.commentValue, expected, file: file, line: line) +} + +private func parseTrivia(from input: String) -> Trivia { + // Wrap the input in valid Swift code so the parser can recognize it + let wrappedSource = "let _ = 0\n\(input)\nlet _ = 1" + + let sourceFile = Parser.parse(source: wrappedSource) + + // Find the token where the comment would appear (before `let _ = 1`) + guard + let commentToken = sourceFile.tokens(viewMode: .sourceAccurate).first(where: { + $0.leadingTrivia.contains(where: { $0.isComment }) + }) + else { + return [] + } + + return commentToken.leadingTrivia +} From 312bbc9180d58afaca6724875cb905832a9aeb55 Mon Sep 17 00:00:00 2001 From: zyadtaha Date: Mon, 14 Apr 2025 13:42:07 +0200 Subject: [PATCH 3/5] Address third review --- Sources/SwiftSyntax/Trivia.swift | 101 +++++++++++------------- Tests/SwiftSyntaxTest/TriviaTests.swift | 8 ++ 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index 57c6374f4eb..77b68125483 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -44,7 +44,7 @@ public struct Trivia: Sendable { /// The string contents of all the comment pieces with any comments tokens trimmed. public var commentValue: String { - var comments = [String]() + var comments = [Substring]() // Determine if all line comments have a single space lazy var allLineCommentsHaveSpace: Bool = { @@ -60,58 +60,57 @@ public struct Trivia: Sendable { } }() - // Helper function to trim leading and trailing whitespace - func trimWhitespace(_ text: String) -> String { - let trimmed = text.drop(while: { $0 == " " }) - .reversed() - .drop(while: { $0 == " " }) - .reversed() - return String(trimmed) + // Returns a substring with leading and trailing spaces removed. + func trimWhitespace(_ text: Substring) -> Substring { + let trimmed = text.drop(while: { $0 == " " }) + let reversed = trimmed.reversed() + let trimmedEnd = reversed.drop(while: { $0 == " " }) + let final = trimmedEnd.reversed() + return Substring(final) } - // Helper function to trim leading and trailing newlines - func trimNewlines(_ text: String) -> String { - let trimmed = text.drop(while: { $0 == "\n" }) - .reversed() - .drop(while: { $0 == "\n" }) - .reversed() - return String(trimmed) - } - - // Helper function to process block comments - func processBlockComment(_ text: String, prefix: String, suffix: String) -> String { - var text = text - text.removeFirst(prefix.count) - text.removeLast(suffix.count) - text = trimWhitespace(text) - text = trimNewlines(text) - return text - } - - // Helper function to process multiline block comments - func processMultilineBlockComment(_ text: String) -> String { - var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - - lines.removeFirst() + // Strips /* */ markers and aligns content by removing common indentation. + func processBlockComment(_ text: Substring) -> String { + var lines = text.split(separator: "\n", omittingEmptySubsequences: false) let minIndentation = lines + .dropFirst() .filter { !$0.isEmpty } .map { $0.prefix { $0 == " " }.count } .min() ?? 0 + var firstLineRemoved = false; + var firstLine = lines[0] + if trimWhitespace(firstLine) == "/*" || trimWhitespace(firstLine) == "/**" { + lines.removeFirst() + firstLineRemoved = true; + } else { + firstLine = firstLine.hasPrefix("/**") ? firstLine.dropFirst(3) : firstLine.dropFirst(2) + while firstLine.first?.isWhitespace == true { + firstLine = firstLine.dropFirst() + } + lines[0] = firstLine + } + if let lastLine = lines.last { - if trimWhitespace(lastLine) == "*/" { - lines.removeLast() - } else { - lines[lines.count - 1].removeLast(2) - lines[lines.count - 1] = trimWhitespace(lines[lines.count - 1]) - } + if trimWhitespace(lastLine) == "*/" { + lines.removeLast() + } else { + var lastLine = lines[lines.count - 1] + lastLine = lastLine.hasSuffix("*/") ? lastLine.dropLast(2) : lastLine + while lastLine.last?.isWhitespace == true { + lastLine = lastLine.dropLast() + } + lines[lines.count - 1] = lastLine + } } - let unindentedLines = lines.map { line in - guard line.count >= minIndentation else { return line } - return String(line.dropFirst(minIndentation)) + let unindentedLines = lines.enumerated().map { index, line -> Substring in + if index == 0 && firstLineRemoved == false { + return line + } + return line.count >= minIndentation ? line.dropFirst(minIndentation) : line } return unindentedLines.joined(separator: "\n") @@ -119,27 +118,17 @@ public struct Trivia: Sendable { for piece in pieces { switch piece { - case .blockComment(let text): - let processedText = - text.hasPrefix("/*\n") - ? processMultilineBlockComment(text) - : processBlockComment(text, prefix: "/*", suffix: "*/") - comments.append(processedText) - - case .docBlockComment(let text): - let processedText = - text.hasPrefix("/**\n") - ? processMultilineBlockComment(text) - : processBlockComment(text, prefix: "/**", suffix: "*/") - comments.append(processedText) + case .blockComment(let text), .docBlockComment(let text): + let processedText = processBlockComment(text[...]) + comments.append(processedText[...]) case .lineComment(let text): let prefix = allLineCommentsHaveSpace ? "// " : "//" - comments.append(String(text.dropFirst(prefix.count))) + comments.append(text.dropFirst(prefix.count)) case .docLineComment(let text): let prefix = allLineCommentsHaveSpace ? "/// " : "///" - comments.append(String(text.dropFirst(prefix.count))) + comments.append(text.dropFirst(prefix.count)) default: break diff --git a/Tests/SwiftSyntaxTest/TriviaTests.swift b/Tests/SwiftSyntaxTest/TriviaTests.swift index 61a680f97fd..6f10ea0c5aa 100644 --- a/Tests/SwiftSyntaxTest/TriviaTests.swift +++ b/Tests/SwiftSyntaxTest/TriviaTests.swift @@ -260,6 +260,14 @@ class TriviaTests: XCTestCase { commentValue: "unindented line" ) + assertCommentValue( + """ + /* + Comment + Comment */ + """, + commentValue: "Comment\n Comment") + // MARK: Doc block comment assertCommentValue( From 52fdc8360609316fa7ec890b01ec49a2d4561e9a Mon Sep 17 00:00:00 2001 From: zyadtaha Date: Sat, 19 Apr 2025 17:12:57 +0200 Subject: [PATCH 4/5] Address fourth review --- Sources/SwiftSyntax/Trivia.swift | 106 ++++++++++++++++-------- Tests/SwiftSyntaxTest/TriviaTests.swift | 94 ++++++--------------- 2 files changed, 98 insertions(+), 102 deletions(-) diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index 77b68125483..743e1342bb4 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -43,8 +43,10 @@ public struct Trivia: Sendable { } /// The string contents of all the comment pieces with any comments tokens trimmed. - public var commentValue: String { + public var commentValue: String? { var comments = [Substring]() + var hasBlockComment = false + var hasLineComment = false // Determine if all line comments have a single space lazy var allLineCommentsHaveSpace: Bool = { @@ -62,71 +64,96 @@ public struct Trivia: Sendable { // Returns a substring with leading and trailing spaces removed. func trimWhitespace(_ text: Substring) -> Substring { - let trimmed = text.drop(while: { $0 == " " }) - let reversed = trimmed.reversed() - let trimmedEnd = reversed.drop(while: { $0 == " " }) - let final = trimmedEnd.reversed() - return Substring(final) + let trimmed = text.drop(while: { $0 == " " }) + let reversed = trimmed.reversed() + let trimmedEnd = reversed.drop(while: { $0 == " " }) + let final = trimmedEnd.reversed() + return Substring(final) } // Strips /* */ markers and aligns content by removing common indentation. func processBlockComment(_ text: Substring) -> String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false) - let minIndentation = + let (minSpaceIndentation, minTabIndentation) = lines .dropFirst() .filter { !$0.isEmpty } - .map { $0.prefix { $0 == " " }.count } - .min() ?? 0 - - var firstLineRemoved = false; - var firstLine = lines[0] - if trimWhitespace(firstLine) == "/*" || trimWhitespace(firstLine) == "/**" { - lines.removeFirst() - firstLineRemoved = true; - } else { - firstLine = firstLine.hasPrefix("/**") ? firstLine.dropFirst(3) : firstLine.dropFirst(2) - while firstLine.first?.isWhitespace == true { - firstLine = firstLine.dropFirst() + .reduce((Int.max, Int.max)) { (currentMin, line) in + var spaceCount = 0, tabCount = 0 + var inLeadingWhitespace = true + + for char in line { + guard inLeadingWhitespace else { break } + + switch char { + case " ": + spaceCount += 1 + case "\t": + tabCount += 1 + default: + inLeadingWhitespace = false + } } - lines[0] = firstLine + + return (Swift.min(currentMin.0, spaceCount), Swift.min(currentMin.1, tabCount)) + } + + var minIndentation = minSpaceIndentation == Int.max ? 0 : minSpaceIndentation + minIndentation += minTabIndentation == Int.max ? 0 : minTabIndentation + + if let first = lines.first { + let prefixToDrop = first.hasPrefix("/**") ? 3 : 2 + lines[0] = first.dropFirst(prefixToDrop) } - - if let lastLine = lines.last { - if trimWhitespace(lastLine) == "*/" { - lines.removeLast() - } else { - var lastLine = lines[lines.count - 1] - lastLine = lastLine.hasSuffix("*/") ? lastLine.dropLast(2) : lastLine - while lastLine.last?.isWhitespace == true { - lastLine = lastLine.dropLast() - } - lines[lines.count - 1] = lastLine - } + + var firstLineRemoved = false + if trimWhitespace(lines[0]).isEmpty { + lines.removeFirst() + firstLineRemoved = true } - let unindentedLines = lines.enumerated().map { index, line -> Substring in + var unindentedLines = lines.enumerated().map { index, line -> Substring in if index == 0 && firstLineRemoved == false { - return line + return line } return line.count >= minIndentation ? line.dropFirst(minIndentation) : line } + if let last = unindentedLines.last, last.hasSuffix("*/") { + unindentedLines[unindentedLines.count - 1] = last.dropLast(2) + } + + if trimWhitespace(unindentedLines[unindentedLines.count - 1]).isEmpty { + unindentedLines.removeLast() + } + 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 let processedText = processBlockComment(text[...]) comments.append(processedText[...]) case .lineComment(let text): + if hasBlockComment { + return nil + } + hasLineComment = true let prefix = allLineCommentsHaveSpace ? "// " : "//" comments.append(text.dropFirst(prefix.count)) case .docLineComment(let text): + if hasBlockComment { + return nil + } + hasLineComment = true let prefix = allLineCommentsHaveSpace ? "/// " : "///" comments.append(text.dropFirst(prefix.count)) @@ -134,7 +161,16 @@ public struct Trivia: Sendable { break } } - return comments.joined(separator: "\n") + + guard !comments.isEmpty else { return nil } + + // If we have multiple line comments, they can be joined with newlines + if hasLineComment { + return comments.joined(separator: "\n") + } + + // In case of block comments, we should only have one + return comments.first.map(String.init) } /// The length of all the pieces in this ``Trivia``. diff --git a/Tests/SwiftSyntaxTest/TriviaTests.swift b/Tests/SwiftSyntaxTest/TriviaTests.swift index 6f10ea0c5aa..255e728077e 100644 --- a/Tests/SwiftSyntaxTest/TriviaTests.swift +++ b/Tests/SwiftSyntaxTest/TriviaTests.swift @@ -152,14 +152,14 @@ class TriviaTests: XCTestCase { // MARK: Block comment - assertCommentValue("/* Some block comment */", commentValue: "Some block comment") + assertCommentValue("/* Some block comment */", commentValue: " Some block comment ") assertCommentValue( """ /* Some block comment * spread on many lines */ """, - commentValue: "Some block comment\n* spread on many lines" + commentValue: " Some block comment\n* spread on many lines " ) assertCommentValue( @@ -168,7 +168,7 @@ class TriviaTests: XCTestCase { * spread on many lines */ """, - commentValue: "Some block comment\n* spread on many lines" + commentValue: " Some block comment\n* spread on many lines" ) assertCommentValue( @@ -177,26 +177,7 @@ class TriviaTests: XCTestCase { Some block comment spread on many lines */ """, - commentValue: "Some block comment\nspread on many lines" - ) - - assertCommentValue( - """ - /* Some block comment - * spread on many lines */ - /* Another block comment */ - """, - commentValue: "Some block comment\n* spread on many lines\nAnother block comment" - ) - - assertCommentValue( - """ - /* Some block comment - * spread on many lines */ - - /* Another block comment */ - """, - commentValue: "Some block comment\n* spread on many lines\nAnother block comment" + commentValue: "Some block comment\nspread on many lines " ) assertCommentValue( @@ -229,7 +210,7 @@ class TriviaTests: XCTestCase { commentValue: "/abc" ) - assertCommentValue("/* ///// abc */", commentValue: "///// abc") + assertCommentValue("/* ///// abc */", commentValue: " ///// abc ") assertCommentValue( """ @@ -266,51 +247,51 @@ class TriviaTests: XCTestCase { Comment Comment */ """, - commentValue: "Comment\n Comment") - - // MARK: Doc block comment + commentValue: "Comment\n Comment " + ) assertCommentValue( """ - /** Some doc block comment */ + /* + \t\tComment + \t\t\tComment */ """, - commentValue: "Some doc block comment" + commentValue: "Comment\n\tComment " ) assertCommentValue( """ - /** Some doc block comment - * spread on many lines */ + /* + \t\tComment + \t\t\tComment */ """, - commentValue: "Some doc block comment\n* spread on many lines" + commentValue: "\tComment\n\tComment " ) + // MARK: Doc block comment + assertCommentValue( """ - /** Some doc block comment - * spread on many lines - */ + /** Some doc block comment */ """, - commentValue: "Some doc block comment\n* spread on many lines" + commentValue: " Some doc block comment " ) assertCommentValue( """ /** Some doc block comment * spread on many lines */ - /** Another doc block comment */ """, - commentValue: "Some doc block comment\n* spread on many lines\nAnother doc block comment" + commentValue: " Some doc block comment\n* spread on many lines " ) assertCommentValue( """ /** Some doc block comment - * spread on many lines */ - - /** Another doc block comment */ + * spread on many lines + */ """, - commentValue: "Some doc block comment\n* spread on many lines\nAnother doc block comment" + commentValue: " Some doc block comment\n* spread on many lines" ) assertCommentValue( @@ -343,7 +324,7 @@ class TriviaTests: XCTestCase { commentValue: "* Some doc block comment\n* with a line comment" ) - assertCommentValue("/** ///// abc */", commentValue: "///// abc") + assertCommentValue("/** ///// abc */", commentValue: " ///// abc ") assertCommentValue( """ @@ -396,20 +377,8 @@ class TriviaTests: XCTestCase { /// Some doc line comment // Some line comment - - /* Some block comment - * spread on many lines */ - /** Another doc block comment */ """, - commentValue: """ - Some doc block comment - * spread on many lines - Some doc line comment - Some line comment - Some block comment - * spread on many lines - Another doc block comment - """ + commentValue: nil ) assertCommentValue( @@ -418,26 +387,17 @@ class TriviaTests: XCTestCase { * // A line comment in a block * spread on many lines */ /** Some doc block comment - * /// A doc line comment in a block * spread on * many lines */ """, - commentValue: """ - Some block comment - * // A line comment in a block - * spread on many lines - Some doc block comment - * /// A doc line comment in a block - * spread on - * many lines - """ + commentValue: nil ) } } func assertCommentValue( _ input: String, - commentValue expected: String, + commentValue expected: String?, file: StaticString = #filePath, line: UInt = #line ) { From 2197103cbcd2a3f89901734ecfca76010b73140e Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Wed, 23 Apr 2025 20:30:18 +0200 Subject: [PATCH 5/5] Adjust PR to address comments I raised in PR feedback and address other comments found while addressing those --- Sources/SwiftSyntax/Trivia+commentValue.swift | 143 ++++++ Sources/SwiftSyntax/Trivia.swift | 131 ----- .../TriviaCommentValueTests.swift | 450 ++++++++++++++++++ Tests/SwiftSyntaxTest/TriviaTests.swift | 351 -------------- 4 files changed, 593 insertions(+), 482 deletions(-) create mode 100644 Sources/SwiftSyntax/Trivia+commentValue.swift create mode 100644 Tests/SwiftSyntaxTest/TriviaCommentValueTests.swift diff --git a/Sources/SwiftSyntax/Trivia+commentValue.swift b/Sources/SwiftSyntax/Trivia+commentValue.swift new file mode 100644 index 00000000000..f9162711180 --- /dev/null +++ b/Sources/SwiftSyntax/Trivia+commentValue.swift @@ -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 { + 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[.. Substring { - let trimmed = text.drop(while: { $0 == " " }) - let reversed = trimmed.reversed() - let trimmedEnd = reversed.drop(while: { $0 == " " }) - let final = trimmedEnd.reversed() - return Substring(final) - } - - // Strips /* */ markers and aligns content by removing common indentation. - func processBlockComment(_ text: Substring) -> String { - var lines = text.split(separator: "\n", omittingEmptySubsequences: false) - - let (minSpaceIndentation, minTabIndentation) = - lines - .dropFirst() - .filter { !$0.isEmpty } - .reduce((Int.max, Int.max)) { (currentMin, line) in - var spaceCount = 0, tabCount = 0 - var inLeadingWhitespace = true - - for char in line { - guard inLeadingWhitespace else { break } - - switch char { - case " ": - spaceCount += 1 - case "\t": - tabCount += 1 - default: - inLeadingWhitespace = false - } - } - - return (Swift.min(currentMin.0, spaceCount), Swift.min(currentMin.1, tabCount)) - } - - var minIndentation = minSpaceIndentation == Int.max ? 0 : minSpaceIndentation - minIndentation += minTabIndentation == Int.max ? 0 : minTabIndentation - - if let first = lines.first { - let prefixToDrop = first.hasPrefix("/**") ? 3 : 2 - lines[0] = first.dropFirst(prefixToDrop) - } - - var firstLineRemoved = false - if trimWhitespace(lines[0]).isEmpty { - lines.removeFirst() - firstLineRemoved = true - } - - var unindentedLines = lines.enumerated().map { index, line -> Substring in - if index == 0 && firstLineRemoved == false { - return line - } - return line.count >= minIndentation ? line.dropFirst(minIndentation) : line - } - - if let last = unindentedLines.last, last.hasSuffix("*/") { - unindentedLines[unindentedLines.count - 1] = last.dropLast(2) - } - - if trimWhitespace(unindentedLines[unindentedLines.count - 1]).isEmpty { - unindentedLines.removeLast() - } - - 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 - let processedText = processBlockComment(text[...]) - comments.append(processedText[...]) - - case .lineComment(let text): - if hasBlockComment { - return nil - } - hasLineComment = true - let prefix = allLineCommentsHaveSpace ? "// " : "//" - comments.append(text.dropFirst(prefix.count)) - - case .docLineComment(let text): - if hasBlockComment { - return nil - } - hasLineComment = true - let prefix = allLineCommentsHaveSpace ? "/// " : "///" - comments.append(text.dropFirst(prefix.count)) - - default: - break - } - } - - guard !comments.isEmpty else { return nil } - - // If we have multiple line comments, they can be joined with newlines - if hasLineComment { - return comments.joined(separator: "\n") - } - - // In case of block comments, we should only have one - return comments.first.map(String.init) - } - /// The length of all the pieces in this ``Trivia``. public var sourceLength: SourceLength { return pieces.map({ $0.sourceLength }).reduce(.zero, +) diff --git a/Tests/SwiftSyntaxTest/TriviaCommentValueTests.swift b/Tests/SwiftSyntaxTest/TriviaCommentValueTests.swift new file mode 100644 index 00000000000..f5a9607a612 --- /dev/null +++ b/Tests/SwiftSyntaxTest/TriviaCommentValueTests.swift @@ -0,0 +1,450 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import SwiftParser +import SwiftSyntax +import XCTest + +class TriviaCommentValueTests: XCTestCase { + func testLineCommentValues() { + assertCommentValue("//", commentValue: "") + assertCommentValue("// Some line comment", commentValue: "Some line comment") + + assertCommentValue( + """ + // Some line comment + // Another + """, + commentValue: "Some line comment\nAnother" + ) + + assertCommentValue( + """ + // - Task + // - Subtask + // - Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ + ) + + assertCommentValue( + """ + //- Task + // - Subtask + //- Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ + ) + + assertCommentValue( + """ + // abc + + // def + """, + commentValue: """ + abc + def + """ + ) + } + + func testDocLineCommentValues() { + assertCommentValue("///", commentValue: "") + + assertCommentValue("/// Some doc line comment", commentValue: "Some doc line comment") + + assertCommentValue( + """ + /// Some doc line comment + /// Another + """, + commentValue: "Some doc line comment\nAnother" + ) + + assertCommentValue( + """ + /// - Task + /// - Subtask + /// - Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ + ) + + assertCommentValue( + """ + ///- Task + /// - Subtask + ///- Task 2 + """, + commentValue: """ + - Task + - Subtask + - Task 2 + """ + ) + } + + func testBlockCommentValues() { + assertCommentValue("/**/", commentValue: "") + + assertCommentValue("/* Some block comment */", commentValue: "Some block comment") + + assertCommentValue( + """ + /* Some block comment + * spread on many lines */ + """, + commentValue: """ + Some block comment + * spread on many lines + """ + ) + + assertCommentValue( + """ + /* Some block comment + * spread on many lines + */ + """, + commentValue: """ + Some block comment + * spread on many lines + """ + ) + + assertCommentValue( + """ + /* + Some block comment + spread on many lines */ + """, + commentValue: """ + Some block comment + spread on many lines + """ + ) + + assertCommentValue( + """ + /* + * Some block comment + * spread on many lines + */ + """, + commentValue: """ + * Some block comment + * spread on many lines + """ + ) + + assertCommentValue( + """ + /* + Paragraph 1 + + Paragraph 2 + */ + """, + commentValue: """ + Paragraph 1 + + Paragraph 2 + """ + ) + + assertCommentValue( + """ + /* + /abc + */ + """, + commentValue: "/abc" + ) + + assertCommentValue("/* ///// abc */", commentValue: "///// abc") + + assertCommentValue( + """ + /* + Some block comment + with another line + */ + """, + commentValue: """ + Some block comment + with another line + """ + ) + + assertCommentValue( + """ + /* + Some block comment + with another line + */ + """, + commentValue: """ + Some block comment + with another line + """ + ) + + assertCommentValue( + """ + /* + unindented line + */ + """, + commentValue: "unindented line" + ) + + assertCommentValue( + """ + /* + Comment + Comment */ + """, + commentValue: """ + Comment + Comment + """ + ) + + assertCommentValue( + """ + /* + \t\tComment + \t\t\tComment */ + """, + commentValue: """ + Comment + \tComment + """ + ) + + assertCommentValue( + """ + /* + \t\tComment + \t\t\tComment */ + """, + commentValue: """ + \t\tComment + \t\t\tComment + """ + ) + + } + + func testDocBlockCommentValues() { + assertCommentValue( + """ + /** Some doc block comment */ + """, + commentValue: "Some doc block comment" + ) + + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + """, + commentValue: """ + Some doc block comment + * spread on many lines + """ + ) + + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines + */ + """, + commentValue: "Some doc block comment\n* spread on many lines" + ) + + assertCommentValue( + """ + /** + Some doc block comment + spread on many lines + */ + """, + commentValue: """ + Some doc block comment + spread on many lines + """ + ) + + assertCommentValue( + """ + /** + * Some doc block comment + * spread on many lines + */ + """, + commentValue: """ + * Some doc block comment + * spread on many lines + """ + ) + + assertCommentValue( + """ + /** + * Some doc block comment + * with a line comment + */ + """, + commentValue: """ + * Some doc block comment + * with a line comment + """ + ) + + assertCommentValue("/** ///// abc */", commentValue: "///// abc") + + assertCommentValue( + """ + /** + Some block comment + with another line + */ + """, + commentValue: """ + Some block comment + with another line + """ + ) + + assertCommentValue( + """ + /** + Some block comment + with another line + */ + """, + commentValue: """ + Some block comment + with another line + """ + ) + + assertCommentValue( + """ + /** + * Some doc block comment + * // spread on many lines + * with a line comment + */ + """, + commentValue: """ + * Some doc block comment + * // spread on many lines\n* with a line comment + """ + ) + + assertCommentValue( + """ + /** + * Some doc block comment + * // spread on many lines + * with a line comment + */ + """, + commentValue: """ + * Some doc block comment + * // spread on many lines\n* with a line comment + """ + ) + + assertCommentValue("/* abc */", commentValue: "abc") + + assertCommentValue( + """ + /* \("") + abc + */ + """, + commentValue: "abc" + ) + } + + func testMixedStyleCommentValues() { + assertCommentValue( + """ + /** Some doc block comment + * spread on many lines */ + + /// Some doc line comment + // Some line comment + """, + commentValue: nil + ) + + assertCommentValue( + """ + /* Some block comment + * // A line comment in a block + * spread on many lines */ + /** Some doc block comment + * spread on + * many lines */ + """, + commentValue: nil + ) + + assertCommentValue("/* abc *//* def */", commentValue: nil) + } +} + +private func assertCommentValue( + _ input: String, + commentValue expected: String?, + file: StaticString = #filePath, + line: UInt = #line +) { + let trivia = parseTrivia(from: input) + XCTAssertEqual(trivia.commentValue, expected, file: file, line: line) +} + +private func parseTrivia(from input: String) -> Trivia { + // Wrap the input in valid Swift code so the parser can recognize it + let wrappedSource = "let _ = 0\n\(input)\nlet _ = 1" + + let sourceFile = Parser.parse(source: wrappedSource) + + // Find the token where the comment would appear (before `let _ = 1`) + guard + let commentToken = sourceFile.tokens(viewMode: .sourceAccurate).first(where: { + $0.leadingTrivia.contains(where: { $0.isComment }) + }) + else { + return [] + } + + return commentToken.leadingTrivia +} diff --git a/Tests/SwiftSyntaxTest/TriviaTests.swift b/Tests/SwiftSyntaxTest/TriviaTests.swift index 255e728077e..b395bc3d0b3 100644 --- a/Tests/SwiftSyntaxTest/TriviaTests.swift +++ b/Tests/SwiftSyntaxTest/TriviaTests.swift @@ -70,355 +70,4 @@ class TriviaTests: XCTestCase { XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .unexpectedText("f")) XCTAssertNotEqual(TriviaPiece.unexpectedText("e"), .lineComment("e")) } - - func testTriviaCommentValues() { - - // MARK: Line comment - - assertCommentValue("//", commentValue: "") - assertCommentValue("// Some line comment", commentValue: "Some line comment") - - assertCommentValue( - """ - // Some line comment - // Another - """, - commentValue: "Some line comment\nAnother" - ) - - assertCommentValue( - """ - // - Task - // - Subtask - // - Task 2 - """, - commentValue: """ - - Task - - Subtask - - Task 2 - """ - ) - - assertCommentValue( - """ - //- Task - // - Subtask - //- Task 2 - """, - commentValue: """ - - Task - - Subtask - - Task 2 - """ - ) - - // MARK: Doc line comment - - assertCommentValue("/// Some doc line comment", commentValue: "Some doc line comment") - - assertCommentValue( - """ - /// Some doc line comment - /// Another - """, - commentValue: "Some doc line comment\nAnother" - ) - - assertCommentValue( - """ - /// - Task - /// - Subtask - /// - Task 2 - """, - commentValue: """ - - Task - - Subtask - - Task 2 - """ - ) - - assertCommentValue( - """ - ///- Task - /// - Subtask - ///- Task 2 - """, - commentValue: """ - - Task - - Subtask - - Task 2 - """ - ) - - // MARK: Block comment - - assertCommentValue("/* Some block comment */", commentValue: " Some block comment ") - - assertCommentValue( - """ - /* Some block comment - * spread on many lines */ - """, - commentValue: " Some block comment\n* spread on many lines " - ) - - assertCommentValue( - """ - /* Some block comment - * spread on many lines - */ - """, - commentValue: " Some block comment\n* spread on many lines" - ) - - assertCommentValue( - """ - /* - Some block comment - spread on many lines */ - """, - commentValue: "Some block comment\nspread on many lines " - ) - - assertCommentValue( - """ - /* - * Some block comment - * spread on many lines - */ - """, - commentValue: "* Some block comment\n* spread on many lines" - ) - - assertCommentValue( - """ - /* - Paragraph 1 - - Paragraph 2 - */ - """, - commentValue: "Paragraph 1\n\nParagraph 2" - ) - - assertCommentValue( - """ - /* - /abc - */ - """, - commentValue: "/abc" - ) - - assertCommentValue("/* ///// abc */", commentValue: " ///// abc ") - - assertCommentValue( - """ - /* - Some block comment - with another line - */ - """, - commentValue: "Some block comment\nwith another line" - ) - - assertCommentValue( - """ - /* - Some block comment - with another line - */ - """, - commentValue: " Some block comment\n with another line" - ) - - assertCommentValue( - """ - /* - unindented line - */ - """, - commentValue: "unindented line" - ) - - assertCommentValue( - """ - /* - Comment - Comment */ - """, - commentValue: "Comment\n Comment " - ) - - assertCommentValue( - """ - /* - \t\tComment - \t\t\tComment */ - """, - commentValue: "Comment\n\tComment " - ) - - assertCommentValue( - """ - /* - \t\tComment - \t\t\tComment */ - """, - commentValue: "\tComment\n\tComment " - ) - - // MARK: Doc block comment - - assertCommentValue( - """ - /** Some doc block comment */ - """, - commentValue: " Some doc block comment " - ) - - assertCommentValue( - """ - /** Some doc block comment - * spread on many lines */ - """, - commentValue: " Some doc block comment\n* spread on many lines " - ) - - assertCommentValue( - """ - /** Some doc block comment - * spread on many lines - */ - """, - commentValue: " Some doc block comment\n* spread on many lines" - ) - - assertCommentValue( - """ - /** - Some doc block comment - spread on many lines - */ - """, - commentValue: "Some doc block comment\nspread on many lines" - ) - - assertCommentValue( - """ - /** - * Some doc block comment - * spread on many lines - */ - """, - commentValue: "* Some doc block comment\n* spread on many lines" - ) - - assertCommentValue( - """ - /** - * Some doc block comment - * with a line comment - */ - """, - commentValue: "* Some doc block comment\n* with a line comment" - ) - - assertCommentValue("/** ///// abc */", commentValue: " ///// abc ") - - assertCommentValue( - """ - /** - Some block comment - with another line - */ - """, - commentValue: "Some block comment\nwith another line" - ) - - assertCommentValue( - """ - /** - Some block comment - with another line - */ - """, - commentValue: " Some block comment\n with another line" - ) - - // MARK: Mixing comment styles - - assertCommentValue( - """ - /** - * Some doc block comment - * // spread on many lines - * with a line comment - */ - """, - commentValue: "* Some doc block comment\n* // spread on many lines\n* with a line comment" - ) - - assertCommentValue( - """ - /** - * Some doc block comment - * // spread on many lines - * with a line comment - */ - """, - commentValue: "* Some doc block comment\n* // spread on many lines\n* with a line comment" - ) - - assertCommentValue( - """ - /** Some doc block comment - * spread on many lines */ - - /// Some doc line comment - // Some line comment - """, - commentValue: nil - ) - - assertCommentValue( - """ - /* Some block comment - * // A line comment in a block - * spread on many lines */ - /** Some doc block comment - * spread on - * many lines */ - """, - commentValue: nil - ) - } -} - -func assertCommentValue( - _ input: String, - commentValue expected: String?, - file: StaticString = #filePath, - line: UInt = #line -) { - let trivia = parseTrivia(from: input) - XCTAssertEqual(trivia.commentValue, expected, file: file, line: line) -} - -private func parseTrivia(from input: String) -> Trivia { - // Wrap the input in valid Swift code so the parser can recognize it - let wrappedSource = "let _ = 0\n\(input)\nlet _ = 1" - - let sourceFile = Parser.parse(source: wrappedSource) - - // Find the token where the comment would appear (before `let _ = 1`) - guard - let commentToken = sourceFile.tokens(viewMode: .sourceAccurate).first(where: { - $0.leadingTrivia.contains(where: { $0.isComment }) - }) - else { - return [] - } - - return commentToken.leadingTrivia }