From 4a3f0c0c17da492e5bddbb26927500b0265c9a7c Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Tue, 18 Apr 2023 17:56:04 -0700 Subject: [PATCH 1/3] Improve implementation of BasicFormat (yet again) This re-writes the implementation of BasicFormat once again to better handle user-indented code. Co-authored-by: Ben Barham --- .../Sources/SyntaxSupport/Trivia.swift | 20 +- .../GenerateSwiftSyntax.swift | 2 +- .../basicformat/BasicFormatFile.swift | 315 ----------------- .../BasicFormatExtensionsFile.swift | 202 +++++++++++ .../swiftsyntax/TriviaPiecesFile.swift | 47 +++ Package.swift | 5 + Sources/SwiftBasicFormat/BasicFormat.swift | 277 +++++++++++++++ Sources/SwiftBasicFormat/CMakeLists.txt | 5 +- .../Trivia+FormatExtensions.swift | 113 +++++++ .../SwiftBasicFormat/Trivia+Indented.swift | 48 --- ...mat.swift => BasicFormat+Extensions.swift} | 317 ++++++------------ .../DiagnosticExtensions.swift | 2 +- Sources/SwiftSyntax/Trivia.swift | 28 -- .../SwiftSyntax/generated/TriviaPieces.swift | 70 ++++ .../SyntaxProtocol+Initializer.swift | 12 +- .../BasicFormatTests.swift | 193 +++++++++++ .../ArrayExprTests.swift | 8 +- .../ClassDeclSyntaxTests.swift | 23 ++ .../CollectionNodeFlatteningTests.swift | 6 +- .../DictionaryExprTests.swift | 10 +- .../ForInStmtTests.swift | 2 +- .../FunctionTests.swift | 26 +- .../IfConfigDeclSyntaxTests.swift | 4 +- .../SwiftSyntaxBuilderTest/IfStmtTests.swift | 6 +- .../InitializerDeclTests.swift | 22 +- .../StringInterpolationTests.swift | 4 +- .../StringLiteralTests.swift | 34 +- 27 files changed, 1123 insertions(+), 678 deletions(-) delete mode 100644 CodeGeneration/Sources/generate-swiftsyntax/templates/basicformat/BasicFormatFile.swift create mode 100644 CodeGeneration/Sources/generate-swiftsyntax/templates/swiftbasicformat/BasicFormatExtensionsFile.swift create mode 100644 Sources/SwiftBasicFormat/BasicFormat.swift create mode 100644 Sources/SwiftBasicFormat/Trivia+FormatExtensions.swift delete mode 100644 Sources/SwiftBasicFormat/Trivia+Indented.swift rename Sources/SwiftBasicFormat/generated/{BasicFormat.swift => BasicFormat+Extensions.swift} (53%) create mode 100644 Tests/SwiftBasicFormatTest/BasicFormatTests.swift diff --git a/CodeGeneration/Sources/SyntaxSupport/Trivia.swift b/CodeGeneration/Sources/SyntaxSupport/Trivia.swift index 6f43a82befe..dee7b5a8c4e 100644 --- a/CodeGeneration/Sources/SyntaxSupport/Trivia.swift +++ b/CodeGeneration/Sources/SyntaxSupport/Trivia.swift @@ -15,7 +15,6 @@ public class Trivia { public let comment: String public let characters: [Character] public let swiftCharacters: [Character] - public let isNewLine: Bool public let isComment: Bool public var lowerName: String { lowercaseFirstWord(name: name) } @@ -36,17 +35,23 @@ public class Trivia { public var isCollection: Bool { charactersLen > 0 } + public var isBlank: Bool { + characters.contains { $0.isWhitespace } + } + + public var isNewLine: Bool { + characters.contains { $0.isNewline } + } + init( name: String, comment: String, characters: [Character] = [], swiftCharacters: [Character] = [], - isNewLine: Bool = false, isComment: Bool = false ) { self.name = name self.comment = comment - self.isNewLine = isNewLine self.isComment = isComment self.characters = characters @@ -86,8 +91,7 @@ public let TRIVIAS: [Trivia] = [ ], swiftCharacters: [ Character("\r") - ], - isNewLine: true + ] ), Trivia( @@ -100,8 +104,7 @@ public let TRIVIAS: [Trivia] = [ swiftCharacters: [ Character("\r"), Character("\n"), - ], - isNewLine: true + ] ), Trivia( @@ -142,8 +145,7 @@ public let TRIVIAS: [Trivia] = [ ], swiftCharacters: [ Character("\n") - ], - isNewLine: true + ] ), Trivia( diff --git a/CodeGeneration/Sources/generate-swiftsyntax/GenerateSwiftSyntax.swift b/CodeGeneration/Sources/generate-swiftsyntax/GenerateSwiftSyntax.swift index bd42ede58b8..d9d3d2a3174 100644 --- a/CodeGeneration/Sources/generate-swiftsyntax/GenerateSwiftSyntax.swift +++ b/CodeGeneration/Sources/generate-swiftsyntax/GenerateSwiftSyntax.swift @@ -80,7 +80,7 @@ struct GenerateSwiftSyntax: ParsableCommand { let fileSpecs: [GeneratedFileSpec] = [ // SwiftBasicFormat - GeneratedFileSpec(swiftBasicFormatGeneratedDir + ["BasicFormat.swift"], basicFormatFile), + GeneratedFileSpec(swiftBasicFormatGeneratedDir + ["BasicFormat+Extensions.swift"], basicFormatExtensionsFile), // IDEUtils GeneratedFileSpec(ideUtilsGeneratedDir + ["SyntaxClassification.swift"], syntaxClassificationFile), diff --git a/CodeGeneration/Sources/generate-swiftsyntax/templates/basicformat/BasicFormatFile.swift b/CodeGeneration/Sources/generate-swiftsyntax/templates/basicformat/BasicFormatFile.swift deleted file mode 100644 index 05eeabd1119..00000000000 --- a/CodeGeneration/Sources/generate-swiftsyntax/templates/basicformat/BasicFormatFile.swift +++ /dev/null @@ -1,315 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2023 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 SwiftSyntax -import SwiftSyntaxBuilder -import SyntaxSupport -import Utils - -extension Child { - var requiresLeadingSpace: Bool? { - switch self.kind { - case .token(_, let requiresLeadingSpace, _): - return requiresLeadingSpace - case .nodeChoices(let choices): - for choice in choices { - if let requiresLeadingSpace = choice.requiresLeadingSpace { - return requiresLeadingSpace - } - } - default: - break - } - return nil - } - - var requiresTrailingSpace: Bool? { - switch self.kind { - case .token(choices: _, _, let requiresTrailingSpace): - return requiresTrailingSpace - case .nodeChoices(let choices): - for choice in choices { - if let requiresTrailingSpace = choice.requiresTrailingSpace { - return requiresTrailingSpace - } - } - default: - break - } - return nil - } -} - -let basicFormatFile = SourceFileSyntax(leadingTrivia: copyrightHeader) { - DeclSyntax("import SwiftSyntax") - - try! ClassDeclSyntax("open class BasicFormat: SyntaxRewriter") { - DeclSyntax("public var indentationLevel: Int = 0") - DeclSyntax("open var indentation: TriviaPiece { .spaces(indentationLevel * 4) }") - DeclSyntax("public var indentedNewline: Trivia { Trivia(pieces: [.newlines(1), indentation]) }") - DeclSyntax("private var lastRewrittenToken: TokenSyntax?") - DeclSyntax("private var putNextTokenOnNewLine: Bool = false") - - DeclSyntax( - """ - open override func visitPre(_ node: Syntax) { - if let keyPath = node.keyPathInParent, shouldIndent(keyPath) { - indentationLevel += 1 - } - if let parent = node.parent, childrenSeparatedByNewline(parent) { - putNextTokenOnNewLine = true && node.previousToken(viewMode: .sourceAccurate) != nil - } - } - """ - ) - DeclSyntax( - """ - open override func visitPost(_ node: Syntax) { - if let keyPath = node.keyPathInParent, shouldIndent(keyPath) { - indentationLevel -= 1 - } - } - """ - ) - - DeclSyntax( - """ - open override func visit(_ node: TokenSyntax) -> TokenSyntax { - var leadingTrivia = node.leadingTrivia - var trailingTrivia = node.trailingTrivia - if requiresLeadingSpace(node) && leadingTrivia.isEmpty && lastRewrittenToken?.trailingTrivia.isEmpty != false { - leadingTrivia += .space - } - if requiresTrailingSpace(node) && trailingTrivia.isEmpty { - trailingTrivia += .space - } - if let keyPath = node.keyPathInParent, requiresLeadingNewline(keyPath), !(leadingTrivia.first?.isNewline ?? false), !shouldOmitNewline(node) { - leadingTrivia = .newline + leadingTrivia - } - var isOnNewline: Bool = (lastRewrittenToken?.trailingTrivia.pieces.last?.isNewline == true) - if case .stringSegment(let text) = lastRewrittenToken?.tokenKind { - isOnNewline = isOnNewline || (text.last?.isNewline == true) - } - leadingTrivia = leadingTrivia.indented(indentation: indentation, isOnNewline: isOnNewline) - let rewritten = TokenSyntax( - node.tokenKind, - leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia, - presence: node.presence - ) - lastRewrittenToken = rewritten - putNextTokenOnNewLine = false - return rewritten - } - """ - ) - - DeclSyntax( - """ - /// If this returns `true`, ``BasicFormat`` will not wrap `node` to a new line. This can be used to e.g. keep string interpolation segments on a single line. - /// - Parameter node: the node that is being visited - /// - Returns: returns true if newline should be omitted - open func shouldOmitNewline(_ node: TokenSyntax) -> Bool { - if node.previousToken(viewMode: .sourceAccurate) == nil { - return true - } - var ancestor: Syntax = Syntax(node) - while let parent = ancestor.parent { - ancestor = parent - if ancestor.is(ExpressionSegmentSyntax.self) { - return true - } - } - - return false - } - """ - ) - - try FunctionDeclSyntax("open func shouldIndent(_ keyPath: AnyKeyPath) -> Bool") { - try SwitchExprSyntax("switch keyPath") { - for node in SYNTAX_NODES where !node.isBase { - for child in node.children where child.isIndented { - SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { - StmtSyntax("return true") - } - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return false") - } - } - } - - try FunctionDeclSyntax("open func requiresLeadingNewline(_ keyPath: AnyKeyPath) -> Bool") { - try SwitchExprSyntax("switch keyPath") { - for node in SYNTAX_NODES where !node.isBase { - for child in node.children where child.requiresLeadingNewline { - SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { - StmtSyntax("return true") - } - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return putNextTokenOnNewLine") - } - } - } - - try FunctionDeclSyntax("open func childrenSeparatedByNewline(_ node: Syntax) -> Bool") { - try SwitchExprSyntax("switch node.as(SyntaxEnum.self)") { - for node in SYNTAX_NODES where !node.isBase { - if node.elementsSeparatedByNewline { - SwitchCaseSyntax("case .\(raw: node.swiftSyntaxKind):") { - StmtSyntax("return true") - } - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return false") - } - } - } - - try FunctionDeclSyntax( - """ - /// If this returns a value that is not `nil`, it overrides the default - /// leading space behavior of a token. - open func requiresLeadingSpace(_ keyPath: AnyKeyPath) -> Bool? - """ - ) { - try SwitchExprSyntax("switch keyPath") { - for node in SYNTAX_NODES where !node.isBase { - for child in node.children { - if let requiresLeadingSpace = child.requiresLeadingSpace { - SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { - StmtSyntax("return \(literal: requiresLeadingSpace)") - } - } - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return nil") - } - } - } - - try FunctionDeclSyntax("open func requiresLeadingSpace(_ token: TokenSyntax) -> Bool") { - StmtSyntax( - """ - if let keyPath = token.keyPathInParent, let requiresLeadingSpace = requiresLeadingSpace(keyPath) { - return requiresLeadingSpace - } - """ - ) - - StmtSyntax( - """ - switch (token.previousToken(viewMode: .sourceAccurate)?.tokenKind, token.tokenKind) { - case (.leftParen, .leftBrace): // Ensures there is not a space in `.map({ $0.foo })` - return false - default: - break - } - """ - ) - - try SwitchExprSyntax("switch token.tokenKind") { - for token in SYNTAX_TOKENS { - if token.requiresLeadingSpace { - SwitchCaseSyntax("case .\(raw: token.swiftKind):") { - StmtSyntax("return true") - } - } - } - for keyword in KEYWORDS where keyword.requiresLeadingSpace { - SwitchCaseSyntax("case .keyword(.\(raw: keyword.escapedName)):") { - StmtSyntax("return true") - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return false") - } - } - } - - try FunctionDeclSyntax( - """ - /// If this returns a value that is not `nil`, it overrides the default - /// trailing space behavior of a token. - open func requiresTrailingSpace(_ keyPath: AnyKeyPath) -> Bool? - """ - ) { - try SwitchExprSyntax("switch keyPath") { - for node in SYNTAX_NODES where !node.isBase { - for child in node.children { - if let requiresTrailingSpace = child.requiresTrailingSpace { - SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { - StmtSyntax("return \(literal: requiresTrailingSpace)") - } - } - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return nil") - } - } - } - - try FunctionDeclSyntax("open func requiresTrailingSpace(_ token: TokenSyntax) -> Bool") { - StmtSyntax( - """ - if let keyPath = token.keyPathInParent, let requiresTrailingSpace = requiresTrailingSpace(keyPath) { - return requiresTrailingSpace - } - """ - ) - - StmtSyntax( - """ - switch (token.tokenKind, token.nextToken(viewMode: .sourceAccurate)?.tokenKind) { - case (.exclamationMark, .leftParen), // Ensures there is not a space in `myOptionalClosure!()` - (.exclamationMark, .period), // Ensures there is not a space in `myOptionalBar!.foo()` - (.keyword(.as), .exclamationMark), // Ensures there is not a space in `as!` - (.keyword(.as), .postfixQuestionMark), // Ensures there is not a space in `as?` - (.keyword(.try), .exclamationMark), // Ensures there is not a space in `try!` - (.keyword(.try), .postfixQuestionMark), // Ensures there is not a space in `try?`: - (.postfixQuestionMark, .leftParen), // Ensures there is not a space in `init?()` or `myOptionalClosure?()`s - (.postfixQuestionMark, .rightAngle), // Ensures there is not a space in `ContiguousArray` - (.postfixQuestionMark, .rightParen): // Ensures there is not a space in `myOptionalClosure?()` - return false - default: - break - } - """ - ) - - try SwitchExprSyntax("switch token.tokenKind") { - for token in SYNTAX_TOKENS { - if token.requiresTrailingSpace { - SwitchCaseSyntax("case .\(raw: token.swiftKind):") { - StmtSyntax("return true") - } - } - } - for keyword in KEYWORDS where keyword.requiresTrailingSpace { - SwitchCaseSyntax("case .keyword(.\(raw: keyword.escapedName)):") { - StmtSyntax("return true") - } - } - SwitchCaseSyntax("default:") { - StmtSyntax("return false") - } - } - } - } -} diff --git a/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftbasicformat/BasicFormatExtensionsFile.swift b/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftbasicformat/BasicFormatExtensionsFile.swift new file mode 100644 index 00000000000..33cba270f02 --- /dev/null +++ b/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftbasicformat/BasicFormatExtensionsFile.swift @@ -0,0 +1,202 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 SwiftSyntax +import SwiftSyntaxBuilder +import SyntaxSupport +import Utils + +extension Child { + var requiresLeadingSpace: Bool? { + switch self.kind { + case .token(_, let requiresLeadingSpace, _): + return requiresLeadingSpace + case .nodeChoices(let choices): + for choice in choices { + if let requiresLeadingSpace = choice.requiresLeadingSpace { + return requiresLeadingSpace + } + } + default: + break + } + return nil + } + + var requiresTrailingSpace: Bool? { + switch self.kind { + case .token(choices: _, _, let requiresTrailingSpace): + return requiresTrailingSpace + case .nodeChoices(let choices): + for choice in choices { + if let requiresTrailingSpace = choice.requiresTrailingSpace { + return requiresTrailingSpace + } + } + default: + break + } + return nil + } +} + +let basicFormatExtensionsFile = SourceFileSyntax(leadingTrivia: copyrightHeader) { + DeclSyntax("import SwiftSyntax") + + try! ExtensionDeclSyntax("public extension SyntaxProtocol") { + DeclSyntax( + """ + var requiresIndent: Bool { + guard let keyPath = keyPathInParent else { + return false + } + return keyPath.requiresIndent + } + """ + ) + } + + try! ExtensionDeclSyntax("public extension TokenSyntax") { + DeclSyntax( + """ + var requiresLeadingNewline: Bool { + if let keyPath = keyPathInParent, keyPath.requiresLeadingNewline { + return true + } + return false + } + """ + ) + + try VariableDeclSyntax("var requiresLeadingSpace: Bool") { + StmtSyntax( + """ + if let keyPath = keyPathInParent, let requiresLeadingSpace = keyPath.requiresLeadingSpace { + return requiresLeadingSpace + } + """ + ) + + try SwitchExprSyntax("switch tokenKind") { + for token in SYNTAX_TOKENS { + if token.requiresLeadingSpace { + SwitchCaseSyntax("case .\(raw: token.swiftKind):") { + StmtSyntax("return true") + } + } + } + for keyword in KEYWORDS where keyword.requiresLeadingSpace { + SwitchCaseSyntax("case .keyword(.\(raw: keyword.escapedName)):") { + StmtSyntax("return true") + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return false") + } + } + } + + try VariableDeclSyntax("var requiresTrailingSpace: Bool") { + StmtSyntax( + """ + if let keyPath = keyPathInParent, let requiresTrailingSpace = keyPath.requiresTrailingSpace { + return requiresTrailingSpace + } + """ + ) + + try SwitchExprSyntax("switch tokenKind") { + for token in SYNTAX_TOKENS { + if token.requiresTrailingSpace { + SwitchCaseSyntax("case .\(raw: token.swiftKind):") { + StmtSyntax("return true") + } + } + } + for keyword in KEYWORDS where keyword.requiresTrailingSpace { + SwitchCaseSyntax("case .keyword(.\(raw: keyword.escapedName)):") { + StmtSyntax("return true") + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return false") + } + } + } + } + + try! ExtensionDeclSyntax("fileprivate extension AnyKeyPath") { + try VariableDeclSyntax("var requiresIndent: Bool") { + try SwitchExprSyntax("switch self") { + for node in SYNTAX_NODES where !node.isBase { + for child in node.children where child.isIndented { + SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { + StmtSyntax("return true") + } + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return false") + } + } + } + + try VariableDeclSyntax("var requiresLeadingNewline: Bool") { + try SwitchExprSyntax("switch self") { + for node in SYNTAX_NODES where !node.isBase { + for child in node.children where child.requiresLeadingNewline { + SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { + StmtSyntax("return true") + } + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return false") + } + } + } + + try VariableDeclSyntax("var requiresLeadingSpace: Bool?") { + try SwitchExprSyntax("switch self") { + for node in SYNTAX_NODES where !node.isBase { + for child in node.children { + if let requiresLeadingSpace = child.requiresLeadingSpace { + SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { + StmtSyntax("return \(literal: requiresLeadingSpace)") + } + } + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return nil") + } + } + } + + try VariableDeclSyntax("var requiresTrailingSpace: Bool?") { + try SwitchExprSyntax("switch self") { + for node in SYNTAX_NODES where !node.isBase { + for child in node.children { + if let requiresTrailingSpace = child.requiresTrailingSpace { + SwitchCaseSyntax("case \\\(raw: node.type.syntaxBaseName).\(raw: child.swiftName):") { + StmtSyntax("return \(literal: requiresTrailingSpace)") + } + } + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return nil") + } + } + } + } +} diff --git a/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftsyntax/TriviaPiecesFile.swift b/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftsyntax/TriviaPiecesFile.swift index 355a54563a7..5886d5589d9 100644 --- a/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftsyntax/TriviaPiecesFile.swift +++ b/CodeGeneration/Sources/generate-swiftsyntax/templates/swiftsyntax/TriviaPiecesFile.swift @@ -264,4 +264,51 @@ let triviaPiecesFile = SourceFileSyntax(leadingTrivia: copyrightHeader) { } } } + + try! generateIsHelpers(for: "TriviaPiece") + + try! generateIsHelpers(for: "RawTriviaPiece") +} + +fileprivate func generateIsHelpers(for pieceName: String) throws -> ExtensionDeclSyntax { + return try ExtensionDeclSyntax("extension \(raw: pieceName)") { + DeclSyntax( + """ + /// Returns `true` if this piece is a newline, space or tab. + public var isWhitespace: Bool { + return isSpaceOrTab || isNewline + } + """ + ) + + try VariableDeclSyntax("public var isNewline: Bool") { + try SwitchExprSyntax("switch self") { + for trivia in TRIVIAS { + if trivia.isNewLine { + SwitchCaseSyntax("case .\(raw: trivia.enumCaseName):") { + StmtSyntax("return true") + } + } + } + SwitchCaseSyntax("default:") { + StmtSyntax("return false") + } + } + } + + DeclSyntax( + """ + public var isSpaceOrTab: Bool { + switch self { + case .spaces: + return true + case .tabs: + return true + default: + return false + } + } + """ + ) + } } diff --git a/Package.swift b/Package.swift index 4ce71bb89f3..d002f5669c0 100644 --- a/Package.swift +++ b/Package.swift @@ -103,6 +103,11 @@ let package = Package( exclude: ["CMakeLists.txt"] ), + .testTarget( + name: "SwiftBasicFormatTest", + dependencies: ["_SwiftSyntaxTestSupport", "SwiftBasicFormat", "SwiftSyntaxBuilder"] + ), + // MARK: SwiftCompilerPlugin .target( diff --git a/Sources/SwiftBasicFormat/BasicFormat.swift b/Sources/SwiftBasicFormat/BasicFormat.swift new file mode 100644 index 00000000000..2cf0f5d7554 --- /dev/null +++ b/Sources/SwiftBasicFormat/BasicFormat.swift @@ -0,0 +1,277 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 SwiftSyntax + +open class BasicFormat: SyntaxRewriter { + /// How much indentation should be added at a new indentation level. + public let indentationWidth: Trivia + + /// As we reach a new indendation level, its indentation will be added to the + /// stack. As we exit that indentation level, the indendation will be popped. + public var indentationStack: [Trivia] + + /// The trivia by which tokens should currently be indented. + public var currentIndentationLevel: Trivia { + // `popIndentationLevel` guarantees that there is always one item on the stack. + return indentationStack.last! + } + + /// For every token that is being put on a new line but did not have + /// user-specified indentation, the generated indentation. + /// + /// This is used as a reference-point to indent user-indented code. + private var anchorPoints: [TokenSyntax: Trivia] = [:] + + public init(indentationIncrement: Trivia = .spaces(4), initialIndentation: Trivia = []) { + self.indentationWidth = indentationIncrement + self.indentationStack = [initialIndentation] + } + + // MARK: - Updating indentation level + + public func pushIndentationLevel(increasingIndentationBy: Trivia) { + indentationStack.append(currentIndentationLevel + increasingIndentationBy) + } + + public func popIndentationLevel() { + precondition(indentationStack.count > 1, "Popping more indentation levels than have been pushed") + indentationStack.removeLast() + } + + open override func visitPre(_ node: Syntax) { + if requiresIndent(node) { + if let firstToken = node.firstToken(viewMode: .sourceAccurate), + let tokenIndentation = firstToken.leadingTrivia.indentation(isOnNewline: false), + !tokenIndentation.isEmpty + { + // If the first token in this block is indented, infer the indentation level from it. + pushIndentationLevel(increasingIndentationBy: tokenIndentation) + } else { + pushIndentationLevel(increasingIndentationBy: indentationWidth) + } + } + } + + open override func visitPost(_ node: Syntax) { + if requiresIndent(node) { + popIndentationLevel() + } + } + + // MARK: - Helper functions + + private func isInsideStringInterpolation(_ token: TokenSyntax) -> Bool { + var ancestor: Syntax = Syntax(token) + while let parent = ancestor.parent { + ancestor = parent + if ancestor.is(ExpressionSegmentSyntax.self) { + return true + } + } + return false + } + + private func childrenSeparatedByNewline(_ node: Syntax) -> Bool { + switch node.as(SyntaxEnum.self) { + case .accessorList: + return true + case .codeBlockItemList: + return true + case .memberDeclList: + return true + case .switchCaseList: + return true + default: + return false + } + } + + /// Find the indentation of the nearest ancestor whose first token is an + /// anchor point (see `anchorPoints`). + private func anchorPointIndentation(for token: TokenSyntax) -> Trivia? { + var ancestor: Syntax = Syntax(token) + while let parent = ancestor.parent { + ancestor = parent + if let firstToken = parent.firstToken(viewMode: .sourceAccurate), + let anchorPointIndentation = anchorPoints[firstToken] + { + return anchorPointIndentation + } + } + return nil + } + + // MARK: - Customization points + + /// Whether a leading newline on `token` should be added. + open func requiresIndent(_ node: T) -> Bool { + return node.requiresIndent + } + + /// Whether a leading newline on `token` should be added. + open func requiresLeadingNewline(_ token: TokenSyntax) -> Bool { + // We don't want to add newlines inside string interpolation + if isInsideStringInterpolation(token) { + return false + } + + if token.requiresLeadingNewline { + return true + } + + var ancestor: Syntax = Syntax(token) + while let parent = ancestor.parent { + ancestor = parent + if ancestor.firstToken(viewMode: .sourceAccurate) != token { + break + } + if let ancestorsParent = ancestor.parent, childrenSeparatedByNewline(ancestorsParent) { + return true + } + } + + return false + } + + /// Whether a leading space on `token` should be added. + open func requiresLeadingWhitespace(_ token: TokenSyntax) -> Bool { + switch (token.previousToken(viewMode: .sourceAccurate)?.tokenKind, token.tokenKind) { + case (.leftParen, .leftBrace): // Ensures there is not a space in `.map({ $0.foo })` + return false + default: + break + } + + return token.requiresLeadingSpace + } + + /// Whether a trailing space on `token` should be added. + open func requiresTrailingWhitespace(_ token: TokenSyntax) -> Bool { + switch (token.tokenKind, token.nextToken(viewMode: .sourceAccurate)?.tokenKind) { + case (.exclamationMark, .leftParen), // Ensures there is not a space in `myOptionalClosure!()` + (.exclamationMark, .period), // Ensures there is not a space in `myOptionalBar!.foo()` + (.keyword(.as), .exclamationMark), // Ensures there is not a space in `as!` + (.keyword(.as), .postfixQuestionMark), // Ensures there is not a space in `as?` + (.keyword(.try), .exclamationMark), // Ensures there is not a space in `try!` + (.keyword(.try), .postfixQuestionMark), // Ensures there is not a space in `try?`: + (.postfixQuestionMark, .leftParen), // Ensures there is not a space in `init?()` or `myOptionalClosure?()`s + (.postfixQuestionMark, .rightAngle), // Ensures there is not a space in `ContiguousArray` + (.postfixQuestionMark, .rightParen): // Ensures there is not a space in `myOptionalClosure?()` + return false + default: + break + } + + return token.requiresTrailingSpace + } + + // MARK: - Formatting a token + + open override func visit(_ token: TokenSyntax) -> TokenSyntax { + lazy var previousTokenWillEndWithWhitespace: Bool = { + guard let previousToken = token.previousToken(viewMode: .sourceAccurate) else { + return false + } + return previousToken.trailingTrivia.pieces.last?.isWhitespace ?? false + || requiresTrailingWhitespace(previousToken) + }() + + lazy var previousTokenWillEndWithNewline: Bool = { + guard let previousToken = token.previousToken(viewMode: .sourceAccurate) else { + // Assume that the start of the tree is equivalent to a newline so we + // don't add a leading newline to the file. + return true + } + return previousToken.trailingTrivia.endsWithNewline + }() + + lazy var nextTokenWillStartWithNewline: Bool = { + guard let nextToken = token.nextToken(viewMode: .sourceAccurate) else { + return false + } + return nextToken.leadingTrivia.startsWithNewline + || requiresLeadingNewline(nextToken) + }() + + /// This token's trailing trivia + any spaces or tabs at the start of the + /// next token's leading trivia. + lazy var combinedTrailingTrivia: Trivia = { + let nextToken = token.nextToken(viewMode: .sourceAccurate) + let nextTokenLeadingWhitespace = nextToken?.leadingTrivia.prefix(while: { $0.isSpaceOrTab }) ?? [] + return trailingTrivia + Trivia(pieces: nextTokenLeadingWhitespace) + }() + + var leadingTrivia = token.leadingTrivia + var trailingTrivia = token.trailingTrivia + + if requiresLeadingNewline(token) { + // Add a leading newline if the token requires it unless + // - it already starts with a newline or + // - the previous token ends with a newline + if !leadingTrivia.startsWithNewline && !previousTokenWillEndWithNewline { + // Add a leading newline if the token requires it and + // - it doesn't already start with a newline and + // - the previous token didn't end with a newline + leadingTrivia = .newline + leadingTrivia + } + } else if requiresLeadingWhitespace(token) { + // Add a leading space if the token requires it unless + // - it already starts with a whitespace or + // - the previous token ends with a whitespace after the rewrite + if !leadingTrivia.startsWithWhitespace && !previousTokenWillEndWithWhitespace { + leadingTrivia += .space + } + } + + if leadingTrivia.indentation(isOnNewline: previousTokenWillEndWithNewline) == [] { + // If the token starts on a new line and does not have indentation, this + // is the last non-indented token. Store its indentation level + anchorPoints[token] = currentIndentationLevel + } + + // Add a trailing space to the token unless + // - it already ends with a whitespace or + // - the next token will start starts with a newline after the rewrite + // because newlines should be preferred to spaces as a whitespace + if requiresTrailingWhitespace(token) + && !trailingTrivia.endsWithWhitespace + && !nextTokenWillStartWithNewline + { + trailingTrivia += .space + } + + var leadingTriviaIndentation = self.currentIndentationLevel + var trailingTriviaIndentation = self.currentIndentationLevel + + // If the trivia contain user-defined indentation, find their anchor point + // and indent the token relative to that anchor point. + if leadingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline), + let anchorPointIndentation = self.anchorPointIndentation(for: token) + { + leadingTriviaIndentation = anchorPointIndentation + } + if combinedTrailingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline), + let anchorPointIndentation = self.anchorPointIndentation(for: token) + { + trailingTriviaIndentation = anchorPointIndentation + } + + leadingTrivia = leadingTrivia.indented(indentation: leadingTriviaIndentation, isOnNewline: false) + trailingTrivia = trailingTrivia.indented(indentation: trailingTriviaIndentation, isOnNewline: false) + + leadingTrivia = leadingTrivia.trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: false) + trailingTrivia = trailingTrivia.trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: nextTokenWillStartWithNewline) + + return token.with(\.leadingTrivia, leadingTrivia).with(\.trailingTrivia, trailingTrivia) + } +} diff --git a/Sources/SwiftBasicFormat/CMakeLists.txt b/Sources/SwiftBasicFormat/CMakeLists.txt index 6fe22217caa..c83ff4e1db7 100644 --- a/Sources/SwiftBasicFormat/CMakeLists.txt +++ b/Sources/SwiftBasicFormat/CMakeLists.txt @@ -7,9 +7,10 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_swift_host_library(SwiftBasicFormat - generated/BasicFormat.swift + BasicFormat.swift + generated/BasicFormat+Extensions.swift SyntaxProtocol+Formatted.swift - Trivia+Indented.swift + Trivia+FormatExtensions.swift ) target_link_libraries(SwiftBasicFormat PUBLIC diff --git a/Sources/SwiftBasicFormat/Trivia+FormatExtensions.swift b/Sources/SwiftBasicFormat/Trivia+FormatExtensions.swift new file mode 100644 index 00000000000..2bd55763743 --- /dev/null +++ b/Sources/SwiftBasicFormat/Trivia+FormatExtensions.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 SwiftSyntax + +extension Trivia { + /// Removes all whitespaces that is trailing before a newline trivia, + /// effectively making sure that lines don't end with a whitespace + func trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: Bool) -> Trivia { + // Iterate through the trivia in reverse. Every time we see a newline drop + // all whitespaces until we see a non-whitespace trivia piece. + var isBeforeNewline = isBeforeNewline + var trimmedReversedPieces: [TriviaPiece] = [] + for piece in pieces.reversed() { + if piece.isNewline { + isBeforeNewline = true + trimmedReversedPieces.append(piece) + continue + } + if isBeforeNewline && piece.isWhitespace { + continue + } + trimmedReversedPieces.append(piece) + isBeforeNewline = false + } + return Trivia(pieces: trimmedReversedPieces.reversed()) + } + + /// Returns `true` if this trivia contains indentation. + func containsIndentation(isOnNewline: Bool) -> Bool { + guard let indentaton = indentation(isOnNewline: isOnNewline) else { + return false + } + return !indentaton.isEmpty + } + + /// Returns the indentation of the last trivia piece in this trivia that is + /// not a whitespace. + func indentation(isOnNewline: Bool) -> Trivia? { + let lastNonWhitespaceTriviaPieceIndex = self.pieces.lastIndex(where: { !$0.isWhitespace }) ?? self.pieces.endIndex + let piecesBeforeLastNonWhitespace = self.pieces[.. + if let lastNewlineIndex = piecesBeforeLastNonWhitespace.lastIndex(where: { $0.isNewline }) { + indentation = piecesBeforeLastNonWhitespace[(lastNewlineIndex + 1)...] + } else if isOnNewline { + indentation = piecesBeforeLastNonWhitespace + } else { + return nil + } + return Trivia(pieces: indentation) + } + + /// Adds `indentation` after every newline in this trivia. + func indented(indentation: Trivia, isOnNewline: Bool) -> Trivia { + guard !isEmpty else { + if isOnNewline { + return indentation + } + return self + } + + var indentedPieces: [TriviaPiece] = [] + if isOnNewline { + indentedPieces.append(contentsOf: indentation) + } + + for piece in pieces { + indentedPieces.append(piece) + if piece.isNewline { + indentedPieces.append(contentsOf: indentation) + } + } + + return Trivia(pieces: indentedPieces) + } + + var startsWithNewline: Bool { + guard let first = self.first else { + return false + } + return first.isNewline + } + + var startsWithWhitespace: Bool { + guard let first = self.first else { + return false + } + return first.isWhitespace + } + + var endsWithNewline: Bool { + guard let last = self.pieces.last else { + return false + } + return last.isNewline + } + + var endsWithWhitespace: Bool { + guard let last = self.pieces.last else { + return false + } + return last.isWhitespace + } +} diff --git a/Sources/SwiftBasicFormat/Trivia+Indented.swift b/Sources/SwiftBasicFormat/Trivia+Indented.swift deleted file mode 100644 index e8aa22e49a3..00000000000 --- a/Sources/SwiftBasicFormat/Trivia+Indented.swift +++ /dev/null @@ -1,48 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2023 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 SwiftSyntax - -extension Trivia { - /// Makes sure each newline of this trivia is followed by `indentation`. If this is not the case, the existing indentation is extended to `indentation`. - /// `isOnNewline` determines whether the trivia starts on a new line. If this is the case, the function makes sure that the returned trivia starts with `indentation`. - func indented(indentation: TriviaPiece, isOnNewline: Bool = false) -> Trivia { - var indentedPieces: [TriviaPiece] = [] - for (index, piece) in self.enumerated() { - let previousPieceIsNewline: Bool - if index == 0 { - previousPieceIsNewline = isOnNewline - } else { - previousPieceIsNewline = pieces[index - 1].isNewline - } - if previousPieceIsNewline { - switch (piece, indentation) { - case (.spaces(let nextPieceSpaces), .spaces(let indentationSpaces)): - if nextPieceSpaces < indentationSpaces { - indentedPieces.append(.spaces(indentationSpaces - nextPieceSpaces)) - } - case (.tabs(let nextPieceTabs), .tabs(let indentationTabs)): - if nextPieceTabs < indentationTabs { - indentedPieces.append(.tabs(indentationTabs - nextPieceTabs)) - } - default: - indentedPieces.append(indentation) - } - } - indentedPieces.append(piece) - } - if self.pieces.last?.isNewline == true { - indentedPieces.append(indentation) - } - return Trivia(pieces: indentedPieces) - } -} diff --git a/Sources/SwiftBasicFormat/generated/BasicFormat.swift b/Sources/SwiftBasicFormat/generated/BasicFormat+Extensions.swift similarity index 53% rename from Sources/SwiftBasicFormat/generated/BasicFormat.swift rename to Sources/SwiftBasicFormat/generated/BasicFormat+Extensions.swift index 44a5ad660da..446ac7644f7 100644 --- a/Sources/SwiftBasicFormat/generated/BasicFormat.swift +++ b/Sources/SwiftBasicFormat/generated/BasicFormat+Extensions.swift @@ -14,180 +14,28 @@ import SwiftSyntax -open class BasicFormat: SyntaxRewriter { - public var indentationLevel: Int = 0 - - open var indentation: TriviaPiece { - .spaces(indentationLevel * 4) - } - - public var indentedNewline: Trivia { - Trivia(pieces: [.newlines(1), indentation]) - } - - private var lastRewrittenToken: TokenSyntax? - - private var putNextTokenOnNewLine: Bool = false - - open override func visitPre(_ node: Syntax) { - if let keyPath = node.keyPathInParent, shouldIndent(keyPath) { - indentationLevel += 1 - } - if let parent = node.parent, childrenSeparatedByNewline(parent) { - putNextTokenOnNewLine = true && node.previousToken(viewMode: .sourceAccurate) != nil - } - } - - open override func visitPost(_ node: Syntax) { - if let keyPath = node.keyPathInParent, shouldIndent(keyPath) { - indentationLevel -= 1 - } - } - - open override func visit(_ node: TokenSyntax) -> TokenSyntax { - var leadingTrivia = node.leadingTrivia - var trailingTrivia = node.trailingTrivia - if requiresLeadingSpace(node) && leadingTrivia.isEmpty && lastRewrittenToken?.trailingTrivia.isEmpty != false { - leadingTrivia += .space - } - if requiresTrailingSpace(node) && trailingTrivia.isEmpty { - trailingTrivia += .space - } - if let keyPath = node.keyPathInParent, requiresLeadingNewline(keyPath), !(leadingTrivia.first?.isNewline ?? false), !shouldOmitNewline(node) { - leadingTrivia = .newline + leadingTrivia - } - var isOnNewline: Bool = (lastRewrittenToken?.trailingTrivia.pieces.last?.isNewline == true) - if case .stringSegment(let text) = lastRewrittenToken?.tokenKind { - isOnNewline = isOnNewline || (text.last?.isNewline == true) - } - leadingTrivia = leadingTrivia.indented(indentation: indentation, isOnNewline: isOnNewline) - let rewritten = TokenSyntax( - node.tokenKind, - leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia, - presence: node.presence - - ) - lastRewrittenToken = rewritten - putNextTokenOnNewLine = false - return rewritten - } - - /// If this returns `true`, ``BasicFormat`` will not wrap `node` to a new line. This can be used to e.g. keep string interpolation segments on a single line. - /// - Parameter node: the node that is being visited - /// - Returns: returns true if newline should be omitted - open func shouldOmitNewline(_ node: TokenSyntax) -> Bool { - if node.previousToken(viewMode: .sourceAccurate) == nil { - return true - } - var ancestor: Syntax = Syntax(node) - while let parent = ancestor.parent { - ancestor = parent - if ancestor.is(ExpressionSegmentSyntax.self) { - return true - } - } - - return false - } - - open func shouldIndent(_ keyPath: AnyKeyPath) -> Bool { - switch keyPath { - case \AccessorBlockSyntax.accessors: - return true - case \ArrayExprSyntax.elements: - return true - case \ClosureExprSyntax.statements: - return true - case \ClosureParameterClauseSyntax.parameterList: - return true - case \CodeBlockSyntax.statements: - return true - case \DictionaryElementSyntax.valueExpression: - return true - case \DictionaryExprSyntax.content: - return true - case \EnumCaseParameterClauseSyntax.parameterList: - return true - case \FunctionCallExprSyntax.argumentList: - return true - case \FunctionTypeSyntax.arguments: - return true - case \MemberDeclBlockSyntax.members: - return true - case \ParameterClauseSyntax.parameterList: - return true - case \SwitchCaseSyntax.statements: - return true - case \TupleExprSyntax.elementList: - return true - case \TupleTypeSyntax.elements: - return true - default: +public extension SyntaxProtocol { + var requiresIndent: Bool { + guard let keyPath = keyPathInParent else { return false } + return keyPath.requiresIndent } - - open func requiresLeadingNewline(_ keyPath: AnyKeyPath) -> Bool { - switch keyPath { - case \AccessorBlockSyntax.rightBrace: - return true - case \ClosureExprSyntax.rightBrace: - return true - case \CodeBlockSyntax.rightBrace: - return true - case \IfConfigClauseSyntax.poundKeyword: - return true - case \IfConfigDeclSyntax.poundEndif: - return true - case \MemberDeclBlockSyntax.rightBrace: - return true - case \SwitchExprSyntax.rightBrace: - return true - default: - return putNextTokenOnNewLine - } - } - - open func childrenSeparatedByNewline(_ node: Syntax) -> Bool { - switch node.as(SyntaxEnum.self) { - case .accessorList: - return true - case .codeBlockItemList: - return true - case .memberDeclList: - return true - case .switchCaseList: +} + +public extension TokenSyntax { + var requiresLeadingNewline: Bool { + if let keyPath = keyPathInParent, keyPath.requiresLeadingNewline { return true - default: - return false - } - } - - /// If this returns a value that is not `nil`, it overrides the default - /// leading space behavior of a token. - open func requiresLeadingSpace(_ keyPath: AnyKeyPath) -> Bool? { - switch keyPath { - case \AvailabilityArgumentSyntax.entry: - return false - case \FunctionParameterSyntax.secondName: - return true - default: - return nil } + return false } - open func requiresLeadingSpace(_ token: TokenSyntax) -> Bool { - if let keyPath = token.keyPathInParent, let requiresLeadingSpace = requiresLeadingSpace(keyPath) { + var requiresLeadingSpace: Bool { + if let keyPath = keyPathInParent, let requiresLeadingSpace = keyPath.requiresLeadingSpace { return requiresLeadingSpace } - switch (token.previousToken(viewMode: .sourceAccurate)?.tokenKind, token.tokenKind) { - case (.leftParen, .leftBrace): // Ensures there is not a space in `.map({ $0.foo })` - return false - default: - break - } - switch token.tokenKind { + switch tokenKind { case .arrow: return true case .binaryOperator: @@ -209,50 +57,11 @@ open class BasicFormat: SyntaxRewriter { } } - /// If this returns a value that is not `nil`, it overrides the default - /// trailing space behavior of a token. - open func requiresTrailingSpace(_ keyPath: AnyKeyPath) -> Bool? { - switch keyPath { - case \AvailabilityArgumentSyntax.entry: - return false - case \BreakStmtSyntax.breakKeyword: - return false - case \DeclNameArgumentSyntax.colon: - return false - case \DictionaryExprSyntax.content: - return false - case \DynamicReplacementArgumentsSyntax.forLabel: - return false - case \SwitchCaseLabelSyntax.colon: - return false - case \SwitchDefaultLabelSyntax.colon: - return false - case \TryExprSyntax.questionOrExclamationMark: - return true - default: - return nil - } - } - - open func requiresTrailingSpace(_ token: TokenSyntax) -> Bool { - if let keyPath = token.keyPathInParent, let requiresTrailingSpace = requiresTrailingSpace(keyPath) { + var requiresTrailingSpace: Bool { + if let keyPath = keyPathInParent, let requiresTrailingSpace = keyPath.requiresTrailingSpace { return requiresTrailingSpace } - switch (token.tokenKind, token.nextToken(viewMode: .sourceAccurate)?.tokenKind) { - case (.exclamationMark, .leftParen), // Ensures there is not a space in `myOptionalClosure!()` - (.exclamationMark, .period), // Ensures there is not a space in `myOptionalBar!.foo()` - (.keyword(.as), .exclamationMark), // Ensures there is not a space in `as!` - (.keyword(.as), .postfixQuestionMark), // Ensures there is not a space in `as?` - (.keyword(.try), .exclamationMark), // Ensures there is not a space in `try!` - (.keyword(.try), .postfixQuestionMark), // Ensures there is not a space in `try?`: - (.postfixQuestionMark, .leftParen), // Ensures there is not a space in `init?()` or `myOptionalClosure?()`s - (.postfixQuestionMark, .rightAngle), // Ensures there is not a space in `ContiguousArray` - (.postfixQuestionMark, .rightParen): // Ensures there is not a space in `myOptionalClosure?()` - return false - default: - break - } - switch token.tokenKind { + switch tokenKind { case .arrow: return true case .binaryOperator: @@ -370,3 +179,97 @@ open class BasicFormat: SyntaxRewriter { } } } + +fileprivate extension AnyKeyPath { + var requiresIndent: Bool { + switch self { + case \AccessorBlockSyntax.accessors: + return true + case \ArrayExprSyntax.elements: + return true + case \ClosureExprSyntax.statements: + return true + case \ClosureParameterClauseSyntax.parameterList: + return true + case \CodeBlockSyntax.statements: + return true + case \DictionaryElementSyntax.valueExpression: + return true + case \DictionaryExprSyntax.content: + return true + case \EnumCaseParameterClauseSyntax.parameterList: + return true + case \FunctionCallExprSyntax.argumentList: + return true + case \FunctionTypeSyntax.arguments: + return true + case \MemberDeclBlockSyntax.members: + return true + case \ParameterClauseSyntax.parameterList: + return true + case \SwitchCaseSyntax.statements: + return true + case \TupleExprSyntax.elementList: + return true + case \TupleTypeSyntax.elements: + return true + default: + return false + } + } + + var requiresLeadingNewline: Bool { + switch self { + case \AccessorBlockSyntax.rightBrace: + return true + case \ClosureExprSyntax.rightBrace: + return true + case \CodeBlockSyntax.rightBrace: + return true + case \IfConfigClauseSyntax.poundKeyword: + return true + case \IfConfigDeclSyntax.poundEndif: + return true + case \MemberDeclBlockSyntax.rightBrace: + return true + case \SwitchExprSyntax.rightBrace: + return true + default: + return false + } + } + + var requiresLeadingSpace: Bool? { + switch self { + case \AvailabilityArgumentSyntax.entry: + return false + case \FunctionParameterSyntax.secondName: + return true + default: + return nil + } + } + + var requiresTrailingSpace: Bool? { + switch self { + case \AvailabilityArgumentSyntax.entry: + return false + case \BreakStmtSyntax.breakKeyword: + return false + case \DeclNameArgumentSyntax.colon: + return false + case \DictionaryExprSyntax.content: + return false + case \DynamicReplacementArgumentsSyntax.forLabel: + return false + case \SwitchCaseLabelSyntax.colon: + return false + case \SwitchDefaultLabelSyntax.colon: + return false + case \TryExprSyntax.questionOrExclamationMark: + return true + default: + return nil + } + } +} diff --git a/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift b/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift index 17424b8eb42..b22445ed9d3 100644 --- a/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift +++ b/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift @@ -127,7 +127,7 @@ extension FixIt.MultiNodeChange { let previousToken = node.previousToken(viewMode: .fixedUp), previousToken.presence == .present, previousToken.trailingTrivia.isEmpty, - BasicFormat().requiresTrailingSpace(previousToken), + BasicFormat().requiresTrailingWhitespace(previousToken), leadingTrivia == nil { /// If neither this nor the previous token are punctionation make sure they diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index a2b0e0a42a2..a9b841be016 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -172,34 +172,6 @@ extension Trivia { } } -extension TriviaPiece { - /// Returns true if the trivia is `.newlines`, `.carriageReturns` or `.carriageReturnLineFeeds` - public var isNewline: Bool { - switch self { - case .newlines, - .carriageReturns, - .carriageReturnLineFeeds: - return true - default: - return false - } - } -} - -extension RawTriviaPiece { - /// Returns true if the trivia is `.newlines`, `.carriageReturns` or `.carriageReturnLineFeeds` - public var isNewline: Bool { - switch self { - case .newlines, - .carriageReturns, - .carriageReturnLineFeeds: - return true - default: - return false - } - } -} - extension RawTriviaPiece: TextOutputStreamable { public func write(to target: inout Target) { TriviaPiece(raw: self).write(to: &target) diff --git a/Sources/SwiftSyntax/generated/TriviaPieces.swift b/Sources/SwiftSyntax/generated/TriviaPieces.swift index 8a9925e4925..d2be79ff6eb 100644 --- a/Sources/SwiftSyntax/generated/TriviaPieces.swift +++ b/Sources/SwiftSyntax/generated/TriviaPieces.swift @@ -463,3 +463,73 @@ extension RawTriviaPiece { } } } + +extension TriviaPiece { + /// Returns `true` if this piece is a newline, space or tab. + public var isWhitespace: Bool { + return isSpaceOrTab || isNewline + } + + public var isNewline: Bool { + switch self { + case .carriageReturns: + return true + case .carriageReturnLineFeeds: + return true + case .formfeeds: + return true + case .newlines: + return true + case .verticalTabs: + return true + default: + return false + } + } + + public var isSpaceOrTab: Bool { + switch self { + case .spaces: + return true + case .tabs: + return true + default: + return false + } + } +} + +extension RawTriviaPiece { + /// Returns `true` if this piece is a newline, space or tab. + public var isWhitespace: Bool { + return isSpaceOrTab || isNewline + } + + public var isNewline: Bool { + switch self { + case .carriageReturns: + return true + case .carriageReturnLineFeeds: + return true + case .formfeeds: + return true + case .newlines: + return true + case .verticalTabs: + return true + default: + return false + } + } + + public var isSpaceOrTab: Bool { + switch self { + case .spaces: + return true + case .tabs: + return true + default: + return false + } + } +} diff --git a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift index 21ac942154f..f928316f782 100644 --- a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift +++ b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift @@ -15,10 +15,12 @@ import SwiftBasicFormat import SwiftSyntaxBuilder private class InitializerExprFormat: BasicFormat { - override var indentation: TriviaPiece { return .spaces(indentationLevel * 2) } + public init() { + super.init(indentationIncrement: .spaces(2)) + } private func formatChildrenSeparatedByNewline(children: SyntaxChildren, elementType: SyntaxType.Type) -> [SyntaxType] { - indentationLevel += 1 + pushIndentationLevel(increasingIndentationBy: indentationWidth) var formattedChildren = children.map { self.visit($0).as(SyntaxType.self)! } @@ -26,12 +28,12 @@ private class InitializerExprFormat: BasicFormat { if $0.leadingTrivia.first?.isNewline == true { return $0 } else { - return $0.with(\.leadingTrivia, indentedNewline + $0.leadingTrivia) + return $0.with(\.leadingTrivia, .newline + currentIndentationLevel + $0.leadingTrivia) } } - indentationLevel -= 1 + popIndentationLevel() if !formattedChildren.isEmpty { - formattedChildren[formattedChildren.count - 1] = formattedChildren[formattedChildren.count - 1].with(\.trailingTrivia, indentedNewline) + formattedChildren[formattedChildren.count - 1] = formattedChildren[formattedChildren.count - 1].with(\.trailingTrivia, .newline + currentIndentationLevel) } return formattedChildren } diff --git a/Tests/SwiftBasicFormatTest/BasicFormatTests.swift b/Tests/SwiftBasicFormatTest/BasicFormatTests.swift new file mode 100644 index 00000000000..6ce1b0a3a80 --- /dev/null +++ b/Tests/SwiftBasicFormatTest/BasicFormatTests.swift @@ -0,0 +1,193 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 SwiftSyntaxBuilder +import SwiftSyntax + +import XCTest +import _SwiftSyntaxTestSupport + +fileprivate func assertFormatted( + source: String, + expected: String, + file: StaticString = #file, + line: UInt = #line +) { + assertStringsEqualWithDiff(Parser.parse(source: source).formatted().description, expected, file: file, line: line) +} + +final class BasicFormatTest: XCTestCase { + func testNotIndented() { + assertFormatted( + source: """ + func foo() { + someFunc(a: 1, + b: 1) + } + """, + expected: """ + func foo() { + someFunc(a: 1, + b: 1) + } + """ + ) + } + + func testPartialIndent() { + assertFormatted( + source: """ + func foo() { + someFunc(a: 1, + b: 1) + } + """, + expected: """ + func foo() { + someFunc(a: 1, + b: 1) + } + """ + ) + } + + func testPartialIndentNested() { + assertFormatted( + source: """ + func outer() { + func inner() { + someFunc(a: 1, + b: 1) + } + } + """, + expected: """ + func outer() { + func inner() { + someFunc(a: 1, + b: 1) + } + } + """ + ) + } + + func testAlreadyIndented() { + let source = """ + func foo() { + someFunc(a: 1, + b: 1) + } + """ + + assertFormatted(source: source, expected: source) + } + + func testAlreadyIndentedWithComment() { + let source = """ + func foo() { + // ABC + someFunc(a: 1, + b: 1) + } + """ + + assertFormatted(source: source, expected: source) + } + + func testAlreadyIndentedWithComment2() { + assertFormatted( + source: """ + func foo() { + // ABC + someFunc(a: 1, + b: 1) + } + """, + expected: """ + func foo() { + // ABC + someFunc(a: 1, + b: 1) + } + """ + ) + } + + func testClosureIndentationArgBefore() { + assertFormatted( + source: """ + someFunc(arg2: 1, + closure: { arg in indented() }) + """, + expected: """ + someFunc(arg2: 1, + closure: { arg in + indented() + }) + """ + ) + } + + func testClosureIndentationAfter() { + assertFormatted( + source: """ + someFunc(closure: { arg in indented() }, + arg2: 1) + """, + expected: """ + someFunc(closure: { arg in + indented() + }, + arg2: 1) + """ + ) + } + + func testLineWrappingInsideIndentedBlock() { + assertFormatted( + source: """ + public init?(errorCode: Int) { + guard errorCode > 0 else { return nil } + self.code = errorCode + } + """, + expected: """ + public init?(errorCode: Int) { + guard errorCode > 0 else { + return nil + } + self.code = errorCode + } + """ + ) + } + + func testCustomIndentationInBlockThatDoesntHaveNewline() { + assertFormatted( + source: """ + extension MyType {func buildSyntax(format: Format) -> Syntax { + return Syntax(buildTest(format: format)) + }} + """, + expected: """ + extension MyType { + func buildSyntax(format: Format) -> Syntax { + return Syntax(buildTest(format: format)) + } + } + """ + ) + } +} diff --git a/Tests/SwiftSyntaxBuilderTest/ArrayExprTests.swift b/Tests/SwiftSyntaxBuilderTest/ArrayExprTests.swift index 6f3f383e841..1b5e2759e79 100644 --- a/Tests/SwiftSyntaxBuilderTest/ArrayExprTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/ArrayExprTests.swift @@ -39,10 +39,10 @@ final class ArrayExprTests: XCTestCase { builder, """ [ - 1, - #"2"3"#, - 4, - "五", + 1, + #"2"3"#, + 4, + "五", ] """ ) diff --git a/Tests/SwiftSyntaxBuilderTest/ClassDeclSyntaxTests.swift b/Tests/SwiftSyntaxBuilderTest/ClassDeclSyntaxTests.swift index d1b45b91bc7..cba089801dc 100644 --- a/Tests/SwiftSyntaxBuilderTest/ClassDeclSyntaxTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/ClassDeclSyntaxTests.swift @@ -52,4 +52,27 @@ final class ClassDeclSyntaxTests: XCTestCase { """ ) } + + func testNodeWithoutAnchorPointInResultBuilder() throws { + let buildable = ClassDeclSyntax(identifier: .identifier("Foo")) { + DeclSyntax( + """ + func foo() -> String { + return "hello world" + } + """ + ) + } + + assertBuildResult( + buildable, + """ + class Foo { + func foo() -> String { + return "hello world" + } + } + """ + ) + } } diff --git a/Tests/SwiftSyntaxBuilderTest/CollectionNodeFlatteningTests.swift b/Tests/SwiftSyntaxBuilderTest/CollectionNodeFlatteningTests.swift index 74a3ecfa7a0..099990c3c9e 100644 --- a/Tests/SwiftSyntaxBuilderTest/CollectionNodeFlatteningTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/CollectionNodeFlatteningTests.swift @@ -16,8 +16,6 @@ import SwiftSyntaxBuilder final class CollectionNodeFlatteningTests: XCTestCase { func test_FlattenCodeBlockItemListWithBuilder() { - let leadingTrivia = Trivia.unexpectedText("␣") - @CodeBlockItemListBuilder func buildInnerCodeBlockItemList() -> CodeBlockItemListSyntax { FunctionCallExprSyntax(callee: ExprSyntax("innerBuilder")) @@ -30,7 +28,7 @@ final class CollectionNodeFlatteningTests: XCTestCase { buildInnerCodeBlockItemList() } - let codeBlock = CodeBlockSyntax(leadingTrivia: leadingTrivia) { + let codeBlock = CodeBlockSyntax { FunctionCallExprSyntax(callee: ExprSyntax("outsideBuilder")) buildOuterCodeBlockItemList() } @@ -38,7 +36,7 @@ final class CollectionNodeFlatteningTests: XCTestCase { assertBuildResult( codeBlock, """ - ␣{ + { outsideBuilder() outerBuilder() innerBuilder() diff --git a/Tests/SwiftSyntaxBuilderTest/DictionaryExprTests.swift b/Tests/SwiftSyntaxBuilderTest/DictionaryExprTests.swift index 05a004dd6f5..193792d1a59 100644 --- a/Tests/SwiftSyntaxBuilderTest/DictionaryExprTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/DictionaryExprTests.swift @@ -45,11 +45,11 @@ final class DictionaryExprTests: XCTestCase { builder, """ [ - 1: 1, - 2: "二", - "three": 3, - 4: - #"f"o"u"r"#, + 1: 1, + 2: "二", + "three": 3, + 4: + #"f"o"u"r"#, ] """ ) diff --git a/Tests/SwiftSyntaxBuilderTest/ForInStmtTests.swift b/Tests/SwiftSyntaxBuilderTest/ForInStmtTests.swift index bfce5852a98..149875f85eb 100644 --- a/Tests/SwiftSyntaxBuilderTest/ForInStmtTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/ForInStmtTests.swift @@ -38,7 +38,7 @@ final class ForInStmtTests: XCTestCase { ).cast(ForInStmtSyntax.self), """ for foo in bar { - _ = foo + _ = foo } """ ), diff --git a/Tests/SwiftSyntaxBuilderTest/FunctionTests.swift b/Tests/SwiftSyntaxBuilderTest/FunctionTests.swift index 9f5a8cd6874..3151d7656bf 100644 --- a/Tests/SwiftSyntaxBuilderTest/FunctionTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/FunctionTests.swift @@ -63,7 +63,7 @@ final class FunctionTests: XCTestCase { ), """ public static func == (lhs: String, rhs: String) -> Bool { - return lhs < rhs + return lhs < rhs } """ ), @@ -77,7 +77,7 @@ final class FunctionTests: XCTestCase { ), """ public static func == (lhs: String, rhs: String) -> Bool { - return lhs > rhs + return lhs > rhs } """ ), @@ -91,7 +91,7 @@ final class FunctionTests: XCTestCase { ), """ public static func == (lhs1: String, lhs2: String, rhs1: String, rhs2: String) -> Bool { - return (lhs1, lhs2) > (rhs1, rhs2) + return (lhs1, lhs2) > (rhs1, rhs2) } """ ), @@ -105,7 +105,7 @@ final class FunctionTests: XCTestCase { ), """ public func foo(input: Bas) -> Foo { - return input as Foo! + return input as Foo! } """ ), @@ -119,7 +119,7 @@ final class FunctionTests: XCTestCase { ), """ public func foo(input: Bas) -> Foo { - return input as Foo! + return input as Foo! } """ ), @@ -133,7 +133,7 @@ final class FunctionTests: XCTestCase { ), """ public func foo(input: [Bar]) -> Foo<[Bar]> { - return input + return input } """ ), @@ -147,7 +147,7 @@ final class FunctionTests: XCTestCase { ), """ public func foo(myOptionalClosure: MyClosure?) { - myOptionalClosure!() + myOptionalClosure!() } """ ), @@ -290,13 +290,13 @@ final class FunctionTests: XCTestCase { builder, """ func test( - _ p1: Int, - p2: Int, - _ p3: Int, - p4: Int, - _ p5: Int + _ p1: Int, + p2: Int, + _ p3: Int, + p4: Int, + _ p5: Int ) -> Int { - return p1 + p2 + p3 + p4 + p5 + return p1 + p2 + p3 + p4 + p5 } """ ) diff --git a/Tests/SwiftSyntaxBuilderTest/IfConfigDeclSyntaxTests.swift b/Tests/SwiftSyntaxBuilderTest/IfConfigDeclSyntaxTests.swift index 226bee15f34..20bd7f27945 100644 --- a/Tests/SwiftSyntaxBuilderTest/IfConfigDeclSyntaxTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/IfConfigDeclSyntaxTests.swift @@ -56,11 +56,11 @@ final class IfConfigDeclSyntaxTests: XCTestCase { """ #if DEBUG public func debug(_ data: Foo) -> String { - return data.debugDescription + return data.debugDescription } #else public func debug(_ data: Foo) -> String { - return data.description + return data.description } #endif """ diff --git a/Tests/SwiftSyntaxBuilderTest/IfStmtTests.swift b/Tests/SwiftSyntaxBuilderTest/IfStmtTests.swift index 802a37b88c3..3e954bb342c 100644 --- a/Tests/SwiftSyntaxBuilderTest/IfStmtTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/IfStmtTests.swift @@ -41,7 +41,7 @@ final class IfStmtTests: XCTestCase { ).cast(IfExprSyntax.self), """ if foo == x { - return foo + return foo } """ ), @@ -58,10 +58,10 @@ final class IfStmtTests: XCTestCase { ).cast(IfExprSyntax.self), """ if foo == x { - return foo + return foo } else { - return bar + return bar } """ ), diff --git a/Tests/SwiftSyntaxBuilderTest/InitializerDeclTests.swift b/Tests/SwiftSyntaxBuilderTest/InitializerDeclTests.swift index bc80a703c5d..8b8b17e6226 100644 --- a/Tests/SwiftSyntaxBuilderTest/InitializerDeclTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/InitializerDeclTests.swift @@ -28,7 +28,7 @@ final class InitializerDeclTests: XCTestCase { builder, """ public init(errorCode: Int) { - self.code = errorCode + self.code = errorCode } """ ) @@ -48,10 +48,10 @@ final class InitializerDeclTests: XCTestCase { builder, """ public init?(errorCode: Int) { - guard errorCode > 0 else { - return nil - } - self.code = errorCode + guard errorCode > 0 else { + return nil + } + self.code = errorCode } """ ) @@ -76,13 +76,13 @@ final class InitializerDeclTests: XCTestCase { builder, """ init( - _ p1: Int, - p2: Int, - _ p3: Int, - p4: Int, - _ p5: Int + _ p1: Int, + p2: Int, + _ p3: Int, + p4: Int, + _ p5: Int ) { - self.init(p1 + p2 + p3 + p4 + p5) + self.init(p1 + p2 + p3 + p4 + p5) } """ ) diff --git a/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift b/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift index 35e7af26829..b1362544aa8 100644 --- a/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift @@ -19,8 +19,8 @@ import SwiftBasicFormat import XCTest class TwoSpacesFormat: BasicFormat { - override var indentation: TriviaPiece { - .spaces(indentationLevel * 2) + public init() { + super.init(indentationIncrement: .spaces(2)) } } diff --git a/Tests/SwiftSyntaxBuilderTest/StringLiteralTests.swift b/Tests/SwiftSyntaxBuilderTest/StringLiteralTests.swift index 76da43a3cf3..1de0bf7a62b 100644 --- a/Tests/SwiftSyntaxBuilderTest/StringLiteralTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/StringLiteralTests.swift @@ -268,11 +268,11 @@ final class StringLiteralTests: XCTestCase { buildable, #""" assertionFailure(""" - Error validating child at index \(index) of \(nodeKind): - Node did not satisfy any node choice requirement. - Validation failures: - \(nonNilErrors.map({ "- \($0.description)" }).joined(separator: "\n")) - """, file: file, line: line) + Error validating child at index \(index) of \(nodeKind): + Node did not satisfy any node choice requirement. + Validation failures: + \(nonNilErrors.map({ "- \($0.description)" }).joined(separator: "\n")) + """, file: file, line: line) """# ) } @@ -294,11 +294,11 @@ final class StringLiteralTests: XCTestCase { buildable, #""" if true { - assertionFailure(""" - Error validating child at index - Node did not satisfy any node choice requirement. - Validation failures: - """) + assertionFailure(""" + Error validating child at index + Node did not satisfy any node choice requirement. + Validation failures: + """) } """# ) @@ -323,13 +323,13 @@ final class StringLiteralTests: XCTestCase { buildable, #""" if true { - assertionFailure( - """ - Error validating child at index - Node did not satisfy any node choice requirement. - Validation failures: - """ - ) + assertionFailure( + """ + Error validating child at index + Node did not satisfy any node choice requirement. + Validation failures: + """ + ) } """# ) From 1a555cf1f604d2944fa79fcf5fbf484c4c502a3b Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Wed, 19 Apr 2023 18:18:09 -0700 Subject: [PATCH 2/3] Improve performance of `BasicFormat` This mostly improves performance by calling `previousToken`, `nextToken`, and `firstToken` less often. --- .../Sources/Utils/CodeGenerationFormat.swift | 12 +++- Sources/SwiftBasicFormat/BasicFormat.swift | 64 +++++++++++-------- .../DiagnosticExtensions.swift | 2 +- .../SyntaxProtocol+Initializer.swift | 2 +- .../StringInterpolationTests.swift | 2 +- 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift b/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift index 2130aa39c1f..f3a02dac6c8 100644 --- a/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift +++ b/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift @@ -15,7 +15,13 @@ import SwiftSyntax /// A format style for files generated by CodeGeneration. public class CodeGenerationFormat: BasicFormat { - public override var indentation: TriviaPiece { .spaces(indentationLevel * 2) } + public init() { + super.init(indentationWidth: .spaces(2)) + } + + var indentedNewline: Trivia { + .newline + currentIndentationLevel + } public override func visit(_ node: ArrayElementListSyntax) -> ArrayElementListSyntax { let children = node.children(viewMode: .all) @@ -100,7 +106,7 @@ public class CodeGenerationFormat: BasicFormat { } private func formatChildrenSeparatedByNewline(children: SyntaxChildren, elementType: SyntaxType.Type) -> [SyntaxType] { - indentationLevel += 1 + pushIndentationLevel(increasingIndentationBy: indentationWidth) var formattedChildren = children.map { self.visit($0).as(SyntaxType.self)! } @@ -111,7 +117,7 @@ public class CodeGenerationFormat: BasicFormat { return $0.with(\.leadingTrivia, indentedNewline + $0.leadingTrivia) } } - indentationLevel -= 1 + popIndentationLevel() if !formattedChildren.isEmpty { formattedChildren[formattedChildren.count - 1] = formattedChildren[formattedChildren.count - 1].with(\.trailingTrivia, indentedNewline) } diff --git a/Sources/SwiftBasicFormat/BasicFormat.swift b/Sources/SwiftBasicFormat/BasicFormat.swift index 2cf0f5d7554..9006a98664f 100644 --- a/Sources/SwiftBasicFormat/BasicFormat.swift +++ b/Sources/SwiftBasicFormat/BasicFormat.swift @@ -32,8 +32,13 @@ open class BasicFormat: SyntaxRewriter { /// This is used as a reference-point to indent user-indented code. private var anchorPoints: [TokenSyntax: Trivia] = [:] - public init(indentationIncrement: Trivia = .spaces(4), initialIndentation: Trivia = []) { - self.indentationWidth = indentationIncrement + /// The previously visited token. This is faster than accessing + /// `token.previousToken` inside `visit(_:TokenSyntax)`. `nil` if no token has + /// been visited yet. + private var previousToken: TokenSyntax? = nil + + public init(indentationWidth: Trivia = .spaces(4), initialIndentation: Trivia = []) { + self.indentationWidth = indentationWidth self.indentationStack = [initialIndentation] } @@ -132,7 +137,7 @@ open class BasicFormat: SyntaxRewriter { var ancestor: Syntax = Syntax(token) while let parent = ancestor.parent { ancestor = parent - if ancestor.firstToken(viewMode: .sourceAccurate) != token { + if ancestor.position != token.position { break } if let ancestorsParent = ancestor.parent, childrenSeparatedByNewline(ancestorsParent) { @@ -143,22 +148,10 @@ open class BasicFormat: SyntaxRewriter { return false } - /// Whether a leading space on `token` should be added. - open func requiresLeadingWhitespace(_ token: TokenSyntax) -> Bool { - switch (token.previousToken(viewMode: .sourceAccurate)?.tokenKind, token.tokenKind) { - case (.leftParen, .leftBrace): // Ensures there is not a space in `.map({ $0.foo })` - return false - default: - break - } - - return token.requiresLeadingSpace - } - - /// Whether a trailing space on `token` should be added. - open func requiresTrailingWhitespace(_ token: TokenSyntax) -> Bool { - switch (token.tokenKind, token.nextToken(viewMode: .sourceAccurate)?.tokenKind) { - case (.exclamationMark, .leftParen), // Ensures there is not a space in `myOptionalClosure!()` + open func requiresWhitespace(between first: TokenSyntax?, and second: TokenSyntax?) -> Bool { + switch (first?.tokenKind, second?.tokenKind) { + case (.leftParen, .leftBrace), // Ensures there is not a space in `.map({ $0.foo })` + (.exclamationMark, .leftParen), // Ensures there is not a space in `myOptionalClosure!()` (.exclamationMark, .period), // Ensures there is not a space in `myOptionalBar!.foo()` (.keyword(.as), .exclamationMark), // Ensures there is not a space in `as!` (.keyword(.as), .postfixQuestionMark), // Ensures there is not a space in `as?` @@ -172,22 +165,34 @@ open class BasicFormat: SyntaxRewriter { break } - return token.requiresTrailingSpace + if first?.requiresTrailingSpace ?? false { + return true + } + if second?.requiresLeadingSpace ?? false { + return true + } + return false } // MARK: - Formatting a token open override func visit(_ token: TokenSyntax) -> TokenSyntax { + defer { + self.previousToken = token + } + let previousToken = self.previousToken ?? token.previousToken(viewMode: .sourceAccurate) + let nextToken = token.nextToken(viewMode: .sourceAccurate) + lazy var previousTokenWillEndWithWhitespace: Bool = { - guard let previousToken = token.previousToken(viewMode: .sourceAccurate) else { + guard let previousToken = previousToken else { return false } return previousToken.trailingTrivia.pieces.last?.isWhitespace ?? false - || requiresTrailingWhitespace(previousToken) + || requiresWhitespace(between: previousToken, and: token) }() lazy var previousTokenWillEndWithNewline: Bool = { - guard let previousToken = token.previousToken(viewMode: .sourceAccurate) else { + guard let previousToken = previousToken else { // Assume that the start of the tree is equivalent to a newline so we // don't add a leading newline to the file. return true @@ -196,7 +201,7 @@ open class BasicFormat: SyntaxRewriter { }() lazy var nextTokenWillStartWithNewline: Bool = { - guard let nextToken = token.nextToken(viewMode: .sourceAccurate) else { + guard let nextToken = nextToken else { return false } return nextToken.leadingTrivia.startsWithNewline @@ -206,7 +211,6 @@ open class BasicFormat: SyntaxRewriter { /// This token's trailing trivia + any spaces or tabs at the start of the /// next token's leading trivia. lazy var combinedTrailingTrivia: Trivia = { - let nextToken = token.nextToken(viewMode: .sourceAccurate) let nextTokenLeadingWhitespace = nextToken?.leadingTrivia.prefix(while: { $0.isSpaceOrTab }) ?? [] return trailingTrivia + Trivia(pieces: nextTokenLeadingWhitespace) }() @@ -224,7 +228,7 @@ open class BasicFormat: SyntaxRewriter { // - the previous token didn't end with a newline leadingTrivia = .newline + leadingTrivia } - } else if requiresLeadingWhitespace(token) { + } else if requiresWhitespace(between: previousToken, and: token) { // Add a leading space if the token requires it unless // - it already starts with a whitespace or // - the previous token ends with a whitespace after the rewrite @@ -243,7 +247,7 @@ open class BasicFormat: SyntaxRewriter { // - it already ends with a whitespace or // - the next token will start starts with a newline after the rewrite // because newlines should be preferred to spaces as a whitespace - if requiresTrailingWhitespace(token) + if requiresWhitespace(between: token, and: nextToken) && !trailingTrivia.endsWithWhitespace && !nextTokenWillStartWithNewline { @@ -272,6 +276,10 @@ open class BasicFormat: SyntaxRewriter { leadingTrivia = leadingTrivia.trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: false) trailingTrivia = trailingTrivia.trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: nextTokenWillStartWithNewline) - return token.with(\.leadingTrivia, leadingTrivia).with(\.trailingTrivia, trailingTrivia) + if leadingTrivia == token.leadingTrivia && trailingTrivia == token.trailingTrivia { + return token + } + + return token.detach().with(\.leadingTrivia, leadingTrivia).with(\.trailingTrivia, trailingTrivia) } } diff --git a/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift b/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift index b22445ed9d3..2a2d7d35b82 100644 --- a/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift +++ b/Sources/SwiftParserDiagnostics/DiagnosticExtensions.swift @@ -127,7 +127,7 @@ extension FixIt.MultiNodeChange { let previousToken = node.previousToken(viewMode: .fixedUp), previousToken.presence == .present, previousToken.trailingTrivia.isEmpty, - BasicFormat().requiresTrailingWhitespace(previousToken), + BasicFormat().requiresWhitespace(between: previousToken, and: node.firstToken(viewMode: .fixedUp)), leadingTrivia == nil { /// If neither this nor the previous token are punctionation make sure they diff --git a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift index f928316f782..c9b4809437c 100644 --- a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift +++ b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift @@ -16,7 +16,7 @@ import SwiftSyntaxBuilder private class InitializerExprFormat: BasicFormat { public init() { - super.init(indentationIncrement: .spaces(2)) + super.init(indentationWidth: .spaces(2)) } private func formatChildrenSeparatedByNewline(children: SyntaxChildren, elementType: SyntaxType.Type) -> [SyntaxType] { diff --git a/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift b/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift index b1362544aa8..f195e2bbb1b 100644 --- a/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/StringInterpolationTests.swift @@ -20,7 +20,7 @@ import XCTest class TwoSpacesFormat: BasicFormat { public init() { - super.init(indentationIncrement: .spaces(2)) + super.init(indentationWidth: .spaces(2)) } } From 2e4761e3432b8616002cc30a93d88b5a77b3c7ea Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Wed, 19 Apr 2023 18:32:14 -0700 Subject: [PATCH 3/3] Minor fixes to BasicFormat that I found while using it to run CodeGeneration --- .../Sources/Utils/CodeGenerationFormat.swift | 4 +- Sources/SwiftBasicFormat/BasicFormat.swift | 59 ++++++++++++++----- .../SyntaxProtocol+Initializer.swift | 4 +- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift b/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift index f3a02dac6c8..361cc90e06e 100644 --- a/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift +++ b/CodeGeneration/Sources/Utils/CodeGenerationFormat.swift @@ -106,7 +106,7 @@ public class CodeGenerationFormat: BasicFormat { } private func formatChildrenSeparatedByNewline(children: SyntaxChildren, elementType: SyntaxType.Type) -> [SyntaxType] { - pushIndentationLevel(increasingIndentationBy: indentationWidth) + increaseIndentationLevel() var formattedChildren = children.map { self.visit($0).as(SyntaxType.self)! } @@ -117,7 +117,7 @@ public class CodeGenerationFormat: BasicFormat { return $0.with(\.leadingTrivia, indentedNewline + $0.leadingTrivia) } } - popIndentationLevel() + decreaseIndentationLevel() if !formattedChildren.isEmpty { formattedChildren[formattedChildren.count - 1] = formattedChildren[formattedChildren.count - 1].with(\.trailingTrivia, indentedNewline) } diff --git a/Sources/SwiftBasicFormat/BasicFormat.swift b/Sources/SwiftBasicFormat/BasicFormat.swift index 9006a98664f..930079cb1ad 100644 --- a/Sources/SwiftBasicFormat/BasicFormat.swift +++ b/Sources/SwiftBasicFormat/BasicFormat.swift @@ -18,12 +18,15 @@ open class BasicFormat: SyntaxRewriter { /// As we reach a new indendation level, its indentation will be added to the /// stack. As we exit that indentation level, the indendation will be popped. - public var indentationStack: [Trivia] + /// `isUserDefined` is `true` if the indentation was inferred from something + /// the user provided manually instead of being inferred from the nesting + /// level. + public var indentationStack: [(indentation: Trivia, isUserDefined: Bool)] /// The trivia by which tokens should currently be indented. public var currentIndentationLevel: Trivia { - // `popIndentationLevel` guarantees that there is always one item on the stack. - return indentationStack.last! + // `decreaseIndentationLevel` guarantees that there is always one item on the stack. + return indentationStack.last!.indentation } /// For every token that is being put on a new line but did not have @@ -39,17 +42,24 @@ open class BasicFormat: SyntaxRewriter { public init(indentationWidth: Trivia = .spaces(4), initialIndentation: Trivia = []) { self.indentationWidth = indentationWidth - self.indentationStack = [initialIndentation] + self.indentationStack = [(indentation: initialIndentation, isUserDefined: false)] } // MARK: - Updating indentation level - public func pushIndentationLevel(increasingIndentationBy: Trivia) { - indentationStack.append(currentIndentationLevel + increasingIndentationBy) + public func increaseIndentationLevel(to userDefinedIndentation: Trivia? = nil) { + if let userDefinedIndentation = userDefinedIndentation { + indentationStack.append((indentation: userDefinedIndentation, isUserDefined: false)) + } else { + indentationStack.append((indentation: currentIndentationLevel + indentationWidth, isUserDefined: false)) + } } - public func popIndentationLevel() { - precondition(indentationStack.count > 1, "Popping more indentation levels than have been pushed") + public func decreaseIndentationLevel() { + if indentationStack.count == 1 { + assertionFailure("Popping more indentation levels than have been pushed") + return + } indentationStack.removeLast() } @@ -57,19 +67,20 @@ open class BasicFormat: SyntaxRewriter { if requiresIndent(node) { if let firstToken = node.firstToken(viewMode: .sourceAccurate), let tokenIndentation = firstToken.leadingTrivia.indentation(isOnNewline: false), - !tokenIndentation.isEmpty + !tokenIndentation.isEmpty, + let lastNonUserDefinedIndentation = indentationStack.last(where: { !$0.isUserDefined })?.indentation { // If the first token in this block is indented, infer the indentation level from it. - pushIndentationLevel(increasingIndentationBy: tokenIndentation) + increaseIndentationLevel(to: lastNonUserDefinedIndentation + tokenIndentation) } else { - pushIndentationLevel(increasingIndentationBy: indentationWidth) + increaseIndentationLevel() } } } open override func visitPost(_ node: Syntax) { if requiresIndent(node) { - popIndentationLevel() + decreaseIndentationLevel() } } @@ -187,7 +198,7 @@ open class BasicFormat: SyntaxRewriter { guard let previousToken = previousToken else { return false } - return previousToken.trailingTrivia.pieces.last?.isWhitespace ?? false + return previousToken.trailingTrivia.endsWithWhitespace || requiresWhitespace(between: previousToken, and: token) }() @@ -197,7 +208,25 @@ open class BasicFormat: SyntaxRewriter { // don't add a leading newline to the file. return true } - return previousToken.trailingTrivia.endsWithNewline + if previousToken.trailingTrivia.endsWithNewline { + return true + } + if case .stringSegment(let segment) = previousToken.tokenKind, segment.last?.isNewline ?? false { + return true + } + return false + }() + + lazy var previousTokenIsStringLiteralEndingInNewline: Bool = { + guard let previousToken = previousToken else { + // Assume that the start of the tree is equivalent to a newline so we + // don't add a leading newline to the file. + return true + } + if case .stringSegment(let segment) = previousToken.tokenKind, segment.last?.isNewline ?? false { + return true + } + return false }() lazy var nextTokenWillStartWithNewline: Bool = { @@ -270,7 +299,7 @@ open class BasicFormat: SyntaxRewriter { trailingTriviaIndentation = anchorPointIndentation } - leadingTrivia = leadingTrivia.indented(indentation: leadingTriviaIndentation, isOnNewline: false) + leadingTrivia = leadingTrivia.indented(indentation: leadingTriviaIndentation, isOnNewline: previousTokenIsStringLiteralEndingInNewline) trailingTrivia = trailingTrivia.indented(indentation: trailingTriviaIndentation, isOnNewline: false) leadingTrivia = leadingTrivia.trimmingTrailingWhitespaceBeforeNewline(isBeforeNewline: false) diff --git a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift index c9b4809437c..69f6be18ccd 100644 --- a/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift +++ b/Sources/_SwiftSyntaxTestSupport/SyntaxProtocol+Initializer.swift @@ -20,7 +20,7 @@ private class InitializerExprFormat: BasicFormat { } private func formatChildrenSeparatedByNewline(children: SyntaxChildren, elementType: SyntaxType.Type) -> [SyntaxType] { - pushIndentationLevel(increasingIndentationBy: indentationWidth) + increaseIndentationLevel() var formattedChildren = children.map { self.visit($0).as(SyntaxType.self)! } @@ -31,7 +31,7 @@ private class InitializerExprFormat: BasicFormat { return $0.with(\.leadingTrivia, .newline + currentIndentationLevel + $0.leadingTrivia) } } - popIndentationLevel() + decreaseIndentationLevel() if !formattedChildren.isEmpty { formattedChildren[formattedChildren.count - 1] = formattedChildren[formattedChildren.count - 1].with(\.trailingTrivia, .newline + currentIndentationLevel) }