Skip to content

Add an 'indented' method to SyntaxProtocol #2843

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Oct 30, 2024
Merged
4 changes: 2 additions & 2 deletions CodeGeneration/Sources/SyntaxSupport/Trivia.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
),

Expand All @@ -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
),

Expand Down
8 changes: 8 additions & 0 deletions Release Notes/601.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftBasicFormat/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

add_swift_syntax_library(SwiftBasicFormat
BasicFormat.swift
Indenter.swift
InferIndentation.swift
Syntax+Extensions.swift
SyntaxProtocol+Formatted.swift
Expand Down
104 changes: 104 additions & 0 deletions Sources/SwiftBasicFormat/Indenter.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 2 additions & 2 deletions Sources/SwiftSyntax/generated/TriviaPieces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Sources/SwiftSyntaxBuilder/Indenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
Expand Down
192 changes: 192 additions & 0 deletions Tests/SwiftBasicFormatTest/IndentTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<h1>Hello, world!</h1>
<p>This is my web site</p>
"""
}
"""#,
expected: #"""
\#tfunc foo() {
\#t let page = """
\#t <h1>Hello, world!</h1>
\#t <p>This is my web site</p>
\#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}
"""
)
}
}