diff --git a/CodeGeneration/Sources/SyntaxSupport/Trivia.swift b/CodeGeneration/Sources/SyntaxSupport/Trivia.swift index 28ddb8866db..b1d9aa96269 100644 --- a/CodeGeneration/Sources/SyntaxSupport/Trivia.swift +++ b/CodeGeneration/Sources/SyntaxSupport/Trivia.swift @@ -150,7 +150,7 @@ public let TRIVIAS: [Trivia] = [ Trivia( name: "DocLineComment", - comment: #"A documentation line comment, starting with '///'."#, + comment: #"A documentation line comment, starting with '///' and excluding the trailing newline."#, isComment: true ), @@ -168,7 +168,7 @@ public let TRIVIAS: [Trivia] = [ Trivia( name: "LineComment", - comment: #"A developer line comment, starting with '//'"#, + comment: #"A developer line comment, starting with '//' and excluding the trailing newline."#, isComment: true ), diff --git a/Release Notes/601.md b/Release Notes/601.md index 75c9ac36178..2f1d6264698 100644 --- a/Release Notes/601.md +++ b/Release Notes/601.md @@ -22,6 +22,10 @@ - Added a new library `SwiftIfConfig`. - Description: This new library provides facilities for evaluating `#if` conditions and determining which regions of a syntax tree are active according to a given build configuration. - Pull Request: https://github.com/swiftlang/swift-syntax/pull/1816 + +- `SwiftBasicFormat` adds a method `indented(by:)` to all syntax node types. + - Description: This method indents a node’s contents using a provided piece of `Trivia`, optionally including the first line. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/2843 ## API Behavior Changes @@ -44,6 +48,10 @@ - Description: `ClosureCaptureSyntax` now has an `initializer` property instead of `equal` and `expression`. Additionally, the `name` property is no longer optional. - Pull request: https://github.com/swiftlang/swift-syntax/pull/2763 +- `Indenter` in `SwiftSyntaxBuilder` has been deprecated in favor of the new `indented(by:)` in `SwiftBasicFormat`. + - Description: Indenting is really more of a formatting operation than a syntax-building operation. Additionally, the `indented(by:)` method is more intuitive to use than a `SyntaxRewriter`. Aside from `BasicFormat`, there are no other public `SyntaxRewriter` classes in the package. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/2843 + ## API-Incompatible Changes - Moved `Radix` and `IntegerLiteralExprSyntax.radix` from `SwiftRefactor` to `SwiftSyntax`. diff --git a/Sources/SwiftBasicFormat/CMakeLists.txt b/Sources/SwiftBasicFormat/CMakeLists.txt index 3a3f7bc74e3..86564bcaa75 100644 --- a/Sources/SwiftBasicFormat/CMakeLists.txt +++ b/Sources/SwiftBasicFormat/CMakeLists.txt @@ -8,6 +8,7 @@ add_swift_syntax_library(SwiftBasicFormat BasicFormat.swift + Indenter.swift InferIndentation.swift Syntax+Extensions.swift SyntaxProtocol+Formatted.swift diff --git a/Sources/SwiftBasicFormat/Indenter.swift b/Sources/SwiftBasicFormat/Indenter.swift new file mode 100644 index 00000000000..e6bd48e49da --- /dev/null +++ b/Sources/SwiftBasicFormat/Indenter.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +#if swift(>=6) +public import SwiftSyntax +#else +import SwiftSyntax +#endif + +extension SyntaxProtocol { + /// Indent this node’s lines by the provided amount. + /// + /// - Parameter indentFirstLine: Whether the first token of this node should be indented. + /// Pass `true` if you know that this node will be placed at the beginning of a line, even if its + /// current leading trivia does not start with a newline (such as at the very start of a file). + public func indented(by indentation: Trivia, indentFirstLine: Bool = false) -> Self { + Indenter(indentation: indentation, indentFirstLine: indentFirstLine) + .rewrite(self) + .cast(Self.self) + } +} + +private class Indenter: SyntaxRewriter { + private let indentation: Trivia + private var shouldIndent: Bool + + init(indentation: Trivia, indentFirstLine: Bool) { + self.indentation = indentation + self.shouldIndent = indentFirstLine + } + + private func indentationIfNeeded() -> [TriviaPiece] { + if shouldIndent { + shouldIndent = false + return indentation.pieces + } else { + return [] + } + } + + private func indentAfterNewlines(_ content: String) -> String { + content.split(separator: "\n").joined(separator: "\n" + indentation.description) + } + + private func indent(_ trivia: Trivia, skipEmpty: Bool) -> Trivia { + if skipEmpty, trivia.isEmpty { return trivia } + + var result: [TriviaPiece] = [] + // most times, we won’t have anything to insert so this will + // reserve enough space + result.reserveCapacity(trivia.count) + + for piece in trivia.pieces { + result.append(contentsOf: indentationIfNeeded()) + switch piece { + case .newlines, .carriageReturns, .carriageReturnLineFeeds: + shouldIndent = true + // style decision: don’t indent totally blank lines + result.append(piece) + case .blockComment(let content): + result.append(.blockComment(indentAfterNewlines(content))) + case .docBlockComment(let content): + result.append(.docBlockComment(indentAfterNewlines(content))) + case .unexpectedText(let content): + result.append(.unexpectedText(indentAfterNewlines(content))) + default: + result.append(piece) + } + } + result.append(contentsOf: indentationIfNeeded()) + return Trivia(pieces: result) + } + + override func visit(_ token: TokenSyntax) -> TokenSyntax { + let indentedLeadingTrivia = indent(token.leadingTrivia, skipEmpty: false) + + // compute this before indenting the trailing trivia since the + // newline here is before the start of the trailing trivia (since + // it is part of the string’s value) + if case .stringSegment(let content) = token.tokenKind, + let last = content.last, + last.isNewline + { + shouldIndent = true + } + + return + token + .with(\.leadingTrivia, indentedLeadingTrivia) + // source files as parsed can’t have anything requiring indentation + // here, but it’s easy to do `.with(\.trailingTrivia, .newline)` so + // we should still check if there’s something to indent. + .with(\.trailingTrivia, indent(token.trailingTrivia, skipEmpty: true)) + } +} diff --git a/Sources/SwiftSyntax/generated/TriviaPieces.swift b/Sources/SwiftSyntax/generated/TriviaPieces.swift index bd5fff776b8..172f598a44b 100644 --- a/Sources/SwiftSyntax/generated/TriviaPieces.swift +++ b/Sources/SwiftSyntax/generated/TriviaPieces.swift @@ -31,11 +31,11 @@ public enum TriviaPiece: Sendable { case carriageReturnLineFeeds(Int) /// A documentation block comment, starting with '/**' and ending with '*/'. case docBlockComment(String) - /// A documentation line comment, starting with '///'. + /// A documentation line comment, starting with '///' and excluding the trailing newline. case docLineComment(String) /// A form-feed 'f' character. case formfeeds(Int) - /// A developer line comment, starting with '//' + /// A developer line comment, starting with '//' and excluding the trailing newline. case lineComment(String) /// A newline '\n' character. case newlines(Int) diff --git a/Sources/SwiftSyntaxBuilder/Indenter.swift b/Sources/SwiftSyntaxBuilder/Indenter.swift index 69056ffe394..6eeb1e88f68 100644 --- a/Sources/SwiftSyntaxBuilder/Indenter.swift +++ b/Sources/SwiftSyntaxBuilder/Indenter.swift @@ -17,7 +17,7 @@ import SwiftSyntax #endif extension Trivia { - func indented(indentation: Trivia) -> Trivia { + fileprivate func indented(indentation: Trivia) -> Trivia { let mappedPieces = self.flatMap { (piece) -> [TriviaPiece] in if piece.isNewline { return [piece] + indentation.pieces @@ -30,6 +30,7 @@ extension Trivia { } /// Adds a given amount of indentation after every newline in a syntax tree. +@available(*, deprecated, message: "Use 'SyntaxProtocol.indented(by:)' from SwiftBasicFormat instead") public class Indenter: SyntaxRewriter { let indentation: Trivia diff --git a/Sources/SwiftSyntaxBuilder/Syntax+StringInterpolation.swift b/Sources/SwiftSyntaxBuilder/Syntax+StringInterpolation.swift index 72c3338cbfb..4bdbc7c0bd0 100644 --- a/Sources/SwiftSyntaxBuilder/Syntax+StringInterpolation.swift +++ b/Sources/SwiftSyntaxBuilder/Syntax+StringInterpolation.swift @@ -82,7 +82,7 @@ extension SyntaxStringInterpolation: StringInterpolationProtocol { let startIndex = sourceText.count let indentedNode: Node if let lastIndentation { - indentedNode = Indenter.indent(node, indentation: lastIndentation) + indentedNode = node.indented(by: lastIndentation) } else { indentedNode = node } diff --git a/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift b/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift index 7c51fbcbe89..a0c54cab223 100644 --- a/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift +++ b/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift @@ -1393,7 +1393,7 @@ private extension AccessorBlockSyntax { accessorSpecifier: .keyword(.get, leadingTrivia: .newline + baseIndentation, trailingTrivia: .space), body: CodeBlockSyntax( leftBrace: .leftBraceToken(), - statements: Indenter.indent(getter, indentation: indentationWidth), + statements: getter.indented(by: indentationWidth), rightBrace: .rightBraceToken(leadingTrivia: .newline + baseIndentation) ) ) diff --git a/Tests/SwiftBasicFormatTest/IndentTests.swift b/Tests/SwiftBasicFormatTest/IndentTests.swift new file mode 100644 index 00000000000..a60f5215d18 --- /dev/null +++ b/Tests/SwiftBasicFormatTest/IndentTests.swift @@ -0,0 +1,192 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 SwiftBasicFormat +import SwiftParser +import SwiftSyntax +@_spi(Testing) import SwiftSyntaxBuilder +import XCTest +import _SwiftSyntaxTestSupport + +fileprivate func assertIndented( + by indentation: Trivia = .tab, + indentFirstLine: Bool = true, + source: String, + expected: String, + file: StaticString = #filePath, + line: UInt = #line +) { + assertStringsEqualWithDiff( + Parser.parse(source: source).indented(by: indentation, indentFirstLine: indentFirstLine).description, + expected, + file: file, + line: line + ) +} + +final class IndentTests: XCTestCase { + func testNotIndented() { + assertIndented( + source: """ + func foo() { + let bar = 2 + } + """, + expected: """ + \tfunc foo() { + \t let bar = 2 + \t} + """ + ) + } + + func testSingleLineComments() { + assertIndented( + source: """ + func foo() { + // This is a comment + // that extends onto + // multiple lines \\ + let bar = 2 + // and another one + } + """, + expected: """ + \tfunc foo() { + \t // This is a comment + \t // that extends onto + \t // multiple lines \\ + \t let bar = 2 + \t // and another one + \t} + """ + ) + } + + func testMultiLineComments() { + assertIndented( + source: """ + func foo() { + /* This is a multiline comment + that extends onto + multiple lines*/ + let bar = 2 + /* on a single line */ + let another = "Hello, world!" /* on a single line */ + } + """, + expected: """ + \tfunc foo() { + \t /* This is a multiline comment + \t that extends onto + \tmultiple lines*/ + \t let bar = 2 + \t /* on a single line */ + \t let another = "Hello, world!" /* on a single line */ + \t} + """ + ) + } + + func testMultiLineString() { + assertIndented( + source: #""" + func foo() { + let page = """ +
This is my web site
+ """ + } + """#, + expected: #""" + \#tfunc foo() { + \#t let page = """ + \#tThis is my web site
+ \#t """ + \#t} + """# + ) + } + + func testIndented() { + assertIndented( + source: """ + func foo() { + let bar = 2 + } + """, + expected: """ + \t func foo() { + \t let bar = 2 + \t } + """ + ) + assertIndented( + source: """ + \tfunc foo() { + \t let bar = 2 + \t} + """, + expected: """ + \t\tfunc foo() { + \t\t let bar = 2 + \t\t} + """ + ) + } + + func testIndentBySpaces() { + assertIndented( + by: .spaces(4), + source: """ + func foo() { + let bar = 2 + } + """, + expected: """ + func foo() { + let bar = 2 + } + """ + ) + } + + func testSkipFirstLine() { + assertIndented( + indentFirstLine: false, + source: """ + \nfunc foo() { + let bar = 2 + } + """, + expected: """ + \n\tfunc foo() { + \t let bar = 2 + \t} + """ + ) + assertIndented( + indentFirstLine: false, + source: """ + func foo() { + let bar = 2 + } + """, + expected: """ + func foo() { + \t let bar = 2 + \t} + """ + ) + } +}