diff --git a/Release Notes/602.md b/Release Notes/602.md new file mode 100644 index 00000000000..b114de7e317 --- /dev/null +++ b/Release Notes/602.md @@ -0,0 +1,26 @@ +# Swift Syntax 602 Release Notes + +## New APIs + +## API Behavior Changes + +## Deprecations + +## API-Incompatible Changes + +- `ExpandEditorPlaceholdersToTrailingClosures` has changed to `ExpandEditorPlaceholdersToLiteralClosures` + - Description: Whether function-typed placeholders are expanded to trailing closures is now configurable using a `format` argument to this rewriter. Additionally clients that support nested placeholders may request that the entire expanded closure be wrapped in an outer placeholder, e.g. `<#{ <#foo#> in <#Bar#> }#>`. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/2897 + - Migration steps: Replace uses of `ExpandEditorPlaceholdersToTrailingClosures` with `ExpandEditorPlaceholdersToLiteralClosures`. The initializer does not need to change: `.init(indentationWidth:)` on the new type provides the same behavior as the old type. + - Notes: This improves code completion in a SourceKitLSP session where the trailing closure form may be undesirable. The nested placeholders offer more flexibility to end users, in editors that support it. + +## Template + +- *Affected API or two word description* + - Description: *A 1-2 sentence description of the new/modified API* + - Issue: *If an issue exists for this change, a link to the issue* + - Pull Request: *Link to the pull request(s) that introduces this change* + - Migration steps: Steps that adopters of swift-syntax should take to move to the new API (required for deprecations and API-incompatible changes). + - Notes: *In case of deprecations or API-incompatible changes, the reason why this change was made and the suggested alternative* + +*Insert entries in chronological order, with newer entries at the bottom* diff --git a/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift b/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift index 87c250a7101..dfbdfd6733d 100644 --- a/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift +++ b/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift @@ -40,7 +40,9 @@ import SwiftSyntaxBuilder /// `type-for-expansion-string`), is parsed into a syntax node. If that node is /// a `FunctionTypeSyntax` then the placeholder is expanded into a /// `ClosureExprSyntax`. Otherwise it is expanded as is, which is also the case -/// for when only a display string is provided. +/// for when only a display string is provided. You may customize the formatting +/// of a closure expansion via ``Context/closureLiteralFormat``, for example to +/// change whether it is split onto multiple lines. /// /// ## Function Typed Placeholder /// ### Before @@ -78,12 +80,29 @@ import SwiftSyntaxBuilder /// ``` struct ExpandSingleEditorPlaceholder: EditRefactoringProvider { struct Context { - let indentationWidth: Trivia? - let initialIndentation: Trivia - - init(indentationWidth: Trivia? = nil, initialIndentation: Trivia = []) { - self.indentationWidth = indentationWidth - self.initialIndentation = initialIndentation + /// The formatter to use when expanding a function-typed placeholder. + let closureLiteralFormat: BasicFormat + /// When true, the expansion will wrap a function-typed placeholder's entire + /// expansion in placeholder delimiters, in addition to any placeholders + /// inside the expanded closure literal. + /// + /// With `allowNestedPlaceholders = false` + /// ```swift + /// { someInt in <#String#> } + /// ``` + /// + /// With `allowNestedPlaceholders = true` + /// ```swift + /// <#{ someInt in <#String#> }#> + /// ``` + let allowNestedPlaceholders: Bool + + init( + closureLiteralFormat: BasicFormat = BasicFormat(), + allowNestedPlaceholders: Bool = false + ) { + self.closureLiteralFormat = closureLiteralFormat + self.allowNestedPlaceholders = allowNestedPlaceholders } } @@ -94,16 +113,17 @@ struct ExpandSingleEditorPlaceholder: EditRefactoringProvider { let expanded: String if let functionType = placeholder.typeForExpansion?.as(FunctionTypeSyntax.self) { - let basicFormat = BasicFormat( - indentationWidth: context.indentationWidth, - initialIndentation: context.initialIndentation - ) - var formattedExpansion = functionType.closureExpansion.formatted(using: basicFormat).description + let format = context.closureLiteralFormat + let initialIndentation = format.currentIndentationLevel + var formattedExpansion = functionType.closureExpansion.formatted(using: format).description // Strip the initial indentation from the placeholder itself. We only introduced the initial indentation to // format consecutive lines. We don't want it at the front of the initial line because it replaces an expression // that might be in the middle of a line. - if formattedExpansion.hasPrefix(context.initialIndentation.description) { - formattedExpansion = String(formattedExpansion.dropFirst(context.initialIndentation.description.count)) + if formattedExpansion.hasPrefix(initialIndentation.description) { + formattedExpansion = String(formattedExpansion.dropFirst(initialIndentation.description.count)) + } + if context.allowNestedPlaceholders { + formattedExpansion = wrapInPlaceholder(formattedExpansion) } expanded = formattedExpansion } else { @@ -161,20 +181,24 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider { let arg = placeholder.parent?.as(LabeledExprSyntax.self), let argList = arg.parent?.as(LabeledExprListSyntax.self), let call = argList.parent?.as(FunctionCallExprSyntax.self), - let expandedTrailingClosures = ExpandEditorPlaceholdersToTrailingClosures.expandTrailingClosurePlaceholders( + let expandedClosures = ExpandEditorPlaceholdersToLiteralClosures.expandClosurePlaceholders( in: call, ifIncluded: arg, - indentationWidth: context.indentationWidth + context: ExpandEditorPlaceholdersToLiteralClosures.Context( + format: .trailing(indentationWidth: context.indentationWidth) + ) ) else { return ExpandSingleEditorPlaceholder.textRefactor(syntax: token) } - return [SourceEdit.replace(call, with: expandedTrailingClosures.description)] + return [SourceEdit.replace(call, with: expandedClosures.description)] } } -/// Expand all the editor placeholders in the function call that can be converted to trailing closures. +/// Expand all the editor placeholders in the function call to literal closures. +/// By default they will be expanded to trailing form; if you provide your own +/// formatter via ``Context/format`` they will be expanded inline. /// /// ## Before /// ```swift @@ -185,7 +209,7 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider { /// ) /// ``` /// -/// ## Expansion of `foo` +/// ## Expansion of `foo`, default behavior /// ```swift /// foo( /// arg: <#T##Int#>, @@ -195,12 +219,50 @@ public struct ExpandEditorPlaceholder: EditRefactoringProvider { /// <#T##String#> /// } /// ``` -public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvider { +/// +/// ## Expansion of `foo` with a basic custom formatter +/// ```swift +/// foo( +/// arg: <#T##Int#>, +/// firstClosure: { someInt in +/// <#T##String#> +/// }, +/// secondClosure: { someInt in +/// <#T##String#> +/// } +/// ) +/// ``` +/// +/// ## Expansion of `foo`, custom formatter with `allowNestedPlaceholders: true` +/// ```swift +/// foo( +/// arg: <#T##Int#>, +/// firstClosure: <#{ someInt in +/// <#T##String#> +/// }#>, +/// secondClosure: <#{ someInt in +/// <#T##String#> +/// }#> +/// ) +/// ``` +public struct ExpandEditorPlaceholdersToLiteralClosures: SyntaxRefactoringProvider { public struct Context { - public let indentationWidth: Trivia? + public enum Format { + /// Default formatting behavior: expand to trailing closures. + case trailing(indentationWidth: Trivia?) + /// Use the given formatter and expand the placeholder inline, without + /// moving it to trailing position. If `allowNestedPlaceholders` is true, + /// the entire closure will also be wrapped as a placeholder. + case custom(BasicFormat, allowNestedPlaceholders: Bool) + } + public let format: Format + + public init(format: Format) { + self.format = format + } public init(indentationWidth: Trivia? = nil) { - self.indentationWidth = indentationWidth + self.init(format: .trailing(indentationWidth: indentationWidth)) } } @@ -208,7 +270,11 @@ public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvi syntax call: FunctionCallExprSyntax, in context: Context = Context() ) -> FunctionCallExprSyntax? { - return Self.expandTrailingClosurePlaceholders(in: call, ifIncluded: nil, indentationWidth: context.indentationWidth) + return Self.expandClosurePlaceholders( + in: call, + ifIncluded: nil, + context: context + ) } /// If the given argument is `nil` or one of the last arguments that are all @@ -216,24 +282,35 @@ public struct ExpandEditorPlaceholdersToTrailingClosures: SyntaxRefactoringProvi /// closure, then return a replacement of this call with one that uses /// closures based on the function types provided by each editor placeholder. /// Otherwise return nil. - fileprivate static func expandTrailingClosurePlaceholders( + fileprivate static func expandClosurePlaceholders( in call: FunctionCallExprSyntax, ifIncluded arg: LabeledExprSyntax?, - indentationWidth: Trivia? + context: Context ) -> FunctionCallExprSyntax? { - guard let expanded = call.expandTrailingClosurePlaceholders(ifIncluded: arg, indentationWidth: indentationWidth) - else { - return nil - } + switch context.format { + case let .custom(formatter, allowNestedPlaceholders: allowNesting): + let expanded = call.expandClosurePlaceholders( + ifIncluded: arg, + customFormat: formatter, + allowNestedPlaceholders: allowNesting + ) + return expanded?.expr - let callToTrailingContext = CallToTrailingClosures.Context( - startAtArgument: call.arguments.count - expanded.numClosures - ) - guard let trailing = CallToTrailingClosures.refactor(syntax: expanded.expr, in: callToTrailingContext) else { - return nil - } + case let .trailing(indentationWidth): + guard let expanded = call.expandClosurePlaceholders(ifIncluded: arg, indentationWidth: indentationWidth) + else { + return nil + } - return trailing + let callToTrailingContext = CallToTrailingClosures.Context( + startAtArgument: call.arguments.count - expanded.numClosures + ) + guard let trailing = CallToTrailingClosures.refactor(syntax: expanded.expr, in: callToTrailingContext) else { + return nil + } + + return trailing + } } } @@ -311,9 +388,11 @@ extension FunctionCallExprSyntax { /// closure, then return a replacement of this call with one that uses /// closures based on the function types provided by each editor placeholder. /// Otherwise return nil. - fileprivate func expandTrailingClosurePlaceholders( + fileprivate func expandClosurePlaceholders( ifIncluded: LabeledExprSyntax?, - indentationWidth: Trivia? + indentationWidth: Trivia? = nil, + customFormat: BasicFormat? = nil, + allowNestedPlaceholders: Bool = false ) -> (expr: FunctionCallExprSyntax, numClosures: Int)? { var includedArg = false var argsToExpand = 0 @@ -343,8 +422,12 @@ extension FunctionCallExprSyntax { let edits = ExpandSingleEditorPlaceholder.textRefactor( syntax: arg.expression.cast(DeclReferenceExprSyntax.self).baseName, in: ExpandSingleEditorPlaceholder.Context( - indentationWidth: indentationWidth, - initialIndentation: lineIndentation + closureLiteralFormat: customFormat + ?? BasicFormat( + indentationWidth: indentationWidth, + initialIndentation: lineIndentation + ), + allowNestedPlaceholders: allowNestedPlaceholders ) ) guard edits.count == 1, let edit = edits.first, !edit.replacement.isEmpty else { diff --git a/Sources/SwiftSyntax/SyntaxProtocol.swift b/Sources/SwiftSyntax/SyntaxProtocol.swift index d2edbddc478..1eba89b48f5 100644 --- a/Sources/SwiftSyntax/SyntaxProtocol.swift +++ b/Sources/SwiftSyntax/SyntaxProtocol.swift @@ -235,7 +235,10 @@ extension SyntaxProtocol { return self.previousToken(viewMode: .sourceAccurate) } - /// Returns this node or the first ancestor that satisfies `condition`. + /// Applies `map` to this node and each of its ancestors until a non-`nil` + /// value is produced, then returns that value. + /// + /// If no node has a non-`nil` mapping, returns `nil`. public func ancestorOrSelf(mapping map: (Syntax) -> T?) -> T? { return self.withUnownedSyntax { var node = $0 diff --git a/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift b/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift index 3818c772aaa..859963a737c 100644 --- a/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift +++ b/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift @@ -22,6 +22,9 @@ fileprivate let closurePlaceholder = wrapInPlaceholder("T##closure##() -> Void") fileprivate let closureWithArgPlaceholder = wrapInPlaceholder( "T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String" ) +fileprivate let closureCombinedTypeDisplayPlaceholder = wrapInPlaceholder( + "T##(Int) -> String" +) fileprivate let voidPlaceholder = wrapInPlaceholder("T##code##Void") fileprivate let intPlaceholder = wrapInPlaceholder("T##Int##Int") fileprivate let stringPlaceholder = wrapInPlaceholder("T##String##String") @@ -190,7 +193,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testExpandEditorPlaceholdersToSingleTrailingClosures() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(arg: \(intPlaceholder), closure: \(closureWithArgPlaceholder)) """, @@ -203,7 +206,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testExpandEditorPlaceholdersToMultipleTrailingClosures() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(arg: \(intPlaceholder), firstClosure: \(closureWithArgPlaceholder), secondClosure: \(closureWithArgPlaceholder)) """, @@ -218,7 +221,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testExpandEditorPlaceholdersDoesntExpandClosureBeforeNormalArgs() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(pre: \(closurePlaceholder), arg: \(intPlaceholder), closure: \(closureWithArgPlaceholder)) """, @@ -231,7 +234,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testCallHasInitialIndentation() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(arg: 1, closure: \(closureWithArgPlaceholder)) """, @@ -244,7 +247,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testCustomIndentationWidth() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(arg: 1, closure: \(closureWithArgPlaceholder)) """, @@ -253,12 +256,12 @@ final class ExpandEditorPlaceholderTests: XCTestCase { \(stringPlaceholder) } """, - indentationWidth: .spaces(2) + format: .trailing(indentationWidth: .spaces(2)) ) } func testCustomIndentationWidthWithInitialIndentation() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo(arg: 1, closure: \(closureWithArgPlaceholder)) """, @@ -267,12 +270,12 @@ final class ExpandEditorPlaceholderTests: XCTestCase { \(stringPlaceholder) } """, - indentationWidth: .spaces(2) + format: .trailing(indentationWidth: .spaces(2)) ) } func testMultilineCall() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo( arg: 1, @@ -290,7 +293,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testMultilineIndentedCall() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo( arg: 1, @@ -308,7 +311,7 @@ final class ExpandEditorPlaceholderTests: XCTestCase { } func testMultilineCallWithNoAdditionalArguments() throws { - try assertExpandEditorPlaceholdersToTrailingClosures( + try assertExpandEditorPlaceholdersToClosures( """ foo( closure: \(closureWithArgPlaceholder) @@ -321,6 +324,133 @@ final class ExpandEditorPlaceholderTests: XCTestCase { """ ) } + + // MARK:- Custom closure format + + func testSingleClosuresWithCustomFormat() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo(arg: \(intPlaceholder), closure: \(closureWithArgPlaceholder)) + """, + expected: """ + foo(arg: \(intPlaceholder), closure: <#{ someInt in \(stringPlaceholder) }#>) + """, + format: .testCustom() + ) + } + + func testWithCustomFormatClosurePlaceholderCombinedTypeAndDisplayString() async throws { + let intPlaceholder = wrapInPlaceholder("Int") + try assertExpandEditorPlaceholdersToClosures( + """ + foo(closure: \(closureCombinedTypeDisplayPlaceholder)) + """, + expected: """ + foo(closure: <#{ \(intPlaceholder) in \(stringPlaceholder) }#>) + """, + format: .testCustom() + ) + } + + func testWithCustomFormatToMultipleClosuresWithCustomFormat() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo(arg: \(intPlaceholder), firstClosure: \(closureWithArgPlaceholder), secondClosure: \(closureWithArgPlaceholder)) + """, + expected: """ + foo(arg: \(intPlaceholder), firstClosure: <#{ someInt in \(stringPlaceholder) }#>, secondClosure: <#{ someInt in \(stringPlaceholder) }#>) + """, + format: .testCustom() + ) + } + + func testWithCustomFormatDoesntExpandClosureBeforeNormalArgs() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo(pre: \(closurePlaceholder), arg: \(intPlaceholder), closure: \(closureWithArgPlaceholder)) + """, + expected: """ + foo(pre: \(closurePlaceholder), arg: \(intPlaceholder), closure: <#{ someInt in \(stringPlaceholder) }#>) + """, + format: .testCustom() + ) + } + + func testWithCustomFormatCallHasInitialIndentation() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo(arg: 1, closure: \(closureWithArgPlaceholder)) + """, + expected: """ + foo(arg: 1, closure: <#{ someInt in \(stringPlaceholder) }#>) + """, + format: .testCustom() + ) + } + + func testCustomFormatAndCustomIndentationWidthWithInitialIndentation() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo(arg: 1, closure: \(closureWithArgPlaceholder)) + """, + expected: """ + foo(arg: 1, closure: <#{ someInt in \(stringPlaceholder) }#>) + """, + format: .testCustom(indentationWidth: .spaces(2)) + ) + } + + func testCustomFormatMultilineCall() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo( + arg: 1, + closure: \(closureWithArgPlaceholder) + ) + """, + expected: """ + foo( + arg: 1, + closure: <#{ someInt in \(stringPlaceholder) }#> + ) + """, + format: .testCustom() + ) + } + + func testCustomFormatMultilineIndentedCall() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo( + arg: 1, + closure: \(closureWithArgPlaceholder) + ) + """, + expected: """ + foo( + arg: 1, + closure: <#{ someInt in \(stringPlaceholder) }#> + ) + """, + format: .testCustom() + ) + } + + func testCustomFormatMultilineCallWithNoAdditionalArguments() throws { + try assertExpandEditorPlaceholdersToClosures( + """ + foo( + closure: \(closureWithArgPlaceholder) + ) + """, + expected: """ + foo( + closure: <#{ someInt in \(stringPlaceholder) }#> + ) + """, + format: .testCustom() + ) + } } fileprivate func assertRefactorPlaceholder( @@ -398,10 +528,10 @@ fileprivate func assertRefactorPlaceholderToken( ) } -fileprivate func assertExpandEditorPlaceholdersToTrailingClosures( +fileprivate func assertExpandEditorPlaceholdersToClosures( _ expr: String, expected: String, - indentationWidth: Trivia? = nil, + format: ExpandEditorPlaceholdersToLiteralClosures.Context.Format = .trailing(indentationWidth: nil), file: StaticString = #filePath, line: UInt = #line ) throws { @@ -410,10 +540,22 @@ fileprivate func assertExpandEditorPlaceholdersToTrailingClosures( try assertRefactor( call, - context: ExpandEditorPlaceholdersToTrailingClosures.Context(indentationWidth: indentationWidth), - provider: ExpandEditorPlaceholdersToTrailingClosures.self, + context: ExpandEditorPlaceholdersToLiteralClosures.Context(format: format), + provider: ExpandEditorPlaceholdersToLiteralClosures.self, expected: [SourceEdit.replace(call, with: expected)], file: file, line: line ) } + +fileprivate extension ExpandEditorPlaceholdersToLiteralClosures.Context.Format { + static func testCustom(indentationWidth: Trivia? = nil) -> Self { + .custom(CustomClosureFormat(indentationWidth: indentationWidth), allowNestedPlaceholders: true) + } +} + +fileprivate class CustomClosureFormat: BasicFormat { + override func requiresNewline(between _: TokenSyntax?, and _: TokenSyntax?) -> Bool { + return false + } +}