Skip to content

Commit 54887a8

Browse files
authored
Merge pull request swiftlang#2511 from ahoppen/ahoppen/trailing-trivia-whitespace
Fix trivia handling issues in editor placeholder expansion
2 parents ecbd7df + 48f665c commit 54887a8

8 files changed

+316
-66
lines changed

Sources/SwiftBasicFormat/BasicFormat.swift

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ open class BasicFormat: SyntaxRewriter {
6969
super.init(viewMode: viewMode)
7070
}
7171

72+
/// Clears all stateful data from this `BasicFormat`.
73+
///
74+
/// This needs to be called between multiple `rewrite` calls to a syntax tree.
75+
func reset() {
76+
indentationStack = [indentationStack.first!]
77+
anchorPoints = [:]
78+
previousToken = nil
79+
stringLiteralNestingLevel = 0
80+
}
81+
7282
// MARK: - Updating indentation level
7383

7484
public func increaseIndentationLevel(to userDefinedIndentation: Trivia? = nil) {
@@ -145,7 +155,7 @@ open class BasicFormat: SyntaxRewriter {
145155

146156
/// Find the indentation of the nearest ancestor whose first token is an
147157
/// anchor point (see `anchorPoints`).
148-
private func anchorPointIndentation(for token: TokenSyntax) -> Trivia? {
158+
private func anchorPointIndentation(for token: TokenSyntax) -> Trivia {
149159
var ancestor: Syntax = Syntax(token)
150160
while let parent = ancestor.parent {
151161
ancestor = parent
@@ -155,7 +165,7 @@ open class BasicFormat: SyntaxRewriter {
155165
return anchorPointIndentation
156166
}
157167
}
158-
return nil
168+
return Trivia()
159169
}
160170

161171
// MARK: - Customization points
@@ -575,23 +585,24 @@ open class BasicFormat: SyntaxRewriter {
575585
trailingTrivia += .space
576586
}
577587

578-
var leadingTriviaIndentation = self.currentIndentationLevel
579-
var trailingTriviaIndentation = self.currentIndentationLevel
588+
let leadingTriviaIndentation: Trivia
589+
let trailingTriviaIndentation: Trivia
580590

581591
// If the trivia contains user-defined indentation, find their anchor point
582592
// and indent the token relative to that anchor point.
593+
//
583594
// Always indent string literals relative to their anchor point because
584595
// their indentation has structural meaning and we just want to maintain
585596
// what the user wrote.
586-
if leadingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline) || isInsideStringLiteral,
587-
let anchorPointIndentation = self.anchorPointIndentation(for: token)
588-
{
589-
leadingTriviaIndentation = anchorPointIndentation
597+
if leadingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline) || isInsideStringLiteral {
598+
leadingTriviaIndentation = anchorPointIndentation(for: token)
599+
} else {
600+
leadingTriviaIndentation = currentIndentationLevel
590601
}
591-
if combinedTrailingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline) || isInsideStringLiteral,
592-
let anchorPointIndentation = self.anchorPointIndentation(for: token)
593-
{
594-
trailingTriviaIndentation = anchorPointIndentation
602+
if combinedTrailingTrivia.containsIndentation(isOnNewline: previousTokenWillEndWithNewline) {
603+
trailingTriviaIndentation = anchorPointIndentation(for: token)
604+
} else {
605+
trailingTriviaIndentation = currentIndentationLevel
595606
}
596607

597608
leadingTrivia = leadingTrivia.indented(

Sources/SwiftBasicFormat/Syntax+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ extension TokenSyntax {
1919
/// find the indentation of the line this token occurs on.
2020
private var indentation: Trivia? {
2121
let previous = self.previousToken(viewMode: .sourceAccurate)
22-
return ((previous?.trailingTrivia ?? []) + leadingTrivia).indentation(isOnNewline: false)
22+
return ((previous?.trailingTrivia ?? []) + leadingTrivia).indentation(isOnNewline: previous == nil)
2323
}
2424

2525
/// Returns the indentation of the line this token occurs on

Sources/SwiftBasicFormat/SyntaxProtocol+Formatted.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import SwiftSyntax
1515
public extension SyntaxProtocol {
1616
/// Build a syntax node from this `Buildable` and format it with the given format.
1717
func formatted(using format: BasicFormat = BasicFormat()) -> Syntax {
18+
format.reset()
1819
return format.rewrite(self)
1920
}
2021
}

Sources/SwiftRefactor/CallToTrailingClosures.swift

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public struct CallToTrailingClosures: SyntaxRefactoringProvider {
4848
// TODO: Rather than returning nil, we should consider throwing errors with
4949
// appropriate messages instead.
5050
public static func refactor(syntax call: FunctionCallExprSyntax, in context: Context = Context()) -> FunctionCallExprSyntax? {
51-
return call.convertToTrailingClosures(from: context.startAtArgument)?.formatted().as(FunctionCallExprSyntax.self)
51+
let converted = call.convertToTrailingClosures(from: context.startAtArgument)
52+
return converted?.formatted().as(FunctionCallExprSyntax.self)
5253
}
5354
}
5455

@@ -69,7 +70,7 @@ extension FunctionCallExprSyntax {
6970
// Trailing comma won't exist any more, move its trivia to the end of
7071
// the closure instead
7172
if let comma = arg.trailingComma {
72-
closure = closure.with(\.trailingTrivia, closure.trailingTrivia.merging(triviaOf: comma))
73+
closure.trailingTrivia = closure.trailingTrivia.merging(triviaOf: comma)
7374
}
7475
closures.append((arg, closure))
7576
}
@@ -80,13 +81,12 @@ extension FunctionCallExprSyntax {
8081

8182
// First trailing closure won't have label/colon. Transfer their trivia.
8283
var trailingClosure = closures.first!.closure
83-
.with(
84-
\.leadingTrivia,
85-
Trivia()
86-
.merging(triviaOf: closures.first!.original.label)
87-
.merging(triviaOf: closures.first!.original.colon)
88-
.merging(closures.first!.closure.leadingTrivia)
89-
)
84+
trailingClosure.leadingTrivia =
85+
Trivia()
86+
.merging(triviaOf: closures.first!.original.label)
87+
.merging(triviaOf: closures.first!.original.colon)
88+
.merging(closures.first!.closure.leadingTrivia)
89+
.droppingLeadingWhitespace
9090
let additionalTrailingClosures = closures.dropFirst().map {
9191
MultipleTrailingClosureElementSyntax(
9292
label: $0.original.label ?? .wildcardToken(),
@@ -97,53 +97,61 @@ extension FunctionCallExprSyntax {
9797

9898
var converted = self.detached
9999

100+
// Trivia that should be attached to the end of the converted call.
101+
var additionalTriviaAtEndOfCall: Trivia? = nil
102+
100103
// Remove parens if there's no non-closure arguments left and remove the
101104
// last comma otherwise. Makes sure to keep the trivia of any removed node.
102105
var argList = Array(arguments.dropLast(closures.count))
103106
if argList.isEmpty {
104-
converted =
105-
converted
106-
.with(\.leftParen, nil)
107-
.with(\.rightParen, nil)
107+
converted.leftParen = nil
108+
converted.rightParen = nil
108109

109110
// No left paren any more, right paren is handled below since it makes
110111
// sense to keep its trivia of the end of the call, regardless of whether
111112
// it was removed or not.
112113
if let leftParen = leftParen {
113-
trailingClosure = trailingClosure.with(
114-
\.leadingTrivia,
115-
Trivia()
116-
.merging(triviaOf: leftParen)
117-
.merging(trailingClosure.leadingTrivia)
118-
)
114+
trailingClosure.leadingTrivia = Trivia()
115+
.merging(triviaOf: leftParen)
116+
.merging(trailingClosure.leadingTrivia)
117+
}
118+
// No right paren anymore. Attach its trivia to the end of the call.
119+
if let rightParen = rightParen {
120+
additionalTriviaAtEndOfCall = Trivia().merging(triviaOf: rightParen)
119121
}
120122
} else {
121123
let last = argList.last!
122-
if let comma = last.trailingComma {
123-
converted =
124-
converted
125-
.with(\.rightParen, TokenSyntax.rightParenToken(trailingTrivia: Trivia().merging(triviaOf: comma)))
126-
}
127-
argList[argList.count - 1] =
128-
last
129-
.with(\.trailingComma, nil)
124+
// Move the trailing trivia of the closing parenthesis to the end of the call after the last trailing, instead of
125+
// keeping it in the middle of the call where the new closing parenthesis lives.
126+
// Also ensure that we don't drop trivia from any comma we remove.
127+
converted.rightParen?.trailingTrivia = Trivia().merging(triviaOf: last.trailingComma)
128+
additionalTriviaAtEndOfCall = rightParen?.trailingTrivia
129+
argList[argList.count - 1] = last.with(\.trailingComma, nil)
130130
}
131131

132132
// Update arguments and trailing closures
133-
converted =
134-
converted
135-
.with(\.arguments, LabeledExprListSyntax(argList))
136-
.with(\.trailingClosure, trailingClosure)
133+
converted.arguments = LabeledExprListSyntax(argList)
134+
converted.trailingClosure = trailingClosure
137135
if !additionalTrailingClosures.isEmpty {
138-
converted = converted.with(\.additionalTrailingClosures, MultipleTrailingClosureElementListSyntax(additionalTrailingClosures))
136+
converted.additionalTrailingClosures = MultipleTrailingClosureElementListSyntax(additionalTrailingClosures)
139137
}
140138

141-
// The right paren either doesn't exist any more, or is before all the
142-
// trailing closures. Moves its trivia to the end of the converted call.
143-
if let rightParen = rightParen {
144-
converted = converted.with(\.trailingTrivia, converted.trailingTrivia.merging(triviaOf: rightParen))
139+
if let additionalTriviaAtEndOfCall {
140+
converted.trailingTrivia = converted.trailingTrivia.merging(additionalTriviaAtEndOfCall.droppingLeadingWhitespace)
145141
}
146142

147143
return converted
148144
}
149145
}
146+
147+
fileprivate extension Trivia {
148+
var droppingLeadingWhitespace: Trivia {
149+
return Trivia(pieces: self.drop(while: \.isWhitespace))
150+
}
151+
}
152+
153+
fileprivate extension Sequence {
154+
func dropSuffix(while predicate: (Element) -> Bool) -> [Element] {
155+
self.reversed().drop(while: predicate).reversed()
156+
}
157+
}

Sources/SwiftRefactor/ExpandEditorPlaceholder.swift

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,17 @@ import SwiftSyntaxBuilder
7070
/// anything here
7171
/// ```
7272
struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
73-
static func textRefactor(syntax token: TokenSyntax, in context: Void) -> [SourceEdit] {
73+
struct Context {
74+
let indentationWidth: Trivia?
75+
let initialIndentation: Trivia
76+
77+
init(indentationWidth: Trivia? = nil, initialIndentation: Trivia = []) {
78+
self.indentationWidth = indentationWidth
79+
self.initialIndentation = initialIndentation
80+
}
81+
}
82+
83+
static func textRefactor(syntax token: TokenSyntax, in context: Context = Context()) -> [SourceEdit] {
7484
guard let placeholder = EditorPlaceholderData(token: token) else {
7585
return []
7686
}
@@ -81,7 +91,18 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
8191
expanded = String(text)
8292
case let .typed(text, type):
8393
if let functionType = type.as(FunctionTypeSyntax.self) {
84-
expanded = functionType.closureExpansion.formatted().description
94+
let basicFormat = BasicFormat(
95+
indentationWidth: context.indentationWidth,
96+
initialIndentation: context.initialIndentation
97+
)
98+
var formattedExpansion = functionType.closureExpansion.formatted(using: basicFormat).description
99+
// Strip the initial indentation from the placeholder itself. We only introduced the initial indentation to
100+
// format consecutive lines. We don't want it at the front of the initial line because it replaces an expression
101+
// that might be in the middle of a line.
102+
if formattedExpansion.hasPrefix(context.initialIndentation.description) {
103+
formattedExpansion = String(formattedExpansion.dropFirst(context.initialIndentation.description.count))
104+
}
105+
expanded = formattedExpansion
85106
} else {
86107
expanded = String(text)
87108
}
@@ -122,13 +143,25 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
122143
///
123144
/// Expansion on `closure1` and `normalArg` is the same as `ExpandSingleEditorPlaceholder`.
124145
public struct ExpandEditorPlaceholder: EditRefactoringProvider {
125-
public static func textRefactor(syntax token: TokenSyntax, in context: Void) -> [SourceEdit] {
146+
public struct Context {
147+
public let indentationWidth: Trivia?
148+
149+
public init(indentationWidth: Trivia? = nil) {
150+
self.indentationWidth = indentationWidth
151+
}
152+
}
153+
154+
public static func textRefactor(syntax token: TokenSyntax, in context: Context = Context()) -> [SourceEdit] {
126155
guard let placeholder = token.parent?.as(DeclReferenceExprSyntax.self),
127156
placeholder.baseName.isEditorPlaceholder,
128157
let arg = placeholder.parent?.as(LabeledExprSyntax.self),
129158
let argList = arg.parent?.as(LabeledExprListSyntax.self),
130159
let call = argList.parent?.as(FunctionCallExprSyntax.self),
131-
let expandedTrailingClosures = ExpandEditorPlaceholdersToTrailingClosures.expandTrailingClosurePlaceholders(in: call, ifIncluded: arg)
160+
let expandedTrailingClosures = ExpandEditorPlaceholdersToTrailingClosures.expandTrailingClosurePlaceholders(
161+
in: call,
162+
ifIncluded: arg,
163+
indentationWidth: context.indentationWidth
164+
)
132165
else {
133166
return ExpandSingleEditorPlaceholder.textRefactor(syntax: token)
134167
}
@@ -159,8 +192,16 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider {
159192
/// }
160193
/// ```
161194
public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvider {
162-
public static func refactor(syntax call: FunctionCallExprSyntax, in context: Void = ()) -> FunctionCallExprSyntax? {
163-
return Self.expandTrailingClosurePlaceholders(in: call, ifIncluded: nil)
195+
public struct Context {
196+
public let indentationWidth: Trivia?
197+
198+
public init(indentationWidth: Trivia? = nil) {
199+
self.indentationWidth = indentationWidth
200+
}
201+
}
202+
203+
public static func refactor(syntax call: FunctionCallExprSyntax, in context: Context = Context()) -> FunctionCallExprSyntax? {
204+
return Self.expandTrailingClosurePlaceholders(in: call, ifIncluded: nil, indentationWidth: context.indentationWidth)
164205
}
165206

166207
/// If the given argument is `nil` or one of the last arguments that are all
@@ -170,9 +211,10 @@ public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvi
170211
/// Otherwise return nil.
171212
fileprivate static func expandTrailingClosurePlaceholders(
172213
in call: FunctionCallExprSyntax,
173-
ifIncluded arg: LabeledExprSyntax?
214+
ifIncluded arg: LabeledExprSyntax?,
215+
indentationWidth: Trivia?
174216
) -> FunctionCallExprSyntax? {
175-
guard let expanded = call.expandTrailingClosurePlaceholders(ifIncluded: arg) else {
217+
guard let expanded = call.expandTrailingClosurePlaceholders(ifIncluded: arg, indentationWidth: indentationWidth) else {
176218
return nil
177219
}
178220

@@ -260,7 +302,8 @@ extension FunctionCallExprSyntax {
260302
/// closures based on the function types provided by each editor placeholder.
261303
/// Otherwise return nil.
262304
fileprivate func expandTrailingClosurePlaceholders(
263-
ifIncluded: LabeledExprSyntax?
305+
ifIncluded: LabeledExprSyntax?,
306+
indentationWidth: Trivia?
264307
) -> (expr: FunctionCallExprSyntax, numClosures: Int)? {
265308
var includedArg = false
266309
var argsToExpand = 0
@@ -283,9 +326,14 @@ extension FunctionCallExprSyntax {
283326
return nil
284327
}
285328

329+
let lineIndentation = self.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? []
330+
286331
var expandedArgs = [LabeledExprSyntax]()
287332
for arg in arguments.suffix(argsToExpand) {
288-
let edits = ExpandSingleEditorPlaceholder.textRefactor(syntax: arg.expression.cast(DeclReferenceExprSyntax.self).baseName)
333+
let edits = ExpandSingleEditorPlaceholder.textRefactor(
334+
syntax: arg.expression.cast(DeclReferenceExprSyntax.self).baseName,
335+
in: ExpandSingleEditorPlaceholder.Context(indentationWidth: indentationWidth, initialIndentation: lineIndentation)
336+
)
289337
guard edits.count == 1, let edit = edits.first, !edit.replacement.isEmpty else {
290338
return nil
291339
}

Tests/SwiftBasicFormatTest/BasicFormatTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,4 +610,39 @@ final class BasicFormatTest: XCTestCase {
610610
)
611611
}
612612

613+
func testFormatClosureWithInitialIndentation() throws {
614+
assertFormatted(
615+
tree: ClosureExprSyntax(
616+
statements: CodeBlockItemListSyntax([
617+
CodeBlockItemSyntax(item: CodeBlockItemSyntax.Item(IntegerLiteralExprSyntax(integerLiteral: 2)))
618+
])
619+
),
620+
expected: """
621+
{
622+
2
623+
}
624+
""",
625+
using: BasicFormat(initialIndentation: .spaces(4))
626+
)
627+
}
628+
629+
func testIndentedStandaloneClosureRoundTrips() throws {
630+
let source = """
631+
foo {
632+
"abc"
633+
}
634+
"""
635+
assertFormatted(source: source, expected: source)
636+
}
637+
638+
func testIndentedStandaloneClosureRoundTrips2() throws {
639+
let source = """
640+
foo {
641+
if true {
642+
print("test")
643+
}
644+
}
645+
"""
646+
assertFormatted(source: source, expected: source)
647+
}
613648
}

0 commit comments

Comments
 (0)