Skip to content

Commit d079e11

Browse files
committed
Refine expansion of function-typed placeholders
This addresses <swiftlang/sourcekit-lsp#1788>. Per <swiftlang/sourcekit-lsp#1789>, these placeholders no longer expand to multi-line trailing closures. Instead they become inline closures, with nested placeholders for the signature and body. This introduces a new formatter, `ClosureLiteralFormat`, so that other uses of `BasicFormat` are not impacted. `ClosureLiteralFormat` does not insert newlines into a closure when there is only a single statement in the body.
1 parent 1cd3534 commit d079e11

File tree

5 files changed

+274
-138
lines changed

5 files changed

+274
-138
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if swift(>=6)
14+
@_spi(RawSyntax) public import SwiftSyntax
15+
#else
16+
@_spi(RawSyntax) import SwiftSyntax
17+
#endif
18+
19+
/// A specialization of `BasicFormat` for closure literals, which is more
20+
/// conservative about newline insertion.
21+
///
22+
/// A closure that seems to be a simple predicate or transform — based on the
23+
/// number of enclosed statements — will not be reformatted to multiple lines.
24+
open class ClosureLiteralFormat: BasicFormat {
25+
open override func requiresNewline(between first: TokenSyntax?, and second: TokenSyntax?) -> Bool {
26+
if let first, isEndOfSmallClosureSignature(first) {
27+
return false
28+
} else if let first, isSmallClosureDelimiter(first, kind: \.leftBrace) {
29+
return false
30+
} else if let second, isSmallClosureDelimiter(second, kind: \.rightBrace) {
31+
return false
32+
} else {
33+
return super.requiresNewline(between: first, and: second)
34+
}
35+
}
36+
37+
/// Returns `true` if `token` is an opening or closing brace (according to
38+
/// `kind`) of a closure, and that closure has no more than one statement in
39+
/// its body.
40+
private func isSmallClosureDelimiter(
41+
_ token: TokenSyntax,
42+
kind: KeyPath<ClosureExprSyntax, TokenSyntax>
43+
) -> Bool {
44+
guard token.keyPathInParent == kind,
45+
let closure = token.parent?.as(ClosureExprSyntax.self)
46+
else {
47+
return false
48+
}
49+
50+
return closure.statements.count <= 1
51+
}
52+
53+
/// Returns `true` if `token` is the last token in the signature of a closure,
54+
/// and that closure has no more than one statement in its body.
55+
private func isEndOfSmallClosureSignature(_ token: TokenSyntax) -> Bool {
56+
guard let signature = token.ancestorOrSelf(mapping: { $0.as(ClosureSignatureSyntax.self) }),
57+
let closure = signature.parent?.as(ClosureExprSyntax.self)
58+
else {
59+
return false
60+
}
61+
62+
return signature.lastToken(viewMode: viewMode) == token
63+
&& closure.statements.count <= 1
64+
}
65+
}

Sources/SwiftRefactor/ExpandEditorPlaceholder.swift

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ import SwiftSyntaxBuilder
5050
///
5151
/// ### After
5252
/// ```swift
53-
/// { someInt in
54-
/// <#T##String#>
55-
/// }
53+
/// <#{ <#someInt#> in <#T##String#> }#>
5654
/// ```
5755
///
5856
/// ## Other Type Placeholder
@@ -94,18 +92,18 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
9492

9593
let expanded: String
9694
if let functionType = placeholder.typeForExpansion?.as(FunctionTypeSyntax.self) {
97-
let basicFormat = BasicFormat(
95+
let format = ClosureLiteralFormat(
9896
indentationWidth: context.indentationWidth,
9997
initialIndentation: context.initialIndentation
10098
)
101-
var formattedExpansion = functionType.closureExpansion.formatted(using: basicFormat).description
99+
var formattedExpansion = functionType.closureExpansion.formatted(using: format).description
102100
// Strip the initial indentation from the placeholder itself. We only introduced the initial indentation to
103101
// format consecutive lines. We don't want it at the front of the initial line because it replaces an expression
104102
// that might be in the middle of a line.
105103
if formattedExpansion.hasPrefix(context.initialIndentation.description) {
106104
formattedExpansion = String(formattedExpansion.dropFirst(context.initialIndentation.description.count))
107105
}
108-
expanded = formattedExpansion
106+
expanded = wrapInPlaceholder(formattedExpansion)
109107
} else {
110108
expanded = placeholder.displayText
111109
}
@@ -117,9 +115,9 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
117115
}
118116

119117
/// If a function-typed placeholder is the argument to a non-trailing closure
120-
/// call, expands it and any adjacent function-typed placeholders to trailing
121-
/// closures on that call. All other placeholders will expand as per
122-
/// `ExpandEditorPlaceholder`.
118+
/// call, expands it and any adjacent function-typed placeholders to literal
119+
/// closures with inner placeholders on that call. All other placeholders will
120+
/// expand as per `ExpandEditorPlaceholder`.
123121
///
124122
/// ## Before
125123
/// ```swift
@@ -137,12 +135,10 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider {
137135
/// foo(
138136
/// closure1: <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#>,
139137
/// normalArg: <#T##Int#>,
140-
/// closure2: { ... }
141-
/// ) { someInt in
142-
/// <#T##String#>
143-
/// } closure2: { someInt in
144-
/// <#T##String#>
145-
/// }
138+
/// closure2: { ... },
139+
/// closure3: { <#someInt#> in <#T##String#> },
140+
/// closure4: { <#someInt#> in <#T##String#> }
141+
/// )
146142
/// ```
147143
///
148144
/// Expansion on `closure1` and `normalArg` is the same as `ExpandSingleEditorPlaceholder`.
@@ -161,7 +157,7 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider {
161157
let arg = placeholder.parent?.as(LabeledExprSyntax.self),
162158
let argList = arg.parent?.as(LabeledExprListSyntax.self),
163159
let call = argList.parent?.as(FunctionCallExprSyntax.self),
164-
let expandedTrailingClosures = ExpandEditorPlaceholdersToTrailingClosures.expandTrailingClosurePlaceholders(
160+
let expandedClosures = ExpandEditorPlaceholdersToLiteralClosures.expandClosurePlaceholders(
165161
in: call,
166162
ifIncluded: arg,
167163
indentationWidth: context.indentationWidth
@@ -170,11 +166,11 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider {
170166
return ExpandSingleEditorPlaceholder.textRefactor(syntax: token)
171167
}
172168

173-
return [SourceEdit.replace(call, with: expandedTrailingClosures.description)]
169+
return [SourceEdit.replace(call, with: expandedClosures.description)]
174170
}
175171
}
176172

177-
/// Expand all the editor placeholders in the function call that can be converted to trailing closures.
173+
/// Expand all the editor placeholders in the function call to literal closures.
178174
///
179175
/// ## Before
180176
/// ```swift
@@ -189,13 +185,11 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider {
189185
/// ```swift
190186
/// foo(
191187
/// arg: <#T##Int#>,
192-
/// ) { someInt in
193-
/// <#T##String#>
194-
/// } secondClosure: { someInt in
195-
/// <#T##String#>
196-
/// }
188+
/// firstClosure: { <#someInt#> in <#T##String#> },
189+
/// secondClosure: { <#someInt#> in <#T##String#> }
190+
/// )
197191
/// ```
198-
public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvider {
192+
public struct ExpandEditorPlaceholdersToLiteralClosures: SyntaxRefactoringProvider {
199193
public struct Context {
200194
public let indentationWidth: Trivia?
201195

@@ -208,32 +202,24 @@ public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvi
208202
syntax call: FunctionCallExprSyntax,
209203
in context: Context = Context()
210204
) -> FunctionCallExprSyntax? {
211-
return Self.expandTrailingClosurePlaceholders(in: call, ifIncluded: nil, indentationWidth: context.indentationWidth)
205+
return Self.expandClosurePlaceholders(
206+
in: call,
207+
ifIncluded: nil,
208+
indentationWidth: context.indentationWidth
209+
)
212210
}
213211

214212
/// If the given argument is `nil` or one of the last arguments that are all
215213
/// function-typed placeholders and this call doesn't have a trailing
216214
/// closure, then return a replacement of this call with one that uses
217215
/// closures based on the function types provided by each editor placeholder.
218216
/// Otherwise return nil.
219-
fileprivate static func expandTrailingClosurePlaceholders(
217+
fileprivate static func expandClosurePlaceholders(
220218
in call: FunctionCallExprSyntax,
221219
ifIncluded arg: LabeledExprSyntax?,
222220
indentationWidth: Trivia?
223221
) -> FunctionCallExprSyntax? {
224-
guard let expanded = call.expandTrailingClosurePlaceholders(ifIncluded: arg, indentationWidth: indentationWidth)
225-
else {
226-
return nil
227-
}
228-
229-
let callToTrailingContext = CallToTrailingClosures.Context(
230-
startAtArgument: call.arguments.count - expanded.numClosures
231-
)
232-
guard let trailing = CallToTrailingClosures.refactor(syntax: expanded.expr, in: callToTrailingContext) else {
233-
return nil
234-
}
235-
236-
return trailing
222+
return call.expandClosurePlaceholders(ifIncluded: arg, indentationWidth: indentationWidth)
237223
}
238224
}
239225

@@ -244,9 +230,7 @@ extension FunctionTypeSyntax {
244230
/// ```
245231
/// would become
246232
/// ```
247-
/// { someInt in
248-
/// <#T##String#>
249-
/// }
233+
/// { <#someInt#> in <#T##String#> }
250234
/// ```
251235
fileprivate var closureExpansion: ClosureExprSyntax {
252236
let closureSignature: ClosureSignatureSyntax?
@@ -311,10 +295,10 @@ extension FunctionCallExprSyntax {
311295
/// closure, then return a replacement of this call with one that uses
312296
/// closures based on the function types provided by each editor placeholder.
313297
/// Otherwise return nil.
314-
fileprivate func expandTrailingClosurePlaceholders(
298+
fileprivate func expandClosurePlaceholders(
315299
ifIncluded: LabeledExprSyntax?,
316300
indentationWidth: Trivia?
317-
) -> (expr: FunctionCallExprSyntax, numClosures: Int)? {
301+
) -> FunctionCallExprSyntax? {
318302
var includedArg = false
319303
var argsToExpand = 0
320304
for arg in arguments.reversed() {
@@ -359,10 +343,7 @@ extension FunctionCallExprSyntax {
359343
}
360344

361345
let originalArgs = arguments.dropLast(argsToExpand)
362-
return (
363-
detached.with(\.arguments, LabeledExprListSyntax(originalArgs + expandedArgs)),
364-
expandedArgs.count
365-
)
346+
return detached.with(\.arguments, LabeledExprListSyntax(originalArgs + expandedArgs))
366347
}
367348
}
368349

Sources/SwiftSyntax/SyntaxProtocol.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,10 @@ extension SyntaxProtocol {
235235
return self.previousToken(viewMode: .sourceAccurate)
236236
}
237237

238-
/// Returns this node or the first ancestor that satisfies `condition`.
238+
/// Applies `map` to this node and each of its ancestors until a non-`nil`
239+
/// value is produced, then returns that value.
240+
///
241+
/// If no node has a non-`nil` mapping, returns `nil`.
239242
public func ancestorOrSelf<T>(mapping map: (Syntax) -> T?) -> T? {
240243
return self.withUnownedSyntax {
241244
var node = $0
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftBasicFormat
14+
import SwiftParser
15+
import SwiftSyntax
16+
@_spi(Testing) import SwiftSyntaxBuilder
17+
import XCTest
18+
import _SwiftSyntaxTestSupport
19+
20+
fileprivate func assertFormatted<T: SyntaxProtocol>(
21+
tree: T,
22+
expected: String,
23+
using format: ClosureLiteralFormat = ClosureLiteralFormat(indentationWidth: .spaces(4)),
24+
file: StaticString = #filePath,
25+
line: UInt = #line
26+
) {
27+
assertStringsEqualWithDiff(tree.formatted(using: format).description, expected, file: file, line: line)
28+
}
29+
30+
fileprivate func assertFormatted(
31+
source: String,
32+
expected: String,
33+
using format: ClosureLiteralFormat = ClosureLiteralFormat(indentationWidth: .spaces(4)),
34+
file: StaticString = #filePath,
35+
line: UInt = #line
36+
) {
37+
assertFormatted(
38+
tree: Parser.parse(source: source),
39+
expected: expected,
40+
using: format,
41+
file: file,
42+
line: line
43+
)
44+
}
45+
46+
final class ClosureLiteralFormatTests: XCTestCase {
47+
func testSingleStatementClosureArg() {
48+
assertFormatted(
49+
source: """
50+
foo(bar: { baz in baz.quux })
51+
""",
52+
expected: """
53+
foo(bar: { baz in baz.quux })
54+
"""
55+
)
56+
}
57+
58+
func testSingleStatementClosureArgAlreadyMultiLine() {
59+
assertFormatted(
60+
source: """
61+
foo(
62+
bar: { baz in
63+
baz.quux
64+
}
65+
)
66+
""",
67+
expected: """
68+
foo(
69+
bar: { baz in
70+
baz.quux
71+
}
72+
)
73+
"""
74+
)
75+
}
76+
77+
func testMultiStatmentClosureArg() {
78+
assertFormatted(
79+
source: """
80+
foo(
81+
bar: { baz in print(baz); return baz.quux }
82+
)
83+
""",
84+
expected: """
85+
foo(
86+
bar: { baz in
87+
print(baz);
88+
return baz.quux
89+
}
90+
)
91+
"""
92+
)
93+
}
94+
95+
func testMultiStatementClosureArgAlreadyMultiLine() {
96+
assertFormatted(
97+
source: """
98+
foo(
99+
bar: { baz in
100+
print(baz)
101+
return baz.quux
102+
}
103+
)
104+
""",
105+
expected: """
106+
foo(
107+
bar: { baz in
108+
print(baz)
109+
return baz.quux
110+
}
111+
)
112+
"""
113+
)
114+
}
115+
116+
func testFormatClosureWithInitialIndentation() throws {
117+
assertFormatted(
118+
tree: ClosureExprSyntax(
119+
statements: CodeBlockItemListSyntax([
120+
CodeBlockItemSyntax(item: CodeBlockItemSyntax.Item(IntegerLiteralExprSyntax(integerLiteral: 2)))
121+
])
122+
),
123+
expected: """
124+
{ 2 }
125+
""",
126+
using: ClosureLiteralFormat(initialIndentation: .spaces(4))
127+
)
128+
}
129+
}

0 commit comments

Comments
 (0)