diff --git a/Package.swift b/Package.swift index 8848465d062..9666465f00a 100644 --- a/Package.swift +++ b/Package.swift @@ -247,12 +247,12 @@ let package = Package( .target( name: "SwiftRefactor", - dependencies: ["SwiftParser", "SwiftSyntax"] + dependencies: ["SwiftBasicFormat", "SwiftParser", "SwiftSyntax", "SwiftSyntaxBuilder"] ), .testTarget( name: "SwiftRefactorTest", - dependencies: ["_SwiftSyntaxTestSupport", "SwiftRefactor", "SwiftSyntaxBuilder"] + dependencies: ["_SwiftSyntaxTestSupport", "SwiftRefactor"] ), // MARK: - Executable targets diff --git a/Sources/SwiftBasicFormat/BasicFormat.swift b/Sources/SwiftBasicFormat/BasicFormat.swift index 40a5948c55f..ce35a128579 100644 --- a/Sources/SwiftBasicFormat/BasicFormat.swift +++ b/Sources/SwiftBasicFormat/BasicFormat.swift @@ -212,7 +212,7 @@ open class BasicFormat: SyntaxRewriter { (.keyword(.set), .leftParen), // var mYar: Int { set(value) {} } (.keyword(.subscript), .leftParen), // subscript(x: Int) (.keyword(.super), .period), // super.someProperty - (.leftBrace, _), + (.leftBrace, .rightBrace), // {} (.leftParen, _), (.leftSquareBracket, _), (.multilineStringQuote, .rawStringDelimiter), // closing raw string delimiter should never be separate by a space @@ -245,7 +245,6 @@ open class BasicFormat: SyntaxRewriter { (_, .exclamationMark), (_, .postfixOperator), (_, .postfixQuestionMark), - (_, .rightBrace), (_, .rightParen), (_, .rightSquareBracket), (_, .semicolon), diff --git a/Sources/SwiftRefactor/AddSeparatorsToIntegerLiteral.swift b/Sources/SwiftRefactor/AddSeparatorsToIntegerLiteral.swift index 77f38b6865d..a2f1cf39b26 100644 --- a/Sources/SwiftRefactor/AddSeparatorsToIntegerLiteral.swift +++ b/Sources/SwiftRefactor/AddSeparatorsToIntegerLiteral.swift @@ -32,7 +32,7 @@ import SwiftSyntax /// 0xF_FFFF_FFFF /// 0b1_010 /// ``` -public struct AddSeparatorsToIntegerLiteral: RefactoringProvider { +public struct AddSeparatorsToIntegerLiteral: SyntaxRefactoringProvider { public static func refactor(syntax lit: IntegerLiteralExprSyntax, in context: Void) -> IntegerLiteralExprSyntax? { if lit.digits.text.contains("_") { guard let strippedLiteral = RemoveSeparatorsFromIntegerLiteral.refactor(syntax: lit) else { diff --git a/Sources/SwiftRefactor/CallToTrailingClosures.swift b/Sources/SwiftRefactor/CallToTrailingClosures.swift new file mode 100644 index 00000000000..2d5cf49b51d --- /dev/null +++ b/Sources/SwiftRefactor/CallToTrailingClosures.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax + +/// Convert a call with inline closures to one that uses trailing closure +/// syntax. Returns `nil` if there's already trailing closures or there are no +/// closures within the call. Pass `startAtArgument` to specify the argument +/// index to start the conversion from, ie. to skip converting closures before +/// `startAtArgument`. +/// +/// ## Before +/// ``` +/// someCall(closure1: { arg in +/// return 1 +/// }, closure2: { arg in +/// return 2 +/// }) +/// ``` +/// +/// ## After +/// ``` +/// someCall { arg in +/// return 1 +/// } closure2: { arg in +/// return 2 +/// } +/// ``` +public struct CallToTrailingClosures: SyntaxRefactoringProvider { + public struct Context { + public let startAtArgument: Int + + public init(startAtArgument: Int = 0) { + self.startAtArgument = startAtArgument + } + } + + // TODO: Rather than returning nil, we should consider throwing errors with + // appropriate messages instead. + public static func refactor(syntax call: FunctionCallExprSyntax, in context: Context = Context()) -> FunctionCallExprSyntax? { + return call.convertToTrailingClosures(from: context.startAtArgument)?.formatted().as(FunctionCallExprSyntax.self) + } +} + +extension FunctionCallExprSyntax { + fileprivate func convertToTrailingClosures(from startAtArgument: Int) -> FunctionCallExprSyntax? { + guard trailingClosure == nil, additionalTrailingClosures == nil, leftParen != nil, rightParen != nil else { + // Already have trailing closures + return nil + } + + var closures = [(original: TupleExprElementSyntax, closure: ClosureExprSyntax)]() + for arg in argumentList.dropFirst(startAtArgument) { + guard var closure = arg.expression.as(ClosureExprSyntax.self) else { + closures.removeAll() + continue + } + + // Trailing comma won't exist any more, move its trivia to the end of + // the closure instead + if let comma = arg.trailingComma { + closure = closure.with(\.trailingTrivia, closure.trailingTrivia.merging(triviaOf: comma)) + } + closures.append((arg, closure)) + } + + guard !closures.isEmpty else { + return nil + } + + // First trailing closure won't have label/colon. Transfer their trivia. + var trailingClosure = closures.first!.closure + .with( + \.leadingTrivia, + Trivia() + .merging(triviaOf: closures.first!.original.label) + .merging(triviaOf: closures.first!.original.colon) + .merging(closures.first!.closure.leadingTrivia) + ) + let additionalTrailingClosures = closures.dropFirst().map { + MultipleTrailingClosureElementSyntax( + label: $0.original.label ?? .wildcardToken(), + colon: $0.original.colon ?? .colonToken(), + closure: $0.closure + ) + } + + var converted = self.detach() + + // Remove parens if there's no non-closure arguments left and remove the + // last comma otherwise. Makes sure to keep the trivia of any removed node. + var argList = Array(argumentList.dropLast(closures.count)) + if argList.isEmpty { + converted = + converted + .with(\.leftParen, nil) + .with(\.rightParen, nil) + + // No left paren any more, right paren is handled below since it makes + // sense to keep its trivia of the end of the call, regardless of whether + // it was removed or not. + if let leftParen = leftParen { + trailingClosure = trailingClosure.with( + \.leadingTrivia, + Trivia() + .merging(triviaOf: leftParen) + .merging(trailingClosure.leadingTrivia) + ) + } + } else { + let last = argList.last! + if let comma = last.trailingComma { + converted = + converted + .with(\.rightParen, TokenSyntax.rightParenToken(trailingTrivia: Trivia().merging(triviaOf: comma))) + } + argList[argList.count - 1] = + last + .with(\.trailingComma, nil) + } + + // Update arguments and trailing closures + converted = + converted + .with(\.argumentList, TupleExprElementListSyntax(argList)) + .with(\.trailingClosure, trailingClosure) + if !additionalTrailingClosures.isEmpty { + converted = converted.with(\.additionalTrailingClosures, MultipleTrailingClosureElementListSyntax(additionalTrailingClosures)) + } + + // The right paren either doesn't exist any more, or is before all the + // trailing closures. Moves its trivia to the end of the converted call. + if let rightParen = rightParen { + converted = converted.with(\.trailingTrivia, converted.trailingTrivia.merging(triviaOf: rightParen)) + } + + return converted + } +} diff --git a/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift b/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift new file mode 100644 index 00000000000..372e8edf30c --- /dev/null +++ b/Sources/SwiftRefactor/ExpandEditorPlaceholder.swift @@ -0,0 +1,359 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax +import SwiftSyntaxBuilder + +/// Expands an editor placeholder, taking into accounts its provided type +/// information (if any). +/// +/// Placeholders must start with '<#' and end with +/// '#>'. They can be one of the following formats: +/// ``` +/// 'T##' display-string '##' type-string ('##' type-for-expansion-string)? +/// 'T##' display-and-type-string +/// display-string +/// ``` +/// It is required that '##' is not a valid substring of display-string or +/// type-string. If this ends up not the case for some reason, we can consider +/// adding escaping for '##'. +/// +/// The type string provided in the placeholder (preferring +/// `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. +/// +/// ## Function Typed Placeholder +/// ### Before +/// ```swift +/// <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#> +/// ``` +/// +/// ### After +/// ```swift +/// { someInt in +/// <#T##String#> +/// } +/// ``` +/// +/// ## Other Type Placeholder +/// ### Before +/// ```swift +/// <#T##Int#> +/// ``` +/// +/// ### After +/// ```swift +/// Int +/// ``` +/// +/// ## No Type Placeholder +/// ### Before +/// ```swift +/// <#anything here#> +/// ``` +/// +/// ## After +/// ```swift +/// anything here +/// ``` +public struct ExpandEditorPlaceholder: EditRefactoringProvider { + public static func isPlaceholder(_ str: String) -> Bool { + return str.hasPrefix(placeholderStart) && str.hasSuffix(placeholderEnd) + } + + public static func wrapInPlaceholder(_ str: String) -> String { + return placeholderStart + str + placeholderEnd + } + + public static func wrapInTypePlaceholder(_ str: String, type: String) -> String { + return Self.wrapInPlaceholder("T##" + str + "##" + type) + } + + public static func textRefactor(syntax token: TokenSyntax, in context: Void) -> [SourceEdit] { + guard let placeholder = EditorPlaceholderData(token: token) else { + return [] + } + + let expanded: String + switch placeholder { + case let .basic(text): + expanded = String(text) + case let .typed(text, type): + if let functionType = type.as(FunctionTypeSyntax.self) { + expanded = functionType.closureExpansion.formatted().description + } else { + expanded = String(text) + } + } + + return [SourceEdit.replace(token, with: token.leadingTrivia.description + expanded + token.trailingTrivia.description)] + } +} + +/// If a function-typed placeholder is the argument to a non-trailing closure +/// call, expands it and any adjacent function-typed placeholders to trailing +/// closures on that call. All other placeholders will expand as per +/// `ExpandEditorPlaceholder`. +/// +/// ## Before +/// ```swift +/// foo( +/// closure1: <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#>, +/// normalArg: <#T##Int#>, +/// closure2: { ... }, +/// closure3: <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#>, +/// closure4: <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#> +/// ) +/// ``` +/// +/// ## `closure3` or `closure4` Expansion +/// ```swift +/// foo( +/// closure1: <#T##(Int) -> String##(Int) -> String##(_ someInt: Int) -> String#>, +/// normalArg: <#T##Int#>, +/// closure2: { ... } +/// ) { someInt in +/// <#T##String#> +/// } closure2: { someInt in +/// <#T##String#> +/// } +/// ``` +/// +/// Expansion on `closure1` and `normalArg` is the same as `ExpandEditorPlaceholder`. +public struct ExpandEditorPlaceholders: EditRefactoringProvider { + public static func textRefactor(syntax token: TokenSyntax, in context: Void) -> [SourceEdit] { + guard let placeholder = token.parent?.as(EditorPlaceholderExprSyntax.self), + let arg = placeholder.parent?.as(TupleExprElementSyntax.self), + let argList = arg.parent?.as(TupleExprElementListSyntax.self), + let call = argList.parent?.as(FunctionCallExprSyntax.self) + else { + return ExpandEditorPlaceholder.textRefactor(syntax: token) + } + + guard let expanded = call.expandTrailingClosurePlaceholders(ifIncluded: arg) else { + return ExpandEditorPlaceholder.textRefactor(syntax: token) + } + + let callToTrailingContext = CallToTrailingClosures.Context(startAtArgument: argList.count - expanded.numClosures) + guard let trailing = CallToTrailingClosures.refactor(syntax: expanded.expr, in: callToTrailingContext) else { + return ExpandEditorPlaceholder.textRefactor(syntax: token) + } + + return [SourceEdit.replace(call, with: trailing.description)] + } +} + +extension FunctionTypeSyntax { + /// Return a closure expression for this function type, eg. + /// ``` + /// (_ someInt: Int) -> String + /// ``` + /// would become + /// ``` + /// { someInt in + /// <#T##String#> + /// } + /// ``` + fileprivate var closureExpansion: ClosureExprSyntax { + let closureSignature: ClosureSignatureSyntax? + if !arguments.isEmpty { + let args = ClosureParamListSyntax { + for arg in arguments { + ClosureParamSyntax(name: arg.expansionNameToken()) + } + } + closureSignature = ClosureSignatureSyntax(input: .simpleInput(args)) + } else { + closureSignature = nil + } + + // Single statement for the body - the placeholder-ed type if non-void and + // 'code' otherwise. + let ret = output.returnType.description + let placeholder: String + if ret == "Void" || ret == "()" { + placeholder = ExpandEditorPlaceholder.wrapInTypePlaceholder("code", type: "Void") + } else { + placeholder = ExpandEditorPlaceholder.wrapInTypePlaceholder(ret, type: ret) + } + + let statementPlaceholder = EditorPlaceholderExprSyntax( + identifier: .identifier(placeholder) + ) + let closureStatement = CodeBlockItemSyntax( + item: .expr(ExprSyntax(statementPlaceholder)) + ) + + return ClosureExprSyntax( + leftBrace: .leftBraceToken(), + signature: closureSignature, + statements: CodeBlockItemListSyntax([closureStatement]), + rightBrace: .rightBraceToken() + ) + } +} + +extension TupleTypeElementSyntax { + /// Return a token to use as the parameter name in the expanded closure. + /// We prefer the argument name if there is one and it isn't a wildcard, + /// falling back to the label with the same conditions, and finally just the + /// placeholder-ed type otherwise. + fileprivate func expansionNameToken() -> TokenSyntax { + if let secondName = secondName, secondName.tokenKind != .wildcard { + return secondName + } + + if let name = name, name.tokenKind != .wildcard { + return name + } + + return .identifier(ExpandEditorPlaceholder.wrapInPlaceholder(type.description)) + } +} + +extension FunctionCallExprSyntax { + /// If the given argument is one of the last arguments that are all + /// function-typed placeholders and this call doesn't have a trailing + /// 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(ifIncluded: TupleExprElementSyntax) -> (expr: FunctionCallExprSyntax, numClosures: Int)? { + var includedArg = false + var argsToExpand = 0 + for arg in argumentList.reversed() { + guard let expr = arg.expression.as(EditorPlaceholderExprSyntax.self), + let data = EditorPlaceholderData(token: expr.identifier), + case let .typed(_, type) = data, + type.is(FunctionTypeSyntax.self) + else { + break + } + if arg == ifIncluded { + includedArg = true + } + argsToExpand += 1 + } + + guard includedArg else { + return nil + } + + var expandedArgs = [TupleExprElementSyntax]() + for arg in argumentList.suffix(argsToExpand) { + let edits = ExpandEditorPlaceholder.textRefactor(syntax: arg.expression.cast(EditorPlaceholderExprSyntax.self).identifier) + guard edits.count == 1, let edit = edits.first, !edit.replacement.isEmpty else { + return nil + } + + var parser = Parser(edit.replacement) + let expr = ExprSyntax.parse(from: &parser) + expandedArgs.append( + arg.detach().with(\.expression, expr) + ) + } + + let originalArgs = argumentList.dropLast(argsToExpand) + return ( + detach().with(\.argumentList, TupleExprElementListSyntax(originalArgs + expandedArgs)), + expandedArgs.count + ) + } +} + +/// Placeholder text must start with '<#' and end with +/// '#>'. Placeholders can be one of the following formats: +/// ``` +/// 'T##' display-string '##' type-string ('##' type-for-expansion-string)? +/// 'T##' display-and-type-string +/// display-string +/// ``` +/// +/// NOTE: It is required that '##' is not a valid substring of display-string +/// or type-string. If this ends up not the case for some reason, we can consider +/// adding escaping for '##'. +fileprivate enum EditorPlaceholderData { + case basic(text: Substring) + case typed(text: Substring, type: TypeSyntax) + + init?(token: TokenSyntax) { + guard ExpandEditorPlaceholder.isPlaceholder(token.text) else { + return nil + } + + var text = token.text.dropFirst(2).dropLast(2) + + if !text.hasPrefix("T##") { + // No type information + self = .basic(text: text) + return + } + + // Drop 'T##' + text = text.dropFirst(3) + + var typeText: Substring + (text, typeText) = split(text, separatedBy: "##") + if typeText.isEmpty { + // No type information + self = .basic(text: text) + return + } + + // Have type text, see if we also have expansion text + + let expansionText: Substring + (typeText, expansionText) = split(typeText, separatedBy: "##") + if expansionText.isEmpty { + if typeText.isEmpty { + // No type information + self = .basic(text: text) + } else { + // Only have type text, use it for the placeholder expansion + self.init(typeText: typeText) + } + + return + } + + // Have expansion type text, use it for the placeholder expansion + self.init(typeText: expansionText) + } + + init(typeText: Substring) { + var parser = Parser(String(typeText)) + + let type: TypeSyntax = TypeSyntax.parse(from: &parser) + if type.hasError { + self = .basic(text: typeText) + } else { + self = .typed(text: typeText, type: type) + } + } +} + +/// Split the given string into two components on the first instance of +/// `separatedBy`. The second element is empty if `separatedBy` is missing +/// from the initial string. +fileprivate func split(_ text: Substring, separatedBy separator: String) -> (Substring, Substring) { + var rest = text + while !rest.isEmpty && !rest.hasPrefix(separator) { + rest = rest.dropFirst() + } + return (text.dropLast(rest.count), rest.dropFirst(2)) +} + +fileprivate let placeholderStart: String = "<#" +fileprivate let placeholderEnd: String = "#>" diff --git a/Sources/SwiftRefactor/FormatRawStringLiteral.swift b/Sources/SwiftRefactor/FormatRawStringLiteral.swift index 738adc3d540..ee204c057bb 100644 --- a/Sources/SwiftRefactor/FormatRawStringLiteral.swift +++ b/Sources/SwiftRefactor/FormatRawStringLiteral.swift @@ -30,7 +30,7 @@ import SwiftSyntax /// ##"Hello \#(world)"## /// "Hello World" /// ``` -public struct FormatRawStringLiteral: RefactoringProvider { +public struct FormatRawStringLiteral: SyntaxRefactoringProvider { public static func refactor(syntax lit: StringLiteralExprSyntax, in context: Void) -> StringLiteralExprSyntax? { var maximumHashes = 0 for segment in lit.segments { diff --git a/Sources/SwiftRefactor/MigrateToNewIfLetSyntax.swift b/Sources/SwiftRefactor/MigrateToNewIfLetSyntax.swift index 3fd906cb73d..02a16940510 100644 --- a/Sources/SwiftRefactor/MigrateToNewIfLetSyntax.swift +++ b/Sources/SwiftRefactor/MigrateToNewIfLetSyntax.swift @@ -33,7 +33,7 @@ import SwiftParser /// if let foo { /// // ... /// } -public struct MigrateToNewIfLetSyntax: RefactoringProvider { +public struct MigrateToNewIfLetSyntax: SyntaxRefactoringProvider { public static func refactor(syntax node: IfExprSyntax, in context: ()) -> IfExprSyntax? { // Visit all conditions in the node. let newConditions = node.conditions.enumerated().map { (index, condition) -> ConditionElementListSyntax.Element in diff --git a/Sources/SwiftRefactor/OpaqueParameterToGeneric.swift b/Sources/SwiftRefactor/OpaqueParameterToGeneric.swift index d42b2c48afb..632c953df1c 100644 --- a/Sources/SwiftRefactor/OpaqueParameterToGeneric.swift +++ b/Sources/SwiftRefactor/OpaqueParameterToGeneric.swift @@ -115,7 +115,7 @@ fileprivate class SomeParameterRewriter: SyntaxRewriter { /// ```swift /// func someFunction(_ input: T1) {} /// ``` -public struct OpaqueParameterToGeneric: RefactoringProvider { +public struct OpaqueParameterToGeneric: SyntaxRefactoringProvider { /// Replace all of the "some" parameters in the given parameter clause with /// freshly-created generic parameters. /// diff --git a/Sources/SwiftRefactor/RefactoringProvider.swift b/Sources/SwiftRefactor/RefactoringProvider.swift index 16a92995a9f..69225237d9b 100644 --- a/Sources/SwiftRefactor/RefactoringProvider.swift +++ b/Sources/SwiftRefactor/RefactoringProvider.swift @@ -12,30 +12,43 @@ import SwiftSyntax -/// A type that transforms syntax to provide a (context-sensitive) -/// refactoring. -/// -/// A type conforming to the `RefactoringProvider` protocol defines -/// a refactoring action against a family of Swift syntax trees. -/// -/// Refactoring -/// =========== -/// -/// Refactoring is the act of transforming source code to be more effective. -/// A refactoring does not affect the semantics of code it is transforming. -/// Rather, it makes that code easier to read and reason about. -/// -/// Code Transformation -/// =================== -/// -/// Refactoring is expressed as structural transformations of Swift -/// syntax trees. The SwiftSyntax API provides a natural, easy-to-use, -/// and compositional set of updates to the syntax tree. For example, a -/// refactoring action that wishes to exchange the leading trivia of a node -/// would call `with(\.leadingTrivia, _:)` against its input syntax and return -/// the resulting syntax node. For compound syntax nodes, entire sub-trees -/// can be added, exchanged, or removed by calling the corresponding `with` -/// API. +/// A refactoring expressed as textual edits on the original syntax tree. In +/// general clients should prefer `SyntaxRefactoringProvider` where possible. +public protocol EditRefactoringProvider { + /// The type of syntax this refactoring action accepts. + associatedtype Input: SyntaxProtocol + /// Contextual information used by the refactoring action. + associatedtype Context = Void + + /// Perform the refactoring action on the provided syntax node. + /// + /// - Parameters: + /// - syntax: The syntax to transform. + /// - context: Contextual information used by the refactoring action. + /// - Returns: Textual edits that describe how to apply the result of the + /// refactoring action on locations within the original tree. An + /// empty array if the refactoring could not be performed. + static func textRefactor(syntax: Input, in context: Context) -> [SourceEdit] +} + +extension EditRefactoringProvider where Context == Void { + /// See `textRefactor(syntax:in:)`. This method provides a convenient way to + /// invoke a refactoring action that requires no context. + /// + /// - Parameters: + /// - syntax: The syntax to transform. + /// - Returns: Textual edits describing the refactoring to perform. + public static func textRefactor(syntax: Input) -> [SourceEdit] { + return self.textRefactor(syntax: syntax, in: ()) + } +} + +/// A refactoring expressed as a structural transformation of the original +/// syntax node. For example, a refactoring action that wishes to exchange the +/// leading trivia of a node could call call `with(\.leadingTrivia, _:)` +/// against its input syntax and return the resulting syntax node. Or, for +/// compound syntax nodes, entire sub-trees can be added, exchanged, or removed +/// by calling the corresponding `with` API. /// /// - Note: The syntax trees returned by SwiftSyntax are immutable: any /// transformation made against the tree results in a distinct tree. @@ -44,43 +57,116 @@ import SwiftSyntax /// ========================= /// /// A refactoring provider cannot assume that the syntax it is given is -/// neessarily well-formed. As the SwiftSyntax library is capable of recovering +/// necessarily well-formed. As the SwiftSyntax library is capable of recovering /// from a variety of erroneous inputs, a refactoring provider has to be /// prepared to fail gracefully as well. Many refactoring providers follow a /// common validation pattern that "preflights" the refactoring by testing the /// structure of the provided syntax nodes. If the tests fail, the refactoring -/// provider exits early by returning `nil`. It is recommended that refactoring -/// actions fail as quickly as possible to give any associated tooling -/// space to recover as well. -public protocol RefactoringProvider { - /// The type of syntax this refactoring action accepts. - associatedtype Input: SyntaxProtocol = SourceFileSyntax +/// provider exits early by returning an empty array. It is recommended that +/// refactoring actions fail as quickly as possible to give any associated +/// tooling space to recover as well. +public protocol SyntaxRefactoringProvider: EditRefactoringProvider { + // Should not be required, see https://github.com/apple/swift/issues/66004. + // The default is a hack to workaround the warning that we'd hit otherwise. + associatedtype Input: SyntaxProtocol = MissingSyntax /// The type of syntax this refactoring action returns. - associatedtype Output: SyntaxProtocol = SourceFileSyntax + associatedtype Output: SyntaxProtocol /// Contextual information used by the refactoring action. associatedtype Context = Void - /// Perform the refactoring action on the provided syntax node. + /// Perform the refactoring action on the provided syntax node. It is assumed + /// that the returned output completely replaces the input node. /// /// - Parameters: /// - syntax: The syntax to transform. /// - context: Contextual information used by the refactoring action. /// - Returns: The result of applying the refactoring action, or `nil` if the /// action could not be performed. - static func refactor(syntax: Self.Input, in context: Self.Context) -> Self.Output? + static func refactor(syntax: Input, in context: Context) -> Output? } -extension RefactoringProvider where Context == Void { - /// Perform the refactoring action on the provided syntax node. - /// - /// This method provides a convenient way to invoke a refactoring action that - /// requires no context. +extension SyntaxRefactoringProvider where Context == Void { + /// See `refactor(syntax:in:)`. This method provides a convenient way to + /// invoke a refactoring action that requires no context. /// /// - Parameters: /// - syntax: The syntax to transform. /// - Returns: The result of applying the refactoring action, or `nil` if the /// action could not be performed. - public static func refactor(syntax: Self.Input) -> Self.Output? { + public static func refactor(syntax: Input) -> Output? { return self.refactor(syntax: syntax, in: ()) } } + +extension SyntaxRefactoringProvider { + /// Provides a default implementation for + /// `EditRefactoringProvider.textRefactor(syntax:in:)` that produces an edit + /// to replace the input of `refactor(syntax:in:)` with its returned output. + public static func textRefactor(syntax: Input, in context: Context) -> [SourceEdit] { + guard let output = refactor(syntax: syntax, in: context) else { + return [] + } + return [SourceEdit.replace(syntax, with: output.description)] + } +} + +/// An textual edit to the original source represented by a range and a +/// replacement. +public struct SourceEdit: Equatable { + /// The half-open range that this edit applies to. + public let range: Range + /// The text to replace the original range with. Empty for a deletion. + public let replacement: String + + /// Length of the original source range that this edit applies to. Zero if + /// this is an addition. + public var length: SourceLength { + return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset) + } + + /// Create an edit to replace `range` in the original source with + /// `replacement`. + public init(range: Range, replacement: String) { + self.range = range + self.replacement = replacement + } + + /// Convenience function to create a textual addition after the given node + /// and its trivia. + public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit { + return SourceEdit(range: node.endPosition.. SourceEdit { + return SourceEdit(range: node.position.. SourceEdit { + return SourceEdit(range: node.position.. SourceEdit { + return SourceEdit(range: node.position.. IntegerLiteralExprSyntax? { guard lit.digits.text.contains("_") else { return lit diff --git a/Sources/SwiftSyntax/IncrementalParseTransition.swift b/Sources/SwiftSyntax/IncrementalParseTransition.swift index b45374cd759..1f6ff2e5937 100644 --- a/Sources/SwiftSyntax/IncrementalParseTransition.swift +++ b/Sources/SwiftSyntax/IncrementalParseTransition.swift @@ -97,11 +97,11 @@ public struct ConcurrentEdits { /// The raw concurrent edits. Are guaranteed to satisfy the requirements /// stated above. - public let edits: [SourceEdit] + public let edits: [IncrementalEdit] /// Initialize this struct from edits that are already in a concurrent form /// and are guaranteed to satisfy the requirements posed above. - public init(concurrent: [SourceEdit]) throws { + public init(concurrent: [IncrementalEdit]) throws { if !Self.isValidConcurrentEditArray(concurrent) { throw ConcurrentEditsError.editsNotConcurrent } @@ -117,7 +117,7 @@ public struct ConcurrentEdits { /// - insert 'z' at offset 2 /// to '012345' results in 'xyz012345'. - public init(fromSequential sequentialEdits: [SourceEdit]) { + public init(fromSequential sequentialEdits: [IncrementalEdit]) { do { try self.init(concurrent: Self.translateSequentialEditsToConcurrentEdits(sequentialEdits)) } catch { @@ -128,7 +128,7 @@ public struct ConcurrentEdits { /// Construct a concurrent edits struct from a single edit. For a single edit, /// there is no differentiation between being it being applied concurrently /// or sequentially. - public init(_ single: SourceEdit) { + public init(_ single: IncrementalEdit) { do { try self.init(concurrent: [single]) } catch { @@ -137,9 +137,9 @@ public struct ConcurrentEdits { } private static func translateSequentialEditsToConcurrentEdits( - _ edits: [SourceEdit] - ) -> [SourceEdit] { - var concurrentEdits: [SourceEdit] = [] + _ edits: [IncrementalEdit] + ) -> [IncrementalEdit] { + var concurrentEdits: [IncrementalEdit] = [] for editToAdd in edits { var editToAdd = editToAdd var editIndiciesMergedWithNewEdit: [Int] = [] @@ -147,14 +147,14 @@ public struct ConcurrentEdits { if existingEdit.replacementRange.intersectsOrTouches(editToAdd.range) { let intersectionLength = existingEdit.replacementRange.intersected(editToAdd.range).length - editToAdd = SourceEdit( + editToAdd = IncrementalEdit( offset: Swift.min(existingEdit.offset, editToAdd.offset), length: existingEdit.length + editToAdd.length - intersectionLength, replacementLength: existingEdit.replacementLength + editToAdd.replacementLength - intersectionLength ) editIndiciesMergedWithNewEdit.append(index) } else if existingEdit.offset < editToAdd.endOffset { - editToAdd = SourceEdit( + editToAdd = IncrementalEdit( offset: editToAdd.offset - existingEdit.replacementLength + existingEdit.length, length: editToAdd.length, replacementLength: editToAdd.replacementLength @@ -175,7 +175,7 @@ public struct ConcurrentEdits { return concurrentEdits } - private static func isValidConcurrentEditArray(_ edits: [SourceEdit]) -> Bool { + private static func isValidConcurrentEditArray(_ edits: [IncrementalEdit]) -> Bool { // Not quite sure if we should disallow creating an `IncrementalParseTransition` // object without edits but there doesn't seem to be much benefit if we do, // and there are 'lit' tests that want to test incremental re-parsing without edits. @@ -195,7 +195,7 @@ public struct ConcurrentEdits { } /// **Public for testing purposes only** - public static func _isValidConcurrentEditArray(_ edits: [SourceEdit]) -> Bool { + public static func _isValidConcurrentEditArray(_ edits: [IncrementalEdit]) -> Bool { return isValidConcurrentEditArray(edits) } } diff --git a/Sources/SwiftSyntax/Trivia.swift b/Sources/SwiftSyntax/Trivia.swift index 222a2c84a18..54e03fa4516 100644 --- a/Sources/SwiftSyntax/Trivia.swift +++ b/Sources/SwiftSyntax/Trivia.swift @@ -59,7 +59,11 @@ public struct Trivia { /// Creates a new `Trivia` by merging in the given trivia. Only includes one /// copy of a common prefix of `self` and `trivia`. - public func merging(_ trivia: Trivia) -> Trivia { + public func merging(_ trivia: Trivia?) -> Trivia { + guard let trivia else { + return self + } + let lhs = self.decomposed let rhs = trivia.decomposed for infixLength in (0...Swift.min(lhs.count, rhs.count)).reversed() { @@ -73,7 +77,10 @@ public struct Trivia { /// Creates a new `Trivia` by merging the leading and trailing `Trivia` /// of `triviaOf` into the end of `self`. Only includes one copy of any /// common prefixes. - public func merging(triviaOf node: T) -> Trivia { + public func merging(triviaOf node: T?) -> Trivia { + guard let node else { + return self + } return merging(node.leadingTrivia).merging(node.trailingTrivia) } diff --git a/Sources/SwiftSyntax/Utils.swift b/Sources/SwiftSyntax/Utils.swift index 1ab3148771f..856bac5ab6f 100644 --- a/Sources/SwiftSyntax/Utils.swift +++ b/Sources/SwiftSyntax/Utils.swift @@ -47,7 +47,7 @@ public struct ByteSourceRange: Equatable { } } -public struct SourceEdit: Equatable { +public struct IncrementalEdit: Equatable { /// The byte range of the original source buffer that the edit applies to. public let range: ByteSourceRange /// The length of the edit replacement in UTF8 bytes. diff --git a/Sources/_SwiftSyntaxTestSupport/IncrementalParseTestUtils.swift b/Sources/_SwiftSyntaxTestSupport/IncrementalParseTestUtils.swift new file mode 100644 index 00000000000..6f7699bc633 --- /dev/null +++ b/Sources/_SwiftSyntaxTestSupport/IncrementalParseTestUtils.swift @@ -0,0 +1,217 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftParser +import XCTest + +/// This function is used to verify the correctness of incremental parsing +/// containing three parts: +/// 1. Round-trip on the incrementally parsed syntax tree. +/// 2. verify that incrementally parsing the edited source base on the original source produces the same syntax tree as reparsing the post-edit file from scratch. +/// 3. verify the reused nodes fall into expectations. +public func assertIncrementalParse( + _ source: String, + reusedNodes expectedReusedNodes: [ReusedNodeSpec] = [], + file: StaticString = #file, + line: UInt = #line +) { + let (concurrentEdits, originalSource, editedSource) = getEditsAndSources(source) + + let originalString = String(originalSource) + let editedString = String(editedSource) + + let originalTree = Parser.parse(source: originalString) + + let reusedNodesCollector = IncrementalParseReusedNodeCollector() + let transition = IncrementalParseTransition(previousTree: originalTree, edits: concurrentEdits, reusedNodeDelegate: reusedNodesCollector) + + let newTree = Parser.parse(source: editedString) + let incrementallyParsedNewTree = Parser.parse(source: editedString, parseTransition: transition) + + // Round-trip + assertStringsEqualWithDiff( + editedString, + "\(incrementallyParsedNewTree)", + additionalInfo: """ + Source failed to round-trip when parsing incrementally + + Actual syntax tree: + \(incrementallyParsedNewTree.debugDescription) + """, + file: file, + line: line + ) + + // Substructure + let subtreeMatcher = SubtreeMatcher(Syntax(incrementallyParsedNewTree), markers: [:]) + do { + try subtreeMatcher.assertSameStructure(Syntax(newTree), includeTrivia: true, file: file, line: line) + } catch { + XCTFail("Matching for a subtree failed with error: \(error)", file: file, line: line) + } + + // Re-used nodes + if reusedNodesCollector.rangeAndNodes.count != expectedReusedNodes.count { + XCTFail( + """ + Expected \(expectedReusedNodes.count) re-used nodes but received \(reusedNodesCollector.rangeAndNodes.count): + \(reusedNodesCollector.rangeAndNodes.map {$0.1.description}.joined(separator: "\n")) + """, + file: file, + line: line + ) + return + } + + for expectedReusedNode in expectedReusedNodes { + guard let range = getByteSourceRange(for: expectedReusedNode.source, in: originalString) else { + XCTFail("Fail to find string in original source,", file: expectedReusedNode.file, line: expectedReusedNode.line) + continue + } + + guard let reusedNode = reusedNodesCollector.rangeAndNodes.first(where: { $0.0 == range })?.1 else { + XCTFail( + """ + Fail to match the range of \(expectedReusedNode.source) in: + \(reusedNodesCollector.rangeAndNodes.map({"\($0.0): \($0.1.description)"}).joined(separator: "\n")) + """, + file: expectedReusedNode.file, + line: expectedReusedNode.line + ) + continue + } + + XCTAssertEqual( + expectedReusedNode.kind, + expectedReusedNode.kind, + """ + Expected \(expectedReusedNode.kind) syntax kind but received \(reusedNode.kind) + """, + file: expectedReusedNode.file, + line: expectedReusedNode.line + ) + } +} + +fileprivate func getByteSourceRange(for substring: String, in sourceString: String) -> ByteSourceRange? { + if let range = sourceString.range(of: substring) { + return ByteSourceRange( + offset: sourceString.utf8.distance(from: sourceString.startIndex, to: range.lowerBound), + length: sourceString.utf8.distance(from: range.lowerBound, to: range.upperBound) + ) + } + return nil +} + +/// An abstract data structure to describe the how a re-used node produced by the incremental parse should look like. +public struct ReusedNodeSpec { + /// The re-used string in original source without any ``Trivia`` + let source: String + /// The ``SyntaxKind`` of re-used node + let kind: SyntaxKind + /// The file and line at which this ``ReusedNodeSpec`` was created, so that assertion failures can be reported at its location. + let file: StaticString + let line: UInt + + public init( + _ source: String, + kind: SyntaxKind, + file: StaticString = #file, + line: UInt = #line + ) { + self.source = source + self.kind = kind + self.file = file + self.line = line + } +} + +/// Get `ConcurrentEdits` in source whose edited zones are marked with markers +/// Also extract the markers from source to get original source and edited source +/// +/// `⏩️` is *start marker*, `⏸️` is *separate marker*, `⏪️` is *end marker* +/// Contents between `⏩️` and `⏸️` are source text that before modification, contents +/// betwwen `⏸️` and `⏪️` are source text that after modification +/// i.e. `⏩️foo⏸️bar⏪️`, the original source is `foo` and the edited source is `bar` +public func getEditsAndSources(_ source: String) -> (edits: ConcurrentEdits, orignialSource: Substring, editedSource: Substring) { + var editedSource = Substring() + var originalSource = Substring() + var concurrentEdits: [IncrementalEdit] = [] + + var lastStartIndex = source.startIndex + while let startIndex = source[lastStartIndex...].firstIndex(where: { $0 == "⏩️" }), + let separateIndex = source[startIndex...].firstIndex(where: { $0 == "⏸️" }), + let endIndex = source[separateIndex...].firstIndex(where: { $0 == "⏪️" }) + { + + originalSource += source[lastStartIndex.. String { + guard let replacementAscii = replacementChar.asciiValue else { + fatalError("replacementChar must be an ASCII character") + } + var edits = edits + if concurrent { + XCTAssert(ConcurrentEdits._isValidConcurrentEditArray(edits)) + + // If the edits are concurrent, sorted and not overlapping (as guaranteed by + // the check above, we can apply them sequentially to the string in reverse + // order because later edits don't affect earlier edits. + edits = edits.reversed() + } + var bytes = Array(testString.utf8) + for edit in edits { + assert(edit.endOffset <= bytes.count) + bytes.removeSubrange(edit.offset.. [IncrementalEdit] { + func getIncrementalEdits() throws -> [IncrementalEditSpec] { let regex = try NSRegularExpression( pattern: "([0-9]+):([0-9]+)-([0-9]+):([0-9]+)=(.*)" ) - var parsedEdits = [IncrementalEdit]() + var parsedEdits = [IncrementalEditSpec]() let editArgs = try self.getValues("-incremental-edit") for edit in editArgs { guard @@ -111,7 +111,7 @@ extension CommandLineArguments { let region = getSourceRegion(match, text: edit) let replacement = match.match(at: 5, text: edit) parsedEdits.append( - IncrementalEdit( + IncrementalEditSpec( region: region, replacement: replacement ) @@ -232,7 +232,7 @@ struct SourceRegion { let endColumn: Int } -struct IncrementalEdit { +struct IncrementalEditSpec { let region: SourceRegion let replacement: String } @@ -312,8 +312,8 @@ func getByteRange( func parseIncrementalEditArguments( args: CommandLineArguments -) throws -> [SourceEdit] { - var edits = [SourceEdit]() +) throws -> [IncrementalEdit] { + var edits = [IncrementalEdit]() let argEdits = try args.getIncrementalEdits() let preEditURL = URL(fileURLWithPath: try args.getRequired("-old-source-file")) @@ -326,7 +326,7 @@ func parseIncrementalEditArguments( argName: "-incremental-edit" ) let replacementLength = argEdit.replacement.utf8.count - edits.append(SourceEdit(range: range, replacementLength: replacementLength)) + edits.append(IncrementalEdit(range: range, replacementLength: replacementLength)) } return edits } diff --git a/Tests/SwiftBasicFormatTest/BasicFormatTests.swift b/Tests/SwiftBasicFormatTest/BasicFormatTests.swift index ca727c91132..c815d9b960e 100644 --- a/Tests/SwiftBasicFormatTest/BasicFormatTests.swift +++ b/Tests/SwiftBasicFormatTest/BasicFormatTests.swift @@ -327,4 +327,18 @@ final class BasicFormatTest: XCTestCase { using: BasicFormat(indentationWidth: .spaces(2)) ) } + + func testClosureExprParam() { + let source = """ + _ = {foo in} + """ + + assertFormatted( + source: source, + expected: """ + _ = { foo in + } + """ + ) + } } diff --git a/Tests/SwiftParserTest/ExpressionTests.swift b/Tests/SwiftParserTest/ExpressionTests.swift index 357c98490ff..fa8d3340131 100644 --- a/Tests/SwiftParserTest/ExpressionTests.swift +++ b/Tests/SwiftParserTest/ExpressionTests.swift @@ -1900,7 +1900,7 @@ final class StatementExpressionTests: XCTestCase { } func testInitCallInPoundIf() { - // Make sure we parse 'init()' as an expr, not a decl. + // MaSources/SwiftSyntax/generated/syntaxNodes/SyntaxTypeNodes.swiftke sure we parse 'init()' as an expr, not a decl. assertParse( """ class C { diff --git a/Tests/SwiftParserTest/IncrementalParsingTests.swift b/Tests/SwiftParserTest/IncrementalParsingTests.swift index e4d1cc1bdaf..b4f8bc5b96d 100644 --- a/Tests/SwiftParserTest/IncrementalParsingTests.swift +++ b/Tests/SwiftParserTest/IncrementalParsingTests.swift @@ -13,51 +13,28 @@ import XCTest import SwiftSyntax import SwiftParser +import _SwiftSyntaxTestSupport public class IncrementalParsingTests: XCTestCase { public func testIncrementalInvalid() { - let original = "struct A { func f() {" - let step: (String, (Int, Int, String)) = - ("struct AA { func f() {", (8, 0, "A")) - - var tree = Parser.parse(source: original) - let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count) - let lookup = IncrementalParseTransition(previousTree: tree, edits: ConcurrentEdits(sourceEdit)) - tree = Parser.parse(source: step.0, parseTransition: lookup) - XCTAssertEqual("\(tree)", step.0) + assertIncrementalParse( + """ + struct A⏩️⏸️A⏪️ { func f() { + """ + ) } public func testReusedNode() throws { try XCTSkipIf(true, "Swift parser does not handle node reuse yet") - - let original = "struct A {}\nstruct B {}\n" - let step: (String, (Int, Int, String)) = - ("struct AA {}\nstruct B {}\n", (8, 0, "A")) - - let origTree = Parser.parse(source: original) - let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count) - let reusedNodeCollector = IncrementalParseReusedNodeCollector() - let transition = IncrementalParseTransition(previousTree: origTree, edits: ConcurrentEdits(sourceEdit), reusedNodeDelegate: reusedNodeCollector) - let newTree = Parser.parse(source: step.0, parseTransition: transition) - XCTAssertEqual("\(newTree)", step.0) - - let origStructB = origTree.statements[1] - let newStructB = newTree.statements[1] - XCTAssertEqual("\(origStructB)", "\nstruct B {}") - XCTAssertEqual("\(newStructB)", "\nstruct B {}") - XCTAssertNotEqual(origStructB, newStructB) - - XCTAssertEqual(reusedNodeCollector.rangeAndNodes.count, 1) - if reusedNodeCollector.rangeAndNodes.count != 1 { return } - let (reusedRange, reusedNode) = reusedNodeCollector.rangeAndNodes[0] - XCTAssertEqual("\(reusedNode)", "\nstruct B {}") - - XCTAssertEqual(newStructB.byteRange, reusedRange) - XCTAssertEqual(origStructB.id, reusedNode.id) - XCTAssertEqual(origStructB, reusedNode.as(CodeBlockItemSyntax.self)) - XCTAssertTrue(reusedNode.is(CodeBlockItemSyntax.self)) - XCTAssertEqual(origStructB, reusedNode.as(CodeBlockItemSyntax.self)!) - XCTAssertEqual(origStructB.parent!.id, reusedNode.parent!.id) + assertIncrementalParse( + """ + struct A⏩️⏸️A⏪️ {} + struct B {} + """, + reusedNodes: [ + ReusedNodeSpec("struct B {}", kind: .codeBlockItem) + ] + ) } } diff --git a/Tests/SwiftRefactorTest/CallToTrailingClosureTests.swift b/Tests/SwiftRefactorTest/CallToTrailingClosureTests.swift new file mode 100644 index 00000000000..e372401b214 --- /dev/null +++ b/Tests/SwiftRefactorTest/CallToTrailingClosureTests.swift @@ -0,0 +1,237 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest +import _SwiftSyntaxTestSupport + +final class CallToTrailingClosuresTest: XCTestCase { + func testSingleClosure() throws { + let baseline: ExprSyntax = """ + foo({ label in + return 1 + }) + """ + + let expected: ExprSyntax = """ + foo { label in + return 1 + } + """ + + try assertRefactorCall(baseline, expected: expected) + } + + func testSingleNamedClosure() throws { + let baseline: ExprSyntax = """ + foo(arg: { label in + return 1 + }) + """ + + let expected: ExprSyntax = """ + foo { label in + return 1 + } + """ + + try assertRefactorCall(baseline, expected: expected) + } + + func testSuffixClosure() throws { + let baseline: ExprSyntax = """ + foo(1, { label in + return 1 + }) + """ + + let expected: ExprSyntax = """ + foo(1) { label in + return 1 + } + """ + + try assertRefactorCall(baseline, expected: expected) + } + + func testSuffixClosures() throws { + let baseline: ExprSyntax = """ + foo({ label in + return 1 + }, 1, { label2 in + return 2 + }, { label3 in + return 3 + }, named: { label4 in + return 4 + }) + """ + + // TODO: The ident here is not great. + // https://github.com/apple/swift-syntax/issues/1473 + let expected: ExprSyntax = """ + foo({ label in + return 1 + }, 1) { label2 in + return 2 + } _: { label3 in + return 3 + } named: { label4 in + return 4 + } + """ + + try assertRefactorCall(baseline, expected: expected) + } + + func testSomeSuffixClosures() throws { + let baseline: ExprSyntax = """ + foo({ label in + return 1 + }, 1, { label2 in + return 2 + }, { label3 in + return 3 + }, named: { label4 in + return 4 + }) + """ + + // TODO: BasicFormat is pretty messed up here + let expected: ExprSyntax = """ + foo({ label in + return 1 + }, 1, { label2 in + return 2 + }) { label3 in + return 3 + } named: { label4 in + return 4 + } + """ + + try assertRefactorCall(baseline, startAtArgument: 3, expected: expected) + } + + func testNoArgs() throws { + let baseline: ExprSyntax = """ + foo() + """ + + try assertRefactorCall(baseline, expected: nil) + } + + func testNonSuffixClosure() throws { + let baseline: ExprSyntax = """ + foo({ _ in }, 1) + """ + + try assertRefactorCall(baseline, expected: nil) + } + + func testExistingTrailingClosure() throws { + let baseline: ExprSyntax = """ + foo({ _ in }) { _ in } + """ + + try assertRefactorCall(baseline, expected: nil) + } + + func testExistingAdditionalTrailingClosure() throws { + let baseline = ExprSyntax( + """ + foo({ _ in }) { _ in + } another: { _ in + } + """ + ) + .cast(FunctionCallExprSyntax.self) + .with(\.trailingClosure, nil) + + try assertRefactorCall(ExprSyntax(baseline), expected: nil) + } + + func testMissingParens() throws { + let baseline = ExprSyntax( + """ + foo(1, { label in + return 1 + }) + """ + ) + .cast(FunctionCallExprSyntax.self) + .with(\.leftParen, nil) + .with(\.rightParen, nil) + + try assertRefactorCall(ExprSyntax(baseline), expected: nil) + } + + func testBadContext() throws { + try assertRefactorCall("foo({ _ in })", startAtArgument: 1, expected: nil) + } + + func testSingleClosureComments() throws { + let baseline: ExprSyntax = """ + /*c1*/foo/*c2*/(/*c3*/1/*c4*/, /*c5*/arg/*c6*/:/*c7*/ {/*c8*/ label in + return 1 + /*c9*/}/*c10*/)/*c11*/ + """ + + let expected: ExprSyntax = """ + /*c1*/foo/*c2*/(/*c3*/1/*c4*/) /*c5*/ /*c6*//*c7*/ {/*c8*/ label in + return 1 + /*c9*/}/*c10*//*c11*/ + """ + + try assertRefactorCall(baseline, expected: expected) + } + + func testTrailingClosureComments() throws { + let baseline: ExprSyntax = """ + /*c1*/foo/*c2*/(/*c3*/1/*c4*/, /*c5*/arg/*c6*/: /*c7*/{/*c8*/ label in + return 1 + /*c9*/}/*c10*/, /*c11*/named/*c12*/: /*c13*/{/*c14*/ label2 in + return 2 + /*c15*/}/*c16*/)/*c17*/ + """ + + let expected: ExprSyntax = """ + /*c1*/foo/*c2*/(/*c3*/1/*c4*/) /*c5*/ /*c6*/ /*c7*/{/*c8*/ label in + return 1 + /*c9*/}/*c10*/ /*c11*/ named/*c12*/: /*c13*/ {/*c14*/ label2 in + return 2 + /*c15*/}/*c16*//*c17*/ + """ + + try assertRefactorCall(baseline, expected: expected) + } +} + +fileprivate func assertRefactorCall( + _ callExpr: ExprSyntax, + startAtArgument: Int = 0, + expected: ExprSyntax?, + file: StaticString = #file, + line: UInt = #line +) throws { + try assertRefactor( + callExpr, + context: CallToTrailingClosures.Context(startAtArgument: startAtArgument), + provider: CallToTrailingClosures.self, + expected: expected, + file: file, + line: line + ) +} diff --git a/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift b/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift new file mode 100644 index 00000000000..0bab9f931e3 --- /dev/null +++ b/Tests/SwiftRefactorTest/ExpandEditorPlaceholderTests.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest +import _SwiftSyntaxTestSupport + +final class ExpandEditorPlaceholderTest: XCTestCase { + func testSimple() throws { + try assertRefactorPlaceholder("displayOnly", expected: "displayOnly") + try assertRefactorPlaceholder("T##typed", expected: "typed") + try assertRefactorPlaceholder("T##displayAndType##Int", expected: "Int") + try assertRefactorPlaceholder("T##bothTypes##Int##BetterInt", expected: "BetterInt") + try assertRefactorPlaceholder("T##bothTypesFirstEmpty####BetterInt", expected: "BetterInt") + } + + func testEmpty() throws { + try assertRefactorPlaceholder("", expected: "") + try assertRefactorPlaceholder("T##", expected: "") + try assertRefactorPlaceholder("T##displayEmptyType##", expected: "displayEmptyType") + try assertRefactorPlaceholder("T####EmptyDisplay", expected: "EmptyDisplay") + try assertRefactorPlaceholder("T######EmptyTypeAndDisplay", expected: "EmptyTypeAndDisplay") + try assertRefactorPlaceholder("T##bothTypesFirstNotEmpty##Int##", expected: "Int") + try assertRefactorPlaceholder("T##bothTypesEmpty####", expected: "bothTypesEmpty") + } + + func testVoidClosure() throws { + let expected = """ + { + \(ExpandEditorPlaceholder.wrapInPlaceholder("T##code##Void")) + } + """ + try assertRefactorPlaceholder("T##display##() -> Void", expected: expected) + } + + func testTypedReturnClosure() throws { + let expected = """ + { + \(ExpandEditorPlaceholder.wrapInPlaceholder("T##Int##Int")) + } + """ + try assertRefactorPlaceholder("T##display##() -> Int", expected: expected) + } + + func testClosureWithArg() throws { + let expected = """ + { arg in + \(ExpandEditorPlaceholder.wrapInPlaceholder("T##Int##Int")) + } + """ + try assertRefactorPlaceholder("T##display##(arg: String) -> Int", expected: expected) + try assertRefactorPlaceholder("T##display##(_ arg: String) -> Int", expected: expected) + } + + func testClosureWithMultipleArgs() throws { + let expected = """ + { arg, arg2 in + \(ExpandEditorPlaceholder.wrapInPlaceholder("T##Int##Int")) + } + """ + try assertRefactorPlaceholder("T##display##(arg: String, arg2: String) -> Int", expected: expected) + } + + func testSimpleComments() throws { + let placeholder = ExpandEditorPlaceholder.wrapInPlaceholder("simple") + try assertRefactorPlaceholder("/*c1*/\(placeholder)/*c2*/", wrap: false, expected: "/*c1*/simple/*c2*/") + } + + func testClosureComments() throws { + let placeholder = ExpandEditorPlaceholder.wrapInPlaceholder("T##display##(arg: String) -> Int") + let expected = """ + /*c1*/{ arg in + \(ExpandEditorPlaceholder.wrapInPlaceholder("T##Int##Int")) + }/*c2*/ + """ + try assertRefactorPlaceholder("/*c1*/\(placeholder)/*c2*/", wrap: false, expected: expected) + } +} + +fileprivate func assertRefactorPlaceholder( + _ placeholder: String, + wrap: Bool = true, + expected: String, + file: StaticString = #file, + line: UInt = #line +) throws { + let token: TokenSyntax + if wrap { + token = "\(raw: ExpandEditorPlaceholder.wrapInPlaceholder(placeholder))" + } else { + let expr: ExprSyntax = "\(raw: placeholder)" + token = try XCTUnwrap(expr.as(EditorPlaceholderExprSyntax.self)?.identifier, file: file, line: line) + } + + try assertRefactor(token, context: (), provider: ExpandEditorPlaceholder.self, expected: [SourceEdit.replace(token, with: expected)], file: file, line: line) +} diff --git a/Tests/SwiftRefactorTest/ExpandEditorPlaceholdersTests.swift b/Tests/SwiftRefactorTest/ExpandEditorPlaceholdersTests.swift new file mode 100644 index 00000000000..2f11dbaf5ca --- /dev/null +++ b/Tests/SwiftRefactorTest/ExpandEditorPlaceholdersTests.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest +import _SwiftSyntaxTestSupport + +fileprivate let closurePlaceholder = ExpandEditorPlaceholder.wrapInPlaceholder("T##closure##() -> Void") +fileprivate let voidPlaceholder = ExpandEditorPlaceholder.wrapInPlaceholder("T##code##Void") +fileprivate let intPlaceholder = ExpandEditorPlaceholder.wrapInPlaceholder("T##Int##Int") + +final class ExpandEditorPlaceholdersTest: XCTestCase { + func testSingleClosureArg() throws { + let baseline = "call(\(closurePlaceholder))" + + let expected: String = """ + call { + \(voidPlaceholder) + } + """ + + try assertRefactorPlaceholderCall(baseline, expected: expected) + } + + func testSingleNonClosureArg() throws { + try assertRefactorPlaceholderToken("call(\(intPlaceholder))", expected: "Int") + } + + func testTypeForExpansionPreferred() throws { + let placeholder = ExpandEditorPlaceholder.wrapInPlaceholder("T##closure##BadType##() -> Int") + let baseline = "call(\(placeholder))" + + let expected: String = """ + call { + \(intPlaceholder) + } + """ + + try assertRefactorPlaceholderCall(baseline, expected: expected) + } + + func testMultipleClosureArgs() throws { + let baseline = "call(arg1: \(closurePlaceholder), arg2: \(closurePlaceholder))" + + let expected: String = """ + call { + \(voidPlaceholder) + } arg2: { + \(voidPlaceholder) + } + """ + + try assertRefactorPlaceholderCall(baseline, expected: expected) + try assertRefactorPlaceholderCall(baseline, placeholder: 1, expected: expected) + } + + func testNonClosureAfterClosure() throws { + let baseline = "call(arg1: \(closurePlaceholder), arg2: \(intPlaceholder))" + + let expected: String = """ + { + \(voidPlaceholder) + } + """ + + try assertRefactorPlaceholderToken(baseline, expected: expected) + } + + func testComments() throws { + let baseline = """ + /*c1*/foo/*c2*/(/*c3*/arg/*c4*/: /*c5*/\(closurePlaceholder)/*c6*/,/*c7*/ + /*c8*/\(closurePlaceholder)/*c9*/)/*c10*/ + """ + + // TODO: Should we remove whitespace from the merged trivia? The space + // between c2 and c3 is the one added for the `{`. The space between c4 + // and c5 is the space between the `:` and c5 (added by merging the + // colon's trivia since it was removed). + let expected: String = """ + /*c1*/foo/*c2*/ /*c3*//*c4*/ /*c5*/{ + \(voidPlaceholder) + }/*c6*//*c7*/ _: + /*c8*/{ + \(voidPlaceholder) + }/*c9*//*c10*/ + """ + + try assertRefactorPlaceholderCall(baseline, placeholder: 1, expected: expected) + } +} + +fileprivate func assertRefactorPlaceholderCall( + _ expr: String, + placeholder: Int = 0, + expected: String, + file: StaticString = #file, + line: UInt = #line +) throws { + let call = try XCTUnwrap(ExprSyntax("\(raw: expr)").as(FunctionCallExprSyntax.self), file: file, line: line) + let arg = try XCTUnwrap(call.argumentList[placeholder].as(TupleExprElementSyntax.self), file: file, line: line) + let token: TokenSyntax = try XCTUnwrap(arg.expression.as(EditorPlaceholderExprSyntax.self), file: file, line: line).identifier + + try assertRefactor(token, context: (), provider: ExpandEditorPlaceholders.self, expected: [SourceEdit.replace(call, with: expected)], file: file, line: line) +} + +fileprivate func assertRefactorPlaceholderToken( + _ expr: String, + placeholder: Int = 0, + expected: String, + file: StaticString = #file, + line: UInt = #line +) throws { + let call = try XCTUnwrap(ExprSyntax("\(raw: expr)").as(FunctionCallExprSyntax.self), file: file, line: line) + let arg = try XCTUnwrap(call.argumentList[placeholder].as(TupleExprElementSyntax.self), file: file, line: line) + let token: TokenSyntax = try XCTUnwrap(arg.expression.as(EditorPlaceholderExprSyntax.self), file: file, line: line).identifier + + try assertRefactor(token, context: (), provider: ExpandEditorPlaceholders.self, expected: [SourceEdit.replace(token, with: expected)], file: file, line: line) +} diff --git a/Tests/SwiftRefactorTest/FormatRawStringLiteral.swift b/Tests/SwiftRefactorTest/FormatRawStringLiteral.swift index ba1354ae925..72c2b53ad49 100644 --- a/Tests/SwiftRefactorTest/FormatRawStringLiteral.swift +++ b/Tests/SwiftRefactorTest/FormatRawStringLiteral.swift @@ -34,8 +34,7 @@ final class FormatRawStringLiteralTest: XCTestCase { for (line, literal, expectation) in tests { let literal = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: literal)) let expectation = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: expectation)) - let refactored = try XCTUnwrap(FormatRawStringLiteral.refactor(syntax: literal)) - assertStringsEqualWithDiff(refactored.description, expectation.description, line: UInt(line)) + try assertRefactor(literal, context: (), provider: FormatRawStringLiteral.self, expected: expectation, line: UInt(line)) } } } diff --git a/Tests/SwiftRefactorTest/MigrateToNewIfLetSyntax.swift b/Tests/SwiftRefactorTest/MigrateToNewIfLetSyntax.swift index 14b1de3f80e..2af5cd3424c 100644 --- a/Tests/SwiftRefactorTest/MigrateToNewIfLetSyntax.swift +++ b/Tests/SwiftRefactorTest/MigrateToNewIfLetSyntax.swift @@ -17,32 +17,6 @@ import SwiftSyntaxBuilder import XCTest import _SwiftSyntaxTestSupport -func assertRefactorIfLet( - _ syntax: ExprSyntax, - expected: ExprSyntax, - file: StaticString = #file, - line: UInt = #line -) throws { - let ifExpr = try XCTUnwrap( - syntax.as(IfExprSyntax.self), - file: file, - line: line - ) - - let refactored = try XCTUnwrap( - MigrateToNewIfLetSyntax.refactor(syntax: ifExpr), - file: file, - line: line - ) - - assertStringsEqualWithDiff( - expected.description, - refactored.description, - file: file, - line: line - ) -} - final class MigrateToNewIfLetSyntaxTest: XCTestCase { func testRefactoring() throws { let baselineSyntax: ExprSyntax = """ @@ -53,7 +27,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testIdempotence() throws { @@ -65,8 +39,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) - try assertRefactorIfLet(expectedSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testMultiBinding() throws { @@ -78,7 +51,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x, var y, let z {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testMixedBinding() throws { @@ -90,7 +63,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x, var y = x, let z = y.w {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testConditions() throws { @@ -102,7 +75,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x = x + 1, x == x, !x {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testWhitespaceNormalization() throws { @@ -114,7 +87,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { if let x, let y {} """ - try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax) + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } func testIfStmt() throws { @@ -127,6 +100,6 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase { """ let exprStmt = try XCTUnwrap(baselineSyntax.as(ExpressionStmtSyntax.self)) - try assertRefactorIfLet(exprStmt.expression, expected: expectedSyntax) + try assertRefactor(exprStmt.expression, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) } } diff --git a/Tests/SwiftRefactorTest/OpaqueParameterToGeneric.swift b/Tests/SwiftRefactorTest/OpaqueParameterToGeneric.swift index 5c5f9ae41db..c78bba9ce75 100644 --- a/Tests/SwiftRefactorTest/OpaqueParameterToGeneric.swift +++ b/Tests/SwiftRefactorTest/OpaqueParameterToGeneric.swift @@ -33,9 +33,7 @@ final class OpaqueParameterToGenericTest: XCTestCase { ) -> some Equatable { } """ - let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline)) - - assertStringsEqualWithDiff(expected.description, refactored.description) + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) } func testRefactoringInit() throws { @@ -53,9 +51,7 @@ final class OpaqueParameterToGenericTest: XCTestCase { ) { } """ - let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline)) - - assertStringsEqualWithDiff(expected.description, refactored.description) + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) } func testRefactoringSubscript() throws { @@ -67,8 +63,6 @@ final class OpaqueParameterToGenericTest: XCTestCase { subscript(index: T1) -> String """ - let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline)) - - assertStringsEqualWithDiff(expected.description, refactored.description) + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) } } diff --git a/Tests/SwiftRefactorTest/RefactorTestUtils.swift b/Tests/SwiftRefactorTest/RefactorTestUtils.swift new file mode 100644 index 00000000000..98adebe58e4 --- /dev/null +++ b/Tests/SwiftRefactorTest/RefactorTestUtils.swift @@ -0,0 +1,148 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest +import _SwiftSyntaxTestSupport + +func assertRefactor( + _ input: R.Input, + context: R.Context, + provider: R.Type, + expected: [SourceEdit], + file: StaticString = #file, + line: UInt = #line +) throws { + let edits = R.textRefactor(syntax: input, in: context) + guard !edits.isEmpty else { + if !expected.isEmpty { + XCTFail( + """ + Refactoring produced empty result, expected: + \(expected) + """, + file: file, + line: line + ) + } + return + } + + if edits.count != expected.count { + XCTFail( + """ + Refactoring produced incorrect number of edits, expected \(expected.count) not \(edits.count). + + Actual: + \(edits.map({ $0.debugDescription }).joined(separator: "\n")) + + Expected: + \(expected.map({ $0.debugDescription }).joined(separator: "\n")) + + """, + file: file, + line: line + ) + return + } + + for (actualEdit, expectedEdit) in zip(edits, expected) { + XCTAssertEqual( + actualEdit, + expectedEdit, + "Incorrect edit, expected \(expectedEdit.debugDescription) but actual was \(actualEdit.debugDescription)", + file: file, + line: line + ) + assertStringsEqualWithDiff( + actualEdit.replacement, + expectedEdit.replacement, + file: file, + line: line + ) + } +} + +func assertRefactor( + _ input: R.Input, + context: R.Context, + provider: R.Type, + expected: R.Output?, + file: StaticString = #file, + line: UInt = #line +) throws { + let refactored = R.refactor(syntax: input, in: context) + guard let refactored = refactored else { + if expected != nil { + XCTFail( + """ + Refactoring produced nil result, expected: + \(expected?.description ?? "") + """, + file: file, + line: line + ) + } + return + } + guard let expected = expected else { + XCTFail( + """ + Expected nil result, actual: + \(refactored.description) + """, + file: file, + line: line + ) + return + } + + assertStringsEqualWithDiff( + refactored.description, + expected.description, + file: file, + line: line + ) +} + +func assertRefactor( + _ input: I, + context: R.Context, + provider: R.Type, + expected: [SourceEdit], + file: StaticString = #file, + line: UInt = #line +) throws { + let castInput = try XCTUnwrap(input.as(R.Input.self)) + try assertRefactor(castInput, context: context, provider: provider, expected: expected, file: file, line: line) +} + +func assertRefactor( + _ input: I, + context: R.Context, + provider: R.Type, + expected: E?, + file: StaticString = #file, + line: UInt = #line +) throws { + let castInput = try XCTUnwrap(input.as(R.Input.self)) + let castExpected: R.Output? + if let expected = expected { + castExpected = try XCTUnwrap(expected.as(R.Output.self)) + } else { + castExpected = nil + } + try assertRefactor(castInput, context: context, provider: provider, expected: castExpected, file: file, line: line) +} diff --git a/Tests/SwiftRefactorTest/ReformatIntegerLiteral.swift b/Tests/SwiftRefactorTest/ReformatIntegerLiteral.swift index ccc7cdf555f..4d72d3357ea 100644 --- a/Tests/SwiftRefactorTest/ReformatIntegerLiteral.swift +++ b/Tests/SwiftRefactorTest/ReformatIntegerLiteral.swift @@ -33,8 +33,7 @@ final class ReformatIntegerLiteralTest: XCTestCase { ] for (line, literal, expectation) in tests { - let refactored = try XCTUnwrap(AddSeparatorsToIntegerLiteral.refactor(syntax: literal)) - assertStringsEqualWithDiff(refactored.description, expectation.description, line: UInt(line)) + try assertRefactor(literal, context: (), provider: AddSeparatorsToIntegerLiteral.self, expected: expectation, line: UInt(line)) } } @@ -49,8 +48,7 @@ final class ReformatIntegerLiteralTest: XCTestCase { ] for (line, literal, expectation) in tests { - let refactored = try XCTUnwrap(RemoveSeparatorsFromIntegerLiteral.refactor(syntax: literal)) - assertStringsEqualWithDiff(refactored.description, expectation.description, line: UInt(line)) + try assertRefactor(literal, context: (), provider: RemoveSeparatorsFromIntegerLiteral.self, expected: expectation, line: UInt(line)) } } } diff --git a/Tests/SwiftSyntaxBuilderTest/ClosureExprTests.swift b/Tests/SwiftSyntaxBuilderTest/ClosureExprTests.swift index 60b529bb246..8a9f60ff63f 100644 --- a/Tests/SwiftSyntaxBuilderTest/ClosureExprTests.swift +++ b/Tests/SwiftSyntaxBuilderTest/ClosureExprTests.swift @@ -29,7 +29,7 @@ final class ClosureExprTests: XCTestCase { assertBuildResult( buildable, """ - {area in + { area in } """ ) diff --git a/Tests/SwiftSyntaxTest/SequentialToConcurrentEditTranslationTests.swift b/Tests/SwiftSyntaxTest/SequentialToConcurrentEditTranslationTests.swift index 518876802f1..974d6d01f52 100644 --- a/Tests/SwiftSyntaxTest/SequentialToConcurrentEditTranslationTests.swift +++ b/Tests/SwiftSyntaxTest/SequentialToConcurrentEditTranslationTests.swift @@ -12,6 +12,7 @@ import XCTest import SwiftSyntax +import _SwiftSyntaxTestSupport let longString = """ 1234567890abcdefghijklmnopqrstuvwxyz\ @@ -26,44 +27,14 @@ let longString = """ 1234567890abcdefghijklmnopqrstuvwzyz """ -/// Apply the given edits to `testString` and return the resulting string. -/// `concurrent` specifies whether the edits should be interpreted as being -/// applied sequentially or concurrently. -func applyEdits( - _ edits: [SourceEdit], - concurrent: Bool, - to testString: String = longString, - replacementChar: Character = "?" -) -> String { - guard let replacementAscii = replacementChar.asciiValue else { - fatalError("replacementChar must be an ASCII character") - } - var edits = edits - if concurrent { - XCTAssert(ConcurrentEdits._isValidConcurrentEditArray(edits)) - - // If the edits are concurrent, sorted and not overlapping (as guaranteed by - // the check above, we can apply them sequentially to the string in reverse - // order because later edits don't affect earlier edits. - edits = edits.reversed() - } - var bytes = Array(testString.utf8) - for edit in edits { - assert(edit.endOffset <= bytes.count) - bytes.removeSubrange(edit.offset..