diff --git a/Package.swift b/Package.swift index 5a97c574e..691b8135d 100644 --- a/Package.swift +++ b/Package.swift @@ -540,6 +540,19 @@ var targets: [Target] = [ exclude: ["CMakeLists.txt"], ), + .testTarget( + name: "SwiftSyntaxCodeActionsTests", + dependencies: [ + "SwiftSyntaxCodeActions" + ] + + swiftSyntaxDependencies([ + "SwiftParser", + "SwiftRefactor", + "SwiftSyntax", + "SwiftSyntaxBuilder", + ]), + ), + // MARK: SwiftSourceKitClientPlugin .target( diff --git a/Sources/SwiftSyntaxCodeActions/AddSeparatorsToIntegerLiteral.swift b/Sources/SwiftSyntaxCodeActions/AddSeparatorsToIntegerLiteral.swift new file mode 100644 index 000000000..8b97ca681 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/AddSeparatorsToIntegerLiteral.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +package import SwiftSyntax + +/// Format an integer literal by inserting underscores at base-appropriate +/// locations. +/// +/// This pass will also clean up any errant underscores. +/// +/// ## Before +/// +/// ```swift +/// 123456789 +/// 0xFFFFFFFFF +/// 0b1_0_1_0 +/// ``` +/// +/// ## After +/// +/// ```swift +/// 123_456_789 +/// 0xF_FFFF_FFFF +/// 0b1_010 +/// ``` +package struct AddSeparatorsToIntegerLiteral: SyntaxRefactoringProvider { + package static func refactor( + syntax lit: IntegerLiteralExprSyntax, + in context: Void + ) throws -> IntegerLiteralExprSyntax { + if lit.literal.text.contains("_") { + let strippedLiteral = try RemoveSeparatorsFromIntegerLiteral.refactor(syntax: lit) + return self.addSeparators(to: strippedLiteral) + } else { + return self.addSeparators(to: lit) + } + } + + private static func addSeparators(to lit: IntegerLiteralExprSyntax) -> IntegerLiteralExprSyntax { + var formattedText = "" + let (prefix, value) = lit.split() + formattedText += prefix + formattedText += value.byAddingGroupSeparators(at: lit.idealGroupSize) + return + lit + .with(\.literal, lit.literal.with(\.tokenKind, .integerLiteral(formattedText))) + } +} + +extension Substring { + fileprivate func byAddingGroupSeparators(at interval: Int) -> String { + var result = "" + result.reserveCapacity(self.count) + for (i, char) in self.filter({ $0 != "_" }).reversed().enumerated() { + if i > 0 && i % interval == 0 { + result.append("_") + } + result.append(char) + } + return String(result.reversed()) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/CMakeLists.txt b/Sources/SwiftSyntaxCodeActions/CMakeLists.txt index f6da75b5e..6b3365c79 100644 --- a/Sources/SwiftSyntaxCodeActions/CMakeLists.txt +++ b/Sources/SwiftSyntaxCodeActions/CMakeLists.txt @@ -1,14 +1,26 @@ add_library(SwiftSyntaxCodeActions STATIC AddDocumentation.swift AddExplicitEnumRawValues.swift + AddSeparatorsToIntegerLiteral.swift ApplyDeMorganLaw.swift ConvertCommentToDocComment.swift + ConvertComputedPropertyToStored.swift + ConvertComputedPropertyToZeroParameterFunction.swift ConvertIfLetToGuard.swift ConvertIntegerLiteral.swift ConvertJSONToCodableStruct.swift + ConvertStoredPropertyToComputed.swift ConvertStringConcatenationToStringInterpolation.swift + ConvertZeroParameterFunctionToComputedProperty.swift + DeclModifierRemover.swift + FormatRawStringLiteral.swift IndentationRemover.swift + IntegerLiteralUtilities.swift + MigrateToNewIfLetSyntax.swift + OpaqueParameterToGeneric.swift PackageManifestEdits.swift + RemoveRedundantParentheses.swift + RemoveSeparatorsFromIntegerLiteral.swift SyntaxCodeActionProvider.swift SyntaxCodeActions.swift SyntaxRefactoringCodeActionProvider.swift diff --git a/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToStored.swift b/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToStored.swift new file mode 100644 index 000000000..2f4ec10b6 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToStored.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +package import SwiftSyntax +import SwiftSyntaxBuilder + +package struct ConvertComputedPropertyToStored: SyntaxRefactoringProvider { + package static func refactor(syntax: VariableDeclSyntax, in context: ()) throws -> VariableDeclSyntax { + guard syntax.bindings.count == 1, let binding = syntax.bindings.first else { + throw RefactoringNotApplicableError("unsupported variable declaration") + } + + guard let accessorBlock = binding.accessorBlock, + case let .getter(body) = accessorBlock.accessors, !body.isEmpty + else { + throw RefactoringNotApplicableError("getter is missing or empty") + } + + let refactored = { (initializer: InitializerClauseSyntax) -> VariableDeclSyntax in + let newBinding = + binding + .with(\.initializer, initializer) + .with(\.accessorBlock, nil) + + let bindingSpecifier = syntax.bindingSpecifier + .with(\.tokenKind, .keyword(.let)) + + return + syntax + .with(\.bindingSpecifier, bindingSpecifier) + .with(\.bindings, PatternBindingListSyntax([newBinding])) + } + + guard body.count == 1 else { + let closure = ClosureExprSyntax( + leftBrace: accessorBlock.leftBrace, + statements: body, + rightBrace: accessorBlock.rightBrace + ) + + return refactored( + InitializerClauseSyntax( + equal: .equalToken(trailingTrivia: .space), + value: FunctionCallExprSyntax(callee: closure) + ) + ) + } + + guard body.count == 1, let item = body.first?.item else { + throw RefactoringNotApplicableError("getter body is not a single expression") + } + + if let item = item.as(ReturnStmtSyntax.self), let expression = item.expression { + let trailingTrivia: Trivia = expression.leadingTrivia.isEmpty ? .space : [] + return refactored( + InitializerClauseSyntax( + leadingTrivia: accessorBlock.leftBrace.trivia, + equal: .equalToken(trailingTrivia: trailingTrivia), + value: expression, + trailingTrivia: accessorBlock.rightBrace.trivia.droppingTrailingWhitespace + ) + ) + } else if var item = item.as(ExprSyntax.self) { + item.trailingTrivia = item.trailingTrivia.droppingTrailingWhitespace + return refactored( + InitializerClauseSyntax( + equal: .equalToken(trailingTrivia: .space), + value: item, + trailingTrivia: accessorBlock.trailingTrivia + ) + ) + } + + throw RefactoringNotApplicableError("could not extract initial value of stored property") + } +} diff --git a/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToZeroParameterFunction.swift b/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToZeroParameterFunction.swift new file mode 100644 index 000000000..05d8824c4 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/ConvertComputedPropertyToZeroParameterFunction.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +package import SwiftSyntax + +package struct ConvertComputedPropertyToZeroParameterFunction: SyntaxRefactoringProvider { + package static func refactor(syntax: VariableDeclSyntax, in context: Void) throws -> FunctionDeclSyntax { + guard syntax.bindings.count == 1, + let binding = syntax.bindings.first, + let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self) + else { throw RefactoringNotApplicableError("unsupported variable declaration") } + + var statements: CodeBlockItemListSyntax + + guard let typeAnnotation = binding.typeAnnotation, + var accessorBlock = binding.accessorBlock + else { throw RefactoringNotApplicableError("no type annotation or stored") } + + var effectSpecifiers: AccessorEffectSpecifiersSyntax? + + switch accessorBlock.accessors { + case .accessors(let accessors): + guard accessors.count == 1, let accessor = accessors.first, + accessor.accessorSpecifier.tokenKind == .keyword(.get), let codeBlock = accessor.body + else { throw RefactoringNotApplicableError("not a getter-only declaration") } + effectSpecifiers = accessor.effectSpecifiers + statements = codeBlock.statements + let accessorSpecifier = accessor.accessorSpecifier + statements.leadingTrivia = + accessorSpecifier.leadingTrivia + accessorSpecifier.trailingTrivia.droppingLeadingWhitespace + + codeBlock.leftBrace.leadingTrivia.droppingLeadingWhitespace + + codeBlock.leftBrace.trailingTrivia.droppingLeadingWhitespace + + statements.leadingTrivia + statements.trailingTrivia += codeBlock.rightBrace.trivia.droppingLeadingWhitespace + statements.trailingTrivia = statements.trailingTrivia.droppingTrailingWhitespace + case .getter(let codeBlock): + statements = codeBlock + #if RESILIENT_LIBRARIES + @unknown default: + fatalError("Unknown case") + #endif + } + + let returnType = typeAnnotation.type + + var returnClause: ReturnClauseSyntax? + let triviaAfterSignature: Trivia + + if !returnType.isVoid { + triviaAfterSignature = .space + returnClause = ReturnClauseSyntax( + arrow: .arrowToken( + leadingTrivia: typeAnnotation.colon.leadingTrivia, + trailingTrivia: typeAnnotation.colon.trailingTrivia + ), + type: returnType + ) + } else { + triviaAfterSignature = typeAnnotation.colon.leadingTrivia + typeAnnotation.colon.trailingTrivia + } + + accessorBlock.leftBrace.leadingTrivia = accessorBlock.leftBrace.leadingTrivia.droppingLeadingWhitespace + accessorBlock.rightBrace.trailingTrivia = accessorBlock.rightBrace.trailingTrivia.droppingTrailingWhitespace + + let body = CodeBlockSyntax( + leftBrace: accessorBlock.leftBrace, + statements: statements, + rightBrace: accessorBlock.rightBrace + ) + + var parameterClause = FunctionParameterClauseSyntax(parameters: []) + parameterClause.trailingTrivia = identifierPattern.identifier.trailingTrivia + triviaAfterSignature + + let functionEffectSpecifiers = FunctionEffectSpecifiersSyntax( + asyncSpecifier: effectSpecifiers?.asyncSpecifier, + throwsClause: effectSpecifiers?.throwsClause + ) + let functionSignature = FunctionSignatureSyntax( + parameterClause: parameterClause, + effectSpecifiers: functionEffectSpecifiers, + returnClause: returnClause + ) + + return FunctionDeclSyntax( + modifiers: syntax.modifiers, + funcKeyword: .keyword( + .func, + leadingTrivia: syntax.bindingSpecifier.leadingTrivia, + trailingTrivia: syntax.bindingSpecifier.trailingTrivia + ), + name: identifierPattern.identifier.with(\.trailingTrivia, []), + signature: functionSignature, + body: body + ) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/ConvertStoredPropertyToComputed.swift b/Sources/SwiftSyntaxCodeActions/ConvertStoredPropertyToComputed.swift new file mode 100644 index 000000000..338d2c938 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/ConvertStoredPropertyToComputed.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +package import SwiftSyntax +import SwiftSyntaxBuilder + +package struct ConvertStoredPropertyToComputed: SyntaxRefactoringProvider { + package struct Context { + package let type: TypeSyntax? + + package init(type: TypeSyntax? = nil) { + self.type = type + } + } + package static func refactor(syntax: VariableDeclSyntax, in context: Context) throws -> VariableDeclSyntax { + guard syntax.bindings.count == 1, let binding = syntax.bindings.first, let initializer = binding.initializer else { + throw RefactoringNotApplicableError("unsupported variable declaration") + } + + var syntax = syntax + + if let lazyKeyword = syntax.modifiers.first(where: { $0.name.tokenKind == .keyword(.lazy) }) { + syntax = DeclModifierRemover { $0.id == lazyKeyword.id } + .rewrite(syntax) + .cast(VariableDeclSyntax.self) + } + + var codeBlockSyntax: CodeBlockItemListSyntax + + if let functionExpression = initializer.value.as(FunctionCallExprSyntax.self), + let closureExpression = functionExpression.calledExpression.as(ClosureExprSyntax.self) + { + guard functionExpression.arguments.isEmpty else { + throw RefactoringNotApplicableError( + "initializer is a closure that takes arguments" + ) + } + + codeBlockSyntax = closureExpression.statements + codeBlockSyntax.leadingTrivia = + closureExpression.leftBrace.leadingTrivia + closureExpression.leftBrace.trailingTrivia + + codeBlockSyntax.leadingTrivia + codeBlockSyntax.trailingTrivia += + closureExpression.trailingTrivia + closureExpression.rightBrace.leadingTrivia + + closureExpression.rightBrace.trailingTrivia + functionExpression.trailingTrivia + } else { + var body = CodeBlockItemListSyntax([ + CodeBlockItemSyntax( + item: .expr(initializer.value) + ) + ]) + body.leadingTrivia = initializer.equal.trailingTrivia + body.leadingTrivia + body.trailingTrivia += .space + codeBlockSyntax = body + } + let typeAnnotation: TypeAnnotationSyntax? + if let existingType = binding.typeAnnotation { + typeAnnotation = existingType + } else if let providedType = context.type { + typeAnnotation = TypeAnnotationSyntax( + colon: .colonToken(trailingTrivia: .space), + type: providedType + ) + } else { + typeAnnotation = TypeAnnotationSyntax( + colon: .colonToken(trailingTrivia: .space), + type: TypeSyntax(stringLiteral: "<#Type#>") + ) + } + + let newBinding = + binding + .with(\.pattern, binding.pattern.with(\.trailingTrivia, [])) + .with(\.initializer, nil) + .with(\.typeAnnotation, typeAnnotation) + .with( + \.accessorBlock, + AccessorBlockSyntax( + accessors: .getter(codeBlockSyntax) + ) + ) + + let newBindingSpecifier = + syntax.bindingSpecifier + .with(\.tokenKind, .keyword(.var)) + + return + syntax + .with(\.bindingSpecifier, newBindingSpecifier) + .with(\.bindings, PatternBindingListSyntax([newBinding])) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/ConvertZeroParameterFunctionToComputedProperty.swift b/Sources/SwiftSyntaxCodeActions/ConvertZeroParameterFunctionToComputedProperty.swift new file mode 100644 index 000000000..d6c057729 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/ConvertZeroParameterFunctionToComputedProperty.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 +package import SwiftSyntax +import SwiftSyntaxBuilder + +package struct ConvertZeroParameterFunctionToComputedProperty: SyntaxRefactoringProvider { + package static func refactor(syntax: FunctionDeclSyntax, in context: ()) throws -> VariableDeclSyntax { + guard syntax.signature.parameterClause.parameters.isEmpty, + let body = syntax.body + else { throw RefactoringNotApplicableError("not a zero parameter function") } + + let variableName = PatternSyntax( + IdentifierPatternSyntax( + identifier: syntax.name + ) + ) + + let triviaFromParameters = + (syntax.signature.parameterClause.leftParen.trivia + syntax.signature.parameterClause.rightParen.trivia) + .droppingTrailingWhitespace + + var variableType: TypeAnnotationSyntax? + + if let returnClause = syntax.signature.returnClause { + variableType = TypeAnnotationSyntax( + colon: .colonToken( + leadingTrivia: triviaFromParameters + returnClause.arrow.leadingTrivia, + trailingTrivia: returnClause.arrow.trailingTrivia + ), + type: returnClause.type + ) + } else { + variableType = TypeAnnotationSyntax( + colon: .colonToken( + leadingTrivia: triviaFromParameters, + trailingTrivia: .space + ), + type: TypeSyntax("Void").with(\.trailingTrivia, .space) + ) + } + + let accessorEffectSpecifiers: AccessorEffectSpecifiersSyntax? + if let fnEffectSpecifiers = syntax.signature.effectSpecifiers { + accessorEffectSpecifiers = AccessorEffectSpecifiersSyntax( + asyncSpecifier: fnEffectSpecifiers.asyncSpecifier, + throwsClause: fnEffectSpecifiers.throwsClause + ) + } else { + accessorEffectSpecifiers = nil + } + + let indentation = BasicFormat.inferIndentation(of: syntax) ?? .spaces(2) + + let accessorBlock: AccessorBlockSyntax + + if let accessorEffectSpecifiers { + let indentedStatements = body.statements.indented(by: indentation) + let getterBody = CodeBlockSyntax( + leftBrace: body.leftBrace, + statements: indentedStatements, + rightBrace: .rightBraceToken(leadingTrivia: .newline + indentation) + ) + + let getAccessor = AccessorDeclSyntax( + accessorSpecifier: .keyword(.get, trailingTrivia: .space), + effectSpecifiers: accessorEffectSpecifiers, + body: getterBody + ).with(\.leadingTrivia, indentation) + + accessorBlock = AccessorBlockSyntax( + leftBrace: .leftBraceToken(trailingTrivia: .newline), + accessors: .accessors(AccessorDeclListSyntax([getAccessor])), + rightBrace: .rightBraceToken(leadingTrivia: .newline) + ) + } else { + accessorBlock = AccessorBlockSyntax( + leftBrace: body.leftBrace, + accessors: .getter(body.statements), + rightBrace: body.rightBrace + ) + } + + let bindingSpecifier = syntax.funcKeyword.detached.with(\.tokenKind, .keyword(.var)) + + let patternBinding = PatternBindingSyntax( + pattern: variableName, + typeAnnotation: variableType, + accessorBlock: accessorBlock + ) + + return VariableDeclSyntax( + attributes: syntax.attributes, + modifiers: syntax.modifiers, + bindingSpecifier: bindingSpecifier, + bindings: PatternBindingListSyntax([patternBinding]) + ) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/DeclModifierRemover.swift b/Sources/SwiftSyntaxCodeActions/DeclModifierRemover.swift new file mode 100644 index 000000000..dfc366349 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/DeclModifierRemover.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 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 +// +//===----------------------------------------------------------------------===// + +@_spi(RawSyntax) import SwiftSyntax +import SwiftSyntaxBuilder + +final class DeclModifierRemover: SyntaxRewriter { + private let predicate: (DeclModifierSyntax) -> Bool + + private var triviaToAttachToNextToken: Trivia = Trivia() + + /// Initializes a modifier remover with a given predicate to determine which modifiers to remove. + /// + /// - Parameter predicate: A closure that determines whether a given `AttributeSyntax` should be removed. + /// If this closure returns `true` for an attribute, that attribute will be removed. + init(removingWhere predicate: @escaping (DeclModifierSyntax) -> Bool) { + self.predicate = predicate + super.init() + } + + override func visit(_ node: DeclModifierListSyntax) -> DeclModifierListSyntax { + var filteredModifiers: [DeclModifierListSyntax.Element] = [] + + for modifier in node { + guard self.predicate(modifier) else { + filteredModifiers.append(prependAndClearAccumulatedTrivia(to: modifier)) + continue + } + + // Removing modifier before comment leaves space before comment intact — doesn’t merge with following trivia. + let trailingTrivia = Trivia(pieces: modifier.trailingTrivia.trimmingPrefix(while: \.isSpaceOrTab)) + triviaToAttachToNextToken += modifier.leadingTrivia.merging(trailingTrivia) + } + + if !triviaToAttachToNextToken.isEmpty, !filteredModifiers.isEmpty { + filteredModifiers[filteredModifiers.count - 1].trailingTrivia = filteredModifiers[filteredModifiers.count - 1] + .trailingTrivia + .merging(triviaToAttachToNextToken) + triviaToAttachToNextToken = Trivia() + } + + return DeclModifierListSyntax(filteredModifiers) + } + + override func visit(_ token: TokenSyntax) -> TokenSyntax { + return prependAndClearAccumulatedTrivia(to: token) + } + + /// Prepends the accumulated trivia to the given node's leading trivia. + /// + /// To preserve correct formatting after attribute removal, this function reassigns + /// significant trivia accumulated from removed attributes to the provided subsequent node. + /// Once attached, the accumulated trivia is cleared. + /// + /// - Parameter node: The syntax node receiving the accumulated trivia. + /// - Returns: The modified syntax node with the prepended trivia. + private func prependAndClearAccumulatedTrivia(to syntaxNode: T) -> T { + guard !triviaToAttachToNextToken.isEmpty else { return syntaxNode } + defer { triviaToAttachToNextToken = Trivia() } + return syntaxNode.with(\.leadingTrivia, triviaToAttachToNextToken.merging(syntaxNode.leadingTrivia)) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/FormatRawStringLiteral.swift b/Sources/SwiftSyntaxCodeActions/FormatRawStringLiteral.swift new file mode 100644 index 000000000..f0ea72f38 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/FormatRawStringLiteral.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +package import SwiftSyntax + +/// Format a string literal by inserting or removing the appropriate number of +/// raw string `#` delimiters. +/// +/// ## Before +/// +/// ```swift +/// "The # of values is \(count)" +/// "Hello \#(world)" +/// ###"Hello World"### +/// ``` +/// +/// ## After +/// +/// ```swift +/// ##"The # of values is \(count)"## +/// ##"Hello \#(world)"## +/// "Hello World" +/// ``` +package struct FormatRawStringLiteral: SyntaxRefactoringProvider { + package static func refactor(syntax lit: StringLiteralExprSyntax, in context: Void) -> StringLiteralExprSyntax { + var maximumHashes = 0 + for segment in lit.segments { + switch segment { + case .expressionSegment(let expr): + if let rawStringDelimiter = expr.pounds { + // Pick up any delimiters in interpolation segments \#...#(...) + maximumHashes = max(maximumHashes, rawStringDelimiter.text.longestRun(of: "#")) + } + case .stringSegment(let string): + // Find the longest run of # characters in the content of the literal. + maximumHashes = max(maximumHashes, string.content.text.longestRun(of: "#")) + #if RESILIENT_LIBRARIES + @unknown default: + fatalError() + #endif + } + } + + guard maximumHashes > 0 else { + return + lit + .with(\.openingPounds, lit.openingPounds?.with(\.tokenKind, .rawStringPoundDelimiter(""))) + .with(\.closingPounds, lit.closingPounds?.with(\.tokenKind, .rawStringPoundDelimiter(""))) + } + + let delimiters = String(repeating: "#", count: maximumHashes + 1) + return + lit + .with(\.openingPounds, lit.openingPounds?.with(\.tokenKind, .rawStringPoundDelimiter(delimiters))) + .with(\.closingPounds, lit.closingPounds?.with(\.tokenKind, .rawStringPoundDelimiter(delimiters))) + } +} + +extension String { + fileprivate func longestRun(of needle: Character) -> Int { + var longest = 0 + var it = self.makeIterator() + while let c = it.next() { + guard c == needle else { + continue + } + + var localLongest = 1 + while let c = it.next(), c == needle { + localLongest += 1 + continue + } + + longest = max(localLongest, longest) + } + return longest + } +} diff --git a/Sources/SwiftSyntaxCodeActions/IntegerLiteralUtilities.swift b/Sources/SwiftSyntaxCodeActions/IntegerLiteralUtilities.swift new file mode 100644 index 000000000..a6be4e8cd --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/IntegerLiteralUtilities.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +extension IntegerLiteralExprSyntax { + /// Returns an (arbitrarily) "ideal" number of digits that should constitute + /// a separator-delimited "group" in an integer literal. + package var idealGroupSize: Int { + switch self.radix { + case .binary: return 4 + case .octal: return 3 + case .decimal: return 3 + case .hex: return 4 + #if RESILIENT_LIBRARIES + @unknown default: return 3 + #endif + } + } + + /// Split the leading radix prefix from the value part of this integer literal. + /// + /// ``` + /// 10 -> ("", "10") + /// 0xFFFF -> ("0x", "FFFF") + /// 0o77 -> ("0o", "77") + /// 0b1010101 -> ("0b", "1010101") + /// ``` + package func split() -> (prefix: String, value: Substring) { + let text = self.literal.text + let radix = self.radix + let literalPrefix = radix.literalPrefix + return (literalPrefix, text.dropFirst(literalPrefix.count)) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/MigrateToNewIfLetSyntax.swift b/Sources/SwiftSyntaxCodeActions/MigrateToNewIfLetSyntax.swift new file mode 100644 index 000000000..a82a42f14 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/MigrateToNewIfLetSyntax.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftParser +import SwiftRefactor +package import SwiftSyntax + +/// ``MigrateToNewIfLetSyntax`` will visit each if expression in the Syntax tree, and +/// checks if there is an if condition which is of the pre Swift 5.7 "if-let-style" +/// and rewrites it to the new one. +/// +/// - Seealso: https://github.com/apple/swift-evolution/blob/main/proposals/0345-if-let-shorthand.md +/// +/// ## Before +/// +/// ```swift +/// if let foo = foo { +/// // ... +/// } +/// ``` +/// +/// ## After +/// +/// ```swift +/// if let foo { +/// // ... +/// } +package struct MigrateToNewIfLetSyntax: SyntaxRefactoringProvider { + package 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 + var conditionCopy = condition + // Check if the condition is an optional binding ... + if var binding = condition.condition.as(OptionalBindingConditionSyntax.self), + // ... that binds an identifier (and not a tuple) ... + let bindingIdentifier = binding.pattern.as(IdentifierPatternSyntax.self), + // ... and has an initializer that is also an identifier ... + let initializerIdentifier = binding.initializer?.value.as(DeclReferenceExprSyntax.self), + // ... and both sides of the assignment are the same identifiers. + bindingIdentifier.identifier.text == initializerIdentifier.baseName.text + { + // Remove the initializer ... + binding.initializer = nil + // ... and remove whitespace before the comma (in `if` statements with multiple conditions). + if index != node.conditions.count - 1 { + binding.pattern = binding.pattern.with(\.trailingTrivia, []) + } + conditionCopy.condition = .optionalBinding(binding) + } + return conditionCopy + } + return node.with(\.conditions, ConditionElementListSyntax(newConditions)) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/OpaqueParameterToGeneric.swift b/Sources/SwiftSyntaxCodeActions/OpaqueParameterToGeneric.swift new file mode 100644 index 000000000..bb3449525 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/OpaqueParameterToGeneric.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +package import SwiftSyntax + +/// Describes a "some" parameter that has been rewritten into a generic +/// parameter. +private struct RewrittenSome { + let original: SomeOrAnyTypeSyntax + let genericParam: GenericParameterSyntax + let genericParamRef: IdentifierTypeSyntax +} + +/// Rewrite `some` parameters to explicit generic parameters. +/// +/// ## Before +/// +/// ```swift +/// func someFunction(_ input: some Value) {} +/// ``` +/// +/// ## After +/// +/// ```swift +/// func someFunction(_ input: T1) {} +/// ``` +private class SomeParameterRewriter: SyntaxRewriter { + var rewrittenSomeParameters: [RewrittenSome] = [] + + override func visit(_ node: SomeOrAnyTypeSyntax) -> TypeSyntax { + if node.someOrAnySpecifier.text != "some" { + return TypeSyntax(node) + } + + let paramName = "T\(rewrittenSomeParameters.count + 1)" + let paramNameSyntax = TokenSyntax.identifier(paramName) + + let inheritedType: TypeSyntax? + let colon: TokenSyntax? + if node.constraint.description != "Any" { + colon = .colonToken() + inheritedType = node.constraint.with(\.leadingTrivia, .space) + } else { + colon = nil + inheritedType = nil + } + + let genericParam = GenericParameterSyntax( + attributes: [], + specifier: nil, + name: paramNameSyntax, + colon: colon, + inheritedType: inheritedType, + trailingComma: nil + ) + + let genericParamRef = IdentifierTypeSyntax( + name: .identifier(paramName), + genericArgumentClause: nil + ) + + rewrittenSomeParameters.append( + .init( + original: node, + genericParam: genericParam, + genericParamRef: genericParamRef + ) + ) + + return TypeSyntax(genericParamRef) + } + + override func visit(_ node: TupleTypeSyntax) -> TypeSyntax { + let newNode = super.visit(node) + + // If this tuple type is simple parentheses around a replaced "some" + // parameter, drop the parentheses. + guard let newTuple = newNode.as(TupleTypeSyntax.self), + newTuple.elements.count == 1, + let onlyElement = newTuple.elements.first, + onlyElement.firstName == nil, + onlyElement.ellipsis == nil, + let onlyIdentifierType = + onlyElement.type.as(IdentifierTypeSyntax.self), + rewrittenSomeParameters.first( + where: { $0.genericParamRef.name.text == onlyIdentifierType.name.text } + ) != nil + else { + return newNode + } + + return TypeSyntax(onlyIdentifierType) + } +} + +/// Rewrite `some` parameters to explicit generic parameters. +/// +/// ## Before +/// +/// ```swift +/// func someFunction(_ input: some Value) {} +/// ``` +/// +/// ## After +/// +/// ```swift +/// func someFunction(_ input: T1) {} +/// ``` +package struct OpaqueParameterToGeneric: SyntaxRefactoringProvider { + /// Replace all of the "some" parameters in the given parameter clause with + /// freshly-created generic parameters. + /// + /// - Returns: nil if there was nothing to rewrite, or a pair of the + /// rewritten parameters and augmented generic parameter list. + static func replaceSomeParameters( + in params: FunctionParameterClauseSyntax, + augmenting genericParams: GenericParameterClauseSyntax? + ) -> (FunctionParameterClauseSyntax, GenericParameterClauseSyntax)? { + let rewriter = SomeParameterRewriter(viewMode: .sourceAccurate) + let rewrittenParams = rewriter.visit(params.parameters) + + if rewriter.rewrittenSomeParameters.isEmpty { + return nil + } + + var newGenericParams: [GenericParameterSyntax] = [] + if let genericParams { + newGenericParams.append(contentsOf: genericParams.parameters) + } + + for rewritten in rewriter.rewrittenSomeParameters { + let newGenericParam = rewritten.genericParam + + // Add a trailing comma to the prior generic parameter, if there is one. + if let lastNewGenericParam = newGenericParams.last { + newGenericParams[newGenericParams.count - 1] = + lastNewGenericParam.with(\.trailingComma, .commaToken()) + newGenericParams.append(newGenericParam.with(\.leadingTrivia, .space)) + } else { + newGenericParams.append(newGenericParam) + } + } + + let newGenericParamSyntax = GenericParameterListSyntax(newGenericParams) + let newGenericParamClause: GenericParameterClauseSyntax + if let genericParams { + newGenericParamClause = genericParams.with( + \.parameters, + newGenericParamSyntax + ) + } else { + newGenericParamClause = GenericParameterClauseSyntax( + leftAngle: .leftAngleToken(), + parameters: newGenericParamSyntax, + genericWhereClause: nil, + rightAngle: .rightAngleToken() + ) + } + + return ( + params.with(\.parameters, rewrittenParams), + newGenericParamClause + ) + } + + package static func refactor( + syntax decl: DeclSyntax, + in context: Void + ) throws -> DeclSyntax { + // Function declaration. + if let funcSyntax = decl.as(FunctionDeclSyntax.self) { + guard + let (newInput, newGenericParams) = replaceSomeParameters( + in: funcSyntax.signature.parameterClause, + augmenting: funcSyntax.genericParameterClause + ) + else { + throw RefactoringNotApplicableError("found no parameters to rewrite") + } + + return DeclSyntax( + funcSyntax + .with(\.signature, funcSyntax.signature.with(\.parameterClause, newInput)) + .with(\.genericParameterClause, newGenericParams) + ) + } + + // Initializer declaration. + if let initSyntax = decl.as(InitializerDeclSyntax.self) { + guard + let (newInput, newGenericParams) = replaceSomeParameters( + in: initSyntax.signature.parameterClause, + augmenting: initSyntax.genericParameterClause + ) + else { + throw RefactoringNotApplicableError("found no parameters to rewrite") + } + + return DeclSyntax( + initSyntax + .with(\.signature, initSyntax.signature.with(\.parameterClause, newInput)) + .with(\.genericParameterClause, newGenericParams) + ) + } + + // Subscript declaration. + if let subscriptSyntax = decl.as(SubscriptDeclSyntax.self) { + guard + let (newIndices, newGenericParams) = replaceSomeParameters( + in: subscriptSyntax.parameterClause, + augmenting: subscriptSyntax.genericParameterClause + ) + else { + throw RefactoringNotApplicableError("found no parameters to rewrite") + } + + return DeclSyntax( + subscriptSyntax + .with(\.parameterClause, newIndices) + .with(\.genericParameterClause, newGenericParams) + ) + } + + throw RefactoringNotApplicableError("unsupported declaration") + } +} diff --git a/Sources/SwiftSyntaxCodeActions/RemoveRedundantParentheses.swift b/Sources/SwiftSyntaxCodeActions/RemoveRedundantParentheses.swift new file mode 100644 index 000000000..2808b6119 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/RemoveRedundantParentheses.swift @@ -0,0 +1,371 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 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 SwiftRefactor +@_spi(RawSyntax) @_spi(ExperimentalLanguageFeatures) package import SwiftSyntax + +/// Removes redundant parentheses from expressions. +/// +/// Examples: +/// - `((x))` -> `x` +/// - `(x)` -> `x` (where x is a simple expression) +/// +package struct RemoveRedundantParentheses: SyntaxRefactoringProvider { + package static func refactor( + syntax: TupleExprSyntax, + in context: Void + ) throws -> ExprSyntax { + // If the syntax tree has errors, we should not attempt to refactor it. + guard !syntax.hasError else { + throw RefactoringNotApplicableError("syntax has errors") + } + + // Check if the tuple expression has exactly one element and no label. + guard let innerExpr = syntax.elements.singleUnlabeledExpression else { + throw RefactoringNotApplicableError("not a parenthesized expression") + } + + // Case 1: Nested parentheses ((expression)) -> (expression) + // Recursively strip inner parentheses to handle cases like (((x))) -> x + if let innerTuple = innerExpr.as(TupleExprSyntax.self) { + do { + let refactoredInner = try refactor(syntax: innerTuple, in: ()) + return preserveTrivia(from: syntax, to: refactoredInner) + } catch { + // Inner refactoring not applicable (e.g., inner is a multi-element tuple like (x, y)), + // but we can still remove the outer parentheses around the inner tuple. + return preserveTrivia(from: syntax, to: innerExpr) + } + } + + // Case 2: Parentheses around simple expressions + if canRemoveParentheses(tuple: syntax, around: innerExpr) { + return preserveTrivia(from: syntax, to: innerExpr) + } + + // Default: Parentheses are not redundant + throw RefactoringNotApplicableError("parentheses are not redundant") + } + + private static func preserveTrivia(from outer: TupleExprSyntax, to inner: ExprSyntax) -> ExprSyntax { + let leadingTrivia = outer.leftParen.leadingTrivia + .merging(outer.leftParen.trailingTrivia) + .merging(inner.leadingTrivia) + let trailingTrivia = inner.trailingTrivia + .merging(outer.rightParen.leadingTrivia) + .merging(outer.rightParen.trailingTrivia) + return + inner + .with(\.leadingTrivia, leadingTrivia) + .with(\.trailingTrivia, trailingTrivia) + } + + private static func canRemoveParentheses(tuple: TupleExprSyntax, around expr: ExprSyntax) -> Bool { + // Safety Check: Immediately-invoked closures + // If parent is a FunctionCallExprSyntax and inner expr is a closure, it's an immediately invoked closure. + // The parentheses are required for disambiguation: `let x = ({ 1 })()` not `let x = { 1 }()`. + if let parent = tuple.parent, parent.is(FunctionCallExprSyntax.self), expr.is(ClosureExprSyntax.self) { + return false + } + + // Safety Check: Ambiguous Closures + // Closures and trailing closures inside conditions need parentheses to avoid ambiguity. + // e.g. `if ({ true }) == ({ true }) {}` or `if (call { true }) == false {}` + // This applies to if/while/guard (ConditionElementSyntax), repeat-while (RepeatStmtSyntax), + // and where clauses (WhereClauseSyntax). + // It also applies to InitializerClauseSyntax if it is inside a condition (e.g. `if let x = ({...})`). + let isInCondition = isInContext( + tuple, + keyPaths: [ + \ConditionElementSyntax.condition, + \RepeatStmtSyntax.condition, + \WhereClauseSyntax.condition, + ] + ) + + let isInSwitchSubject = isInContext(tuple, keyPaths: [\SwitchExprSyntax.subject]) + let isInForInSequence = isInContext(tuple, keyPaths: [\ForInStmtSyntax.sequence]) + + // Safety Check: Conditions and where clauses + if isInCondition && requiresParenForAmbiguousClosure(expr) { + return false + } + + // Safety Check: Switch subjects + // `switch { true } {}` is invalid; a closure literal must be parenthesized. + if isInSwitchSubject && requiresParenForAmbiguousClosure(expr) { + return false + } + + // Safety Check: for-in sequences + // Trailing closures (or IIFEs) in the sequence position should keep parentheses + // to avoid ambiguity warnings (e.g. `for _ in (call { ... })`). + if isInForInSequence && requiresParenForAmbiguousClosure(expr) { + return false + } + + // Allowlist: Check keyPathInParent to explicitly know that this expression + // occurs in a place where the parentheses are redundant. + if let keyPath = tuple.keyPathInParent { + switch keyPath { + case \ConditionElementSyntax.condition, + \InitializerClauseSyntax.value, + \RepeatStmtSyntax.condition, + \ReturnStmtSyntax.expression, + \SwitchExprSyntax.subject, + \ThrowStmtSyntax.expression: + return true + default: + break + } + } + + // Fallback: Allow if the expression itself is "simple" + guard isSimpleExpression(expr) else { + return false + } + + // Safety Check: Postfix and Binary Precedence + // Expressions like `try`, `await`, `consume`, and `copy` bind looser than postfix and infix expressions. + // e.g., `(try? f()).description` is different from `try? f().description`. + // The former accesses `.description` on the Optional result, the latter on the unwrapped value. + // Similarly, `(try? f()) + 1` is different from `try? f() + 1` (Int? + Int vs Int + Int). + if let parent = tuple.parent, hasTighterBindingThanEffect(parent) { + switch expr.as(ExprSyntaxEnum.self) { + case .tryExpr, .awaitExpr, .unsafeExpr, .consumeExpr, .copyExpr: + return false + default: + break + } + } + + return true + } + + /// Returns true if the node is an expression with higher precedence than effects (try/await/etc). + /// This includes postfix expressions (member access, subscript, call, force unwrap, optional chaining), + /// infix operators, type casting (as/is), and ternary expressions. + private static func hasTighterBindingThanEffect(_ node: Syntax) -> Bool { + if node.is(ExprListSyntax.self) { + return true + } + + guard let expr = node.as(ExprSyntax.self) else { + return false + } + switch expr.as(ExprSyntaxEnum.self) { + // Postfix expressions: member access, subscript, function call, force unwrap, and postfix operators + // These all bind tighter than effect expressions (try/await/etc). + // For member access, since we're a TupleExprSyntax, we are always the base. + case .memberAccessExpr, .subscriptCallExpr, .functionCallExpr, .forceUnwrapExpr, .postfixOperatorExpr: + return true + + case .optionalChainingExpr: + // Optional chaining (?.) binds tighter than effects + return true + + // Infix operators and sequence expressions bind tighter than effects. + // For sequence expressions (before SwiftOperators folding), the parent chain + // is: TupleExpr -> ExprList -> SequenceExpr, e.g., `(try? f()) + 1`. + case .infixOperatorExpr, .sequenceExpr: + return true + + // Type casting operators (as, is) bind tighter than effects. + // Ternary operator also binds tighter than effects. + case .asExpr, .isExpr, .ternaryExpr: + return true + + // All other expression types do not bind tighter than effects + case .arrayExpr, .arrowExpr, .assignmentExpr, .awaitExpr, .binaryOperatorExpr, + .booleanLiteralExpr, .borrowExpr, ._canImportExpr, ._canImportVersionInfo, + .closureExpr, .consumeExpr, .copyExpr, .declReferenceExpr, .dictionaryExpr, + .discardAssignmentExpr, .doExpr, .editorPlaceholderExpr, .floatLiteralExpr, + .genericSpecializationExpr, .ifExpr, .inOutExpr, .integerLiteralExpr, + .keyPathExpr, .macroExpansionExpr, .missingExpr, .nilLiteralExpr, + .packElementExpr, .packExpansionExpr, .patternExpr, .postfixIfConfigExpr, + .prefixOperatorExpr, .regexLiteralExpr, .simpleStringLiteralExpr, + .stringLiteralExpr, .superExpr, .switchExpr, .tryExpr, .tupleExpr, + .typeExpr, .unresolvedAsExpr, .unresolvedIsExpr, .unresolvedTernaryExpr, + .unsafeExpr: + return false + #if RESILIENT_LIBRARIES + @unknown default: + return false + #endif + } + } + + private static func hasTrailingClosure(_ expr: ExprSyntax) -> Bool { + switch expr.as(ExprSyntaxEnum.self) { + case .functionCallExpr(let functionCall): + return functionCall.trailingClosure != nil || !functionCall.additionalTrailingClosures.isEmpty + case .macroExpansionExpr(let macroExpansion): + return macroExpansion.trailingClosure != nil || !macroExpansion.additionalTrailingClosures.isEmpty + case .subscriptCallExpr(let subscriptCall): + return subscriptCall.trailingClosure != nil || !subscriptCall.additionalTrailingClosures.isEmpty + default: + return false + } + } + + private static func requiresParenForAmbiguousClosure(_ expr: ExprSyntax) -> Bool { + expr.is(ClosureExprSyntax.self) + || hasTrailingClosure(expr) + || isImmediatelyInvokedClosure(expr) + } + + private static func isInContext(_ tuple: TupleExprSyntax, keyPaths: [AnyKeyPath]) -> Bool { + return tuple.ancestorOrSelf(mapping: { node in + if let keyPathInParent = node.keyPathInParent, + keyPaths.contains(where: { $0 == keyPathInParent }) + { + return true + } + return nil + }) ?? false + } + + private static func isImmediatelyInvokedClosure(_ expr: ExprSyntax) -> Bool { + guard let functionCall = expr.as(FunctionCallExprSyntax.self) else { + return false + } + if functionCall.calledExpression.is(ClosureExprSyntax.self) { + return true + } + if let tuple = functionCall.calledExpression.as(TupleExprSyntax.self), + tuple.elements.singleUnlabeledExpression?.is(ClosureExprSyntax.self) == true + { + return true + } + return false + } + + /// Checks if a type is simple enough to not require parentheses. + /// Complex types like `any Equatable`, `some P`, or `A & B` need parentheses, e.g. `(any Equatable).self`. + private static func isSimpleType(_ type: TypeSyntax) -> Bool { + switch type.as(TypeSyntaxEnum.self) { + case .arrayType, + .classRestrictionType, + .dictionaryType, + .identifierType, + .implicitlyUnwrappedOptionalType, + .inlineArrayType, + .memberType, + .metatypeType, + .missingType, + .optionalType, + .tupleType: + return true + case .attributedType, // @escaping, @Sendable, etc. + .compositionType, // A & B + .functionType, // (A) -> B + .namedOpaqueReturnType, + .packElementType, + .packExpansionType, + .someOrAnyType, // some P, any P + .suppressedType: // ~Copyable + return false + #if RESILIENT_LIBRARIES + @unknown default: + return false + #endif + } + } + + private static func isSimpleExpression(_ expr: ExprSyntax) -> Bool { + // Allowlist of simple expressions that typically don't depend on precedence + // in a way that requires parentheses when used in most contexts, + // or are self-contained. + switch expr.as(ExprSyntaxEnum.self) { + // Simple expressions that don't require parentheses + case .arrayExpr, + .booleanLiteralExpr, + .closureExpr, + .declReferenceExpr, + .dictionaryExpr, + .floatLiteralExpr, + .forceUnwrapExpr, + .integerLiteralExpr, + .macroExpansionExpr, + .memberAccessExpr, + .nilLiteralExpr, + .optionalChainingExpr, + .regexLiteralExpr, + .simpleStringLiteralExpr, + .stringLiteralExpr, + .subscriptCallExpr, + .superExpr: + return true + + // Types, effects, await, unsafe are simple only if the underlying type is simple + case .typeExpr(let typeExpr): + return isSimpleType(typeExpr.type) + case .awaitExpr(let awaitExpr): + return isSimpleExpression(awaitExpr.expression) + case .unsafeExpr(let unsafeExpr): + return isSimpleExpression(unsafeExpr.expression) + + case .tryExpr(let tryExpr): + // Only try! and try? are simple; regular try is NOT simple + // because it affects precedence (e.g., try (try! foo()).bar() vs try try! foo().bar()) + guard tryExpr.questionOrExclamationMark != nil else { + return false + } + return isSimpleExpression(tryExpr.expression) + case .functionCallExpr(let functionCall): + // A function call is simple enough to remove parentheses around it. + // Immediately-invoked closures need parentheses for disambiguation. + // Without parentheses, `let x = { 1 }()` parses as `let x = { 1 }` followed by `()` as a separate + // statement, rather than calling the closure. With parentheses: `let x = ({ 1 })()` works correctly. + return !functionCall.calledExpression.is(ClosureExprSyntax.self) + + // Complex expressions that are NOT simple + case .arrowExpr, + .asExpr, + .assignmentExpr, + .binaryOperatorExpr, + .borrowExpr, + ._canImportExpr, + ._canImportVersionInfo, + .consumeExpr, + .copyExpr, + .discardAssignmentExpr, + .doExpr, + .editorPlaceholderExpr, + .genericSpecializationExpr, + .ifExpr, + .inOutExpr, + .infixOperatorExpr, + .isExpr, + .keyPathExpr, + .missingExpr, + .packElementExpr, + .packExpansionExpr, + .patternExpr, + .postfixIfConfigExpr, + .postfixOperatorExpr, + .prefixOperatorExpr, + .sequenceExpr, + .switchExpr, + .ternaryExpr, + .tupleExpr, + .unresolvedAsExpr, + .unresolvedIsExpr, + .unresolvedTernaryExpr: + return false + #if RESILIENT_LIBRARIES + @unknown default: + return false + #endif + } + } +} diff --git a/Sources/SwiftSyntaxCodeActions/RemoveSeparatorsFromIntegerLiteral.swift b/Sources/SwiftSyntaxCodeActions/RemoveSeparatorsFromIntegerLiteral.swift new file mode 100644 index 000000000..e51962ccb --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/RemoveSeparatorsFromIntegerLiteral.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +package import SwiftSyntax + +/// Format an integer literal by removing any existing separators. +/// +/// ## Before +/// +/// ```swift +/// 123_456_789 +/// 0xF_FFFF_FFFF +/// ``` +/// ## After +/// +/// ```swift +/// 123456789 +/// 0xFFFFFFFFF +/// ``` +package struct RemoveSeparatorsFromIntegerLiteral: SyntaxRefactoringProvider { + package static func refactor(syntax lit: IntegerLiteralExprSyntax, in context: Void) -> IntegerLiteralExprSyntax { + guard lit.literal.text.contains("_") else { return lit } + let formattedText = lit.literal.text.filter({ $0 != "_" }) + return lit.with(\.literal, lit.literal.with(\.tokenKind, .integerLiteral(formattedText))) + } +} diff --git a/Sources/SwiftSyntaxCodeActions/SyntaxRefactoringCodeActionProvider.swift b/Sources/SwiftSyntaxCodeActions/SyntaxRefactoringCodeActionProvider.swift index 973c37a50..f0f9dc891 100644 --- a/Sources/SwiftSyntaxCodeActions/SyntaxRefactoringCodeActionProvider.swift +++ b/Sources/SwiftSyntaxCodeActions/SyntaxRefactoringCodeActionProvider.swift @@ -109,7 +109,7 @@ extension [SourceEdit] { // MARK: - Helper Extensions -private extension TypeSyntax { +extension TypeSyntax { var isVoid: Bool { switch self.as(TypeSyntaxEnum.self) { case .identifierType(let identifierType) where identifierType.name.text == "Void": @@ -122,6 +122,22 @@ private extension TypeSyntax { } } +extension TokenSyntax { + var trivia: Trivia { + return leadingTrivia + trailingTrivia + } +} + +extension Trivia { + var droppingLeadingWhitespace: Trivia { + return Trivia(pieces: self.drop(while: \.isWhitespace)) + } + + var droppingTrailingWhitespace: Trivia { + return Trivia(pieces: self.reversed().drop(while: \.isWhitespace).reversed()) + } +} + // MARK: Adapters for specific refactoring provides in swift-syntax. extension AddSeparatorsToIntegerLiteral: SyntaxRefactoringCodeActionProvider { diff --git a/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToStoredTests.swift b/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToStoredTests.swift new file mode 100644 index 000000000..1ef1feb0c --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToStoredTests.swift @@ -0,0 +1,163 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class ConvertComputedPropertyToStoredTests: XCTestCase { + func testToStored() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { Color() /* some text */ } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = Color() /* some text */ + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithReturnStatement() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { + return Color() + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = Color() + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithReturnStatementAndTrailingComment() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { + return Color() /* some text */ + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = Color() /* some text */ + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithReturnStatementAndTrailingCommentOnNewLine() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { + return Color() + /* some text */ + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = Color() + /* some text */ + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithMultipleStatementsInAccessor() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { + let color = Color() + return color + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = { + let color = Color() + return color + }() + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithMultipleStatementsInAccessorAndTrailingCommentsOnNewLine() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { + let color = Color() + return color + // returns color + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = { + let color = Color() + return color + // returns color + }() + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoredWithMultipleStatementsInAccessorAndLeadingComments() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color { // returns color + let color = Color() + return color + } + """ + + let expected: DeclSyntax = """ + let defaultColor: Color = { // returns color + let color = Color() + return color + }() + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testToStoreWithSeparatingComments() throws { + let baseline: DeclSyntax = """ + var x: Int { + return + /* One */ 1 + } + """ + + let expected: DeclSyntax = """ + let x: Int = + /* One */ 1 + """ + + try assertRefactorConvert(baseline, expected: expected) + } +} + +private func assertRefactorConvert( + _ callDecl: DeclSyntax, + expected: DeclSyntax?, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertRefactor( + callDecl, + context: (), + provider: ConvertComputedPropertyToStored.self, + expected: expected, + file: file, + line: line + ) +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToZeroParameterFunctionTests.swift b/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToZeroParameterFunctionTests.swift new file mode 100644 index 000000000..fc7ef6cbe --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/ConvertComputedPropertyToZeroParameterFunctionTests.swift @@ -0,0 +1,318 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class ConvertComputedPropertyToZeroParameterFunctionTests: XCTestCase { + func testRefactoringComputedPropertyToFunction() throws { + let baseline: DeclSyntax = """ + var asJSON: String { "" } + """ + + let expected: DeclSyntax = """ + func asJSON() -> String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithVoidToFunction() throws { + let baseline: DeclSyntax = """ + var asJSON: Void { () } + """ + + let expected: DeclSyntax = """ + func asJSON() { () } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithTupleToFunction() throws { + let baseline: DeclSyntax = """ + var asJSON: (String, String) { ("", "") } + """ + + let expected: DeclSyntax = """ + func asJSON() -> (String, String) { ("", "") } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithClosureToFunction() throws { + let baseline: DeclSyntax = """ + var asJSON: () -> Void { {} } + """ + + let expected: DeclSyntax = """ + func asJSON() -> () -> Void { {} } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithClosureToFunction2() throws { + let baseline: DeclSyntax = """ + var asJSON: () -> () { {} } + """ + + let expected: DeclSyntax = """ + func asJSON() -> () -> () { {} } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithVoidToFunctionWithSeparatingComment() throws { + let baseline: DeclSyntax = """ + var asJSON : /*comment*/ Void { () } + """ + + let expected: DeclSyntax = """ + func asJSON() /*comment*/ { () } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToFunctionWithReturnStmt() throws { + let baseline: DeclSyntax = """ + var asJSON: String { return "" } + """ + + let expected: DeclSyntax = """ + func asJSON() -> String { return "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToFunctionWithModifiers() throws { + let baseline: DeclSyntax = """ + static var asJSON: String { "" } + """ + + let expected: DeclSyntax = """ + static func asJSON() -> String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToFunctionWithMultipleStms() throws { + let baseline: DeclSyntax = """ + var asJSON: String { + let builder = JSONBuilder() + return builder.convert() + } + """ + + let expected: DeclSyntax = """ + func asJSON() -> String { + let builder = JSONBuilder() + return builder.convert() + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToFunctionWithCommentsAndIndentations() throws { + let baseline: DeclSyntax = """ + static var asJSON : String { /*comment*/ "" /*comment*/ } + """ + + let expected: DeclSyntax = """ + static func asJSON() -> String { /*comment*/ "" /*comment*/ } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToFunctionWithComments() throws { + let baseline: DeclSyntax = """ + // Comment + public static var asJSON : String { /*comment*/ + /*comment*/ return "String" + // Some documentation + } // Comment + """ + + let expected: DeclSyntax = """ + // Comment + public static func asJSON() -> String { /*comment*/ + /*comment*/ return "String" + // Some documentation + } // Comment + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyToNothing() throws { + let baseline: DeclSyntax = """ + var x: Int { + get { 5 } + set { /*anything */ } + } + """ + try assertRefactorConvert(baseline, expected: nil) + } + + func testRefactoringComputedPropertyWithGetAccessorToFunction() throws { + let baseline: DeclSyntax = """ + var x: Int { + get { 5 } + } + """ + + let expected: DeclSyntax = """ + func x() -> Int { + 5 + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithGetAccessorAndAsyncEffectSpecifierToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { + get async { await someAsyncValue() } + } + + """ + + let expected: DeclSyntax = """ + func foo() async -> Int { + await someAsyncValue() + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithGetAccessorAndThrowsEffectSpecifierToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { + get throws { someAsyncValue() } + } + """ + + let expected: DeclSyntax = """ + func foo() throws -> Int { + someAsyncValue() + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithGetAccessorAndAsyncThrowsEffectSpecifierToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { + get async throws { someAsyncValue() } + } + """ + + let expected: DeclSyntax = """ + func foo() async throws -> Int { + someAsyncValue() + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithLeadingTriviaInBindingToFunction() throws { + let baseline: DeclSyntax = """ + /// Documented behavior + var foo: Int { 0 } + """ + + let expected: DeclSyntax = """ + /// Documented behavior + func foo() -> Int { 0 } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithAccessorCommentsToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { + get /*docs...*/ { 0 } + } + """ + + let expected: DeclSyntax = """ + func foo() -> Int { + /*docs...*/ 0 + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithCommentsInsideAccessorToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { + get { /*docs*/ 0 /*documented*/ } + } + """ + + let expected: DeclSyntax = """ + func foo() -> Int { + /*docs*/ 0 /*documented*/ + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringComputedPropertyWithAccessorMultipleCommentsToFunction() throws { + let baseline: DeclSyntax = """ + var foo: Int { // Leading comments + get /*docs...*/ { 0 } // docs + /*Trailing Comments*/ } + """ + + let expected: DeclSyntax = """ + func foo() -> Int { // Leading comments + /*docs...*/ 0 // docs + /*Trailing Comments*/ } + """ + + try assertRefactorConvert(baseline, expected: expected) + } +} + +private func assertRefactorConvert( + _ callDecl: DeclSyntax, + expected: DeclSyntax?, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertRefactor( + callDecl, + context: (), + provider: ConvertComputedPropertyToZeroParameterFunction.self, + expected: expected, + file: file, + line: line + ) +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/ConvertStoredPropertyToComputedTests.swift b/Tests/SwiftSyntaxCodeActionsTests/ConvertStoredPropertyToComputedTests.swift new file mode 100644 index 000000000..f0d9f7d5d --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/ConvertStoredPropertyToComputedTests.swift @@ -0,0 +1,352 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class ConvertStoredPropertyToComputedTests: XCTestCase { + func testRefactoringStoredPropertyWithInitializer1() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer1AndLeadingComments() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = /* red */ .red + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { /* red */ .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer1AndLeadingComments2() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = + /* red */ .red + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { + /* red */ .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer1AndTrailingComments() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = .red /* red */ + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { .red /* red */ } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer1AndTrailingComments2() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = .red + /* red */ + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { .red + /* red */ } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializerAndComments() throws { + let baseline: DeclSyntax = """ + static /* one */ let defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + static /* one */ var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializerAndCommentsInBinding() throws { + let baseline: DeclSyntax = """ + static let /* binding */ defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + static var /* binding */ defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer2() throws { + let baseline: DeclSyntax = """ + static let defaultColor: Color = Color.red + """ + + let expected: DeclSyntax = """ + static var defaultColor: Color { Color.red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer3() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color = Color.red + """ + + let expected: DeclSyntax = """ + var defaultColor: Color { Color.red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithInitializer4() throws { + let baseline: DeclSyntax = """ + var defaultColor: Color = Color() + """ + + let expected: DeclSyntax = """ + var defaultColor: Color { Color() } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithMultipleStatements() throws { + let baseline: DeclSyntax = """ + var three: Int = { + let one = 1 + let two = 2 + return 1 + 2 + }() + """ + + let expected: DeclSyntax = """ + var three: Int { + let one = 1 + let two = 2 + return 1 + 2 + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithFunctionCallAndArguments() throws { + let baseline: DeclSyntax = """ + let myVar = { value in + return value + }(1) + """ + + try assertRefactorConvert(baseline, expected: nil) + } + + func testRefactoringStoredPropertyWithLazyKeyword() throws { + let baseline: DeclSyntax = """ + lazy var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithModifiers() throws { + let baseline: DeclSyntax = """ + private lazy var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + private var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithModifiers2() throws { + let baseline: DeclSyntax = """ + lazy private var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + private var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithModifiersAndComment() throws { + let baseline: DeclSyntax = """ + lazy /* some comment */ private var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + /* some comment */ private var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithModifiersAndComment2() throws { + let baseline: DeclSyntax = """ + private /* comment */ lazy var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + private /* comment */ var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithModifierAndComment() throws { + let baseline: DeclSyntax = """ + lazy /* comment */ var defaultColor: Color = .red + """ + + let expected: DeclSyntax = """ + /* comment */ var defaultColor: Color { .red } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStructStoredPropertiyWithModifiers() throws { + let baseline: DeclSyntax = """ + struct Foo { + lazy private var defaultColor: Color = .red + } + """ + + let expected: DeclSyntax = """ + struct Foo { + private var defaultColor: Color { .red } + } + """ + + try assertRefactorStructConvert(baseline, expected: expected) + } + + func testRefactoringStructStoredPropertiyWithModifiers2() throws { + let baseline: DeclSyntax = """ + struct Foo { + private + /* comment */ lazy var defaultColor: Color = .red + } + """ + + let expected: DeclSyntax = """ + struct Foo { + private + /* comment */ var defaultColor: Color { .red } + } + """ + + try assertRefactorStructConvert(baseline, expected: expected) + } + + func testRefactoringStructStoredPropertiyWithModifiers3() throws { + let baseline: DeclSyntax = """ + struct Foo { + private /* comment */ + /* another comment */ lazy var defaultColor: Color = .red + } + """ + + let expected: DeclSyntax = """ + struct Foo { + private /* comment */ + /* another comment */ var defaultColor: Color { .red } + } + """ + + try assertRefactorStructConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyMissingTypeAnnotation() throws { + let baseline: DeclSyntax = "var foo = \"abc\"" + let expected: DeclSyntax = "var foo: <#Type#>{ \"abc\" }" + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringStoredPropertyWithTypeAnnotation() throws { + let baseline: DeclSyntax = "var foo = \"abc\"" + let expected: DeclSyntax = "var foo: String{ \"abc\" }" + + let context = ConvertStoredPropertyToComputed.Context(type: TypeSyntax(stringLiteral: "String")) + try assertRefactorConvert(baseline, expected: expected, context: context) + } +} + +private func assertRefactorConvert( + _ callDecl: DeclSyntax, + expected: DeclSyntax?, + context: ConvertStoredPropertyToComputed.Context = .init(), + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertRefactor( + callDecl, + context: context, + provider: ConvertStoredPropertyToComputed.self, + expected: expected, + file: file, + line: line + ) +} + +private func assertRefactorStructConvert( + _ callDecl: DeclSyntax, + expected: DeclSyntax, + file: StaticString = #filePath, + line: UInt = #line +) throws { + + let structCallDecl = try XCTUnwrap(callDecl.as(StructDeclSyntax.self)) + let variable = try XCTUnwrap(structCallDecl.memberBlock.members.first?.decl.as(VariableDeclSyntax.self)) + let refactored = try ConvertStoredPropertyToComputed.refactor( + syntax: variable, + in: ConvertStoredPropertyToComputed.Context() + ) + + let members = MemberBlockItemListSyntax { + MemberBlockItemSyntax(decl: DeclSyntax(refactored)) + } + + let refactoredMemberBlock = structCallDecl.memberBlock.with(\.members, members) + let refactoredStruct = structCallDecl.with(\.memberBlock, refactoredMemberBlock) + assertStringsEqualWithDiff(refactoredStruct.description, expected.description) +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/ConvertZeroParameterFunctionToComputedPropertyTests.swift b/Tests/SwiftSyntaxCodeActionsTests/ConvertZeroParameterFunctionToComputedPropertyTests.swift new file mode 100644 index 000000000..6fce3bd37 --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/ConvertZeroParameterFunctionToComputedPropertyTests.swift @@ -0,0 +1,324 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class ConvertZeroParameterFunctionToComputedPropertyTests: XCTestCase { + func testRefactoringFunctionToComputedProperty() throws { + let baseline: DeclSyntax = """ + func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithVoidType() throws { + let baseline: DeclSyntax = """ + func asJSON() { () } + """ + + let expected: DeclSyntax = """ + var asJSON: Void { () } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithTuple() throws { + let baseline: DeclSyntax = """ + func asJSON() -> (String, String) { ("", "") } + """ + + let expected: DeclSyntax = """ + var asJSON: (String, String) { ("", "") } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithClosure() throws { + let baseline: DeclSyntax = """ + func asJSON() -> () -> Void { {} } + """ + + let expected: DeclSyntax = """ + var asJSON: () -> Void { {} } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithModifiers() throws { + let baseline: DeclSyntax = """ + static func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + static var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithModifiersAndIndentations() throws { + let baseline: DeclSyntax = """ + static func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + static var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithModifiersAndComments() throws { + let baseline: DeclSyntax = """ + static func asJSON() -> String { // comment + /*comment*/ "" /*comment*/ + } // comment + """ + + let expected: DeclSyntax = """ + static var asJSON: String { // comment + /*comment*/ "" /*comment*/ + } // comment + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithReturnStms() throws { + let baseline: DeclSyntax = """ + static func asJSON() -> String { + return "" + } + """ + + let expected: DeclSyntax = """ + static var asJSON: String { + return "" + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithMultipleStms() throws { + let baseline: DeclSyntax = """ + static func asJSON() -> String { + let builder = JSONBuilder() + return builder.convert() + } + """ + + let expected: DeclSyntax = """ + static var asJSON: String { + let builder = JSONBuilder() + return builder.convert() + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyPreservesBlockComment() throws { + let baseline: DeclSyntax = """ + /* Block comment */ + func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + /* Block comment */ + var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyPreservesDocComment() throws { + let baseline: DeclSyntax = """ + /// Documentation comment + public static func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + /// Documentation comment + public static var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + func testRefactoringFunctionToComputedPropertyWithAttributes() throws { + let baseline: DeclSyntax = """ + @available(*, deprecated, message: "Use the property instead") + func asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + @available(*, deprecated, message: "Use the property instead") + var asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testRefactoringFunctionToComputedPropertyWithMidTrivia() throws { + let baseline: DeclSyntax = """ + func /* comment */ asJSON() -> String { "" } + """ + + let expected: DeclSyntax = """ + var /* comment */ asJSON: String { "" } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testAsyncThrowsFunction() throws { + let baseline: DeclSyntax = """ + func foo() async throws -> Int { + try await someCall() + } + """ + + let expected: DeclSyntax = """ + var foo: Int { + get async throws { + try await someCall() + } + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testAsyncOnlyFunction() throws { + let baseline: DeclSyntax = """ + func bar() async -> String { + await getValue() + } + """ + + let expected: DeclSyntax = """ + var bar: String { + get async { + await getValue() + } + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testThrowsOnlyFunction() throws { + let baseline: DeclSyntax = """ + func baz() throws -> Bool { + try riskyOperation() + } + """ + + let expected: DeclSyntax = """ + var baz: Bool { + get throws { + try riskyOperation() + } + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testSynchronousFunction() throws { + let baseline: DeclSyntax = """ + func qux() -> Int { + return 42 + } + """ + + let expected: DeclSyntax = """ + var qux: Int { + return 42 + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testAsyncFunctionWithMultiLineStatement() throws { + let baseline: DeclSyntax = """ + func foo() async { + bar( + 1 + ) + } + """ + + let expected: DeclSyntax = """ + var foo: Void { + get async { + bar( + 1 + ) + } + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } + + func testAsyncThrowsFunctionWithMultipleStatements() throws { + let baseline: DeclSyntax = """ + func complex() async throws -> String { + let x = try await fetch() + let y = process(x) + return y + } + """ + + let expected: DeclSyntax = """ + var complex: String { + get async throws { + let x = try await fetch() + let y = process(x) + return y + } + } + """ + + try assertRefactorConvert(baseline, expected: expected) + } +} + +private func assertRefactorConvert( + _ callDecl: DeclSyntax, + expected: DeclSyntax?, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try assertRefactor( + callDecl, + context: (), + provider: ConvertZeroParameterFunctionToComputedProperty.self, + expected: expected, + file: file, + line: line + ) +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/FormatRawStringLiteralTests.swift b/Tests/SwiftSyntaxCodeActionsTests/FormatRawStringLiteralTests.swift new file mode 100644 index 000000000..d81e6a4e8 --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/FormatRawStringLiteralTests.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftParser +import SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class FormatRawStringLiteralTests: XCTestCase { + func testDelimiterPlacement() throws { + let tests = [ + (#line, literal: #" "Hello World" "#, expectation: #" "Hello World" "#), + (#line, literal: ##" #"Hello World" "##, expectation: #" "Hello World" "#), + (#line, literal: ##" #"Hello World"# "##, expectation: #" "Hello World" "#), + (#line, literal: #####" "####" "#####, expectation: #####" "####" "#####), + (#line, literal: #####" #"####"# "#####, expectation: ######" #####"####"##### "######), + (#line, literal: #####" #"\####(hello)"# "#####, expectation: ######" #####"\####(hello)"##### "######), + ( + #line, literal: #######" #"###### \####(hello) ##"# "#######, + expectation: ########" #######"###### \####(hello) ##"####### "######## + ), + (#line, literal: ########" #######"hello \(world) "####### "########, expectation: #" "hello \(world) " "#), + ] + + for (line, literal, expectation) in tests { + let literal = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: literal)) + let expectation = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: expectation)) + try assertRefactor( + literal, + context: (), + provider: FormatRawStringLiteral.self, + expected: expectation, + line: UInt(line) + ) + } + } +} + +extension StringLiteralExprSyntax { + static func parseWithoutDiagnostics(from source: String) -> StringLiteralExprSyntax? { + var parser = Parser(source) + return ExprSyntax.parse(from: &parser).as(StringLiteralExprSyntax.self) + } +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/IntegerLiteralUtilitiesTests.swift b/Tests/SwiftSyntaxCodeActionsTests/IntegerLiteralUtilitiesTests.swift new file mode 100644 index 000000000..fa93bedad --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/IntegerLiteralUtilitiesTests.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class IntegerLiteralUtilitiesTests: XCTestCase { + func testRadixMatching() { + XCTAssertEqual((ExprSyntax("0b1010101").cast(IntegerLiteralExprSyntax.self)).radix, .binary) + XCTAssertEqual((ExprSyntax("0xFF").cast(IntegerLiteralExprSyntax.self)).radix, .hex) + XCTAssertEqual((ExprSyntax("0o777").cast(IntegerLiteralExprSyntax.self)).radix, .octal) + XCTAssertEqual((ExprSyntax("42").cast(IntegerLiteralExprSyntax.self)).radix, .decimal) + } + + func testSplit() { + XCTAssertEqual((ExprSyntax("0b1010101").cast(IntegerLiteralExprSyntax.self)).split().prefix, "0b") + XCTAssertEqual((ExprSyntax("0xFF").cast(IntegerLiteralExprSyntax.self)).split().prefix, "0x") + XCTAssertEqual((ExprSyntax("0o777").cast(IntegerLiteralExprSyntax.self)).split().prefix, "0o") + XCTAssertEqual((ExprSyntax("42").cast(IntegerLiteralExprSyntax.self)).split().prefix, "") + } +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/MigrateToNewIfLetSyntaxTests.swift b/Tests/SwiftSyntaxCodeActionsTests/MigrateToNewIfLetSyntaxTests.swift new file mode 100644 index 000000000..96d0f297a --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/MigrateToNewIfLetSyntaxTests.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class MigrateToNewIfLetSyntaxTests: XCTestCase { + func testRefactoring() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testIdempotence() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testMultiBinding() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x, var y = y, let z = z {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x, var y, let z {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testMixedBinding() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x, var y = x, let z = y.w {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x, var y = x, let z = y.w {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testConditions() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x + 1, x == x, !x {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x = x + 1, x == x, !x {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testWhitespaceNormalization() throws { + let baselineSyntax: ExprSyntax = """ + if let x = x , let y = y {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x, let y {} + """ + + try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax) + } + + func testIfStmt() throws { + let baselineSyntax: StmtSyntax = """ + if let x = x {} + """ + + let expectedSyntax: ExprSyntax = """ + if let x {} + """ + + let exprStmt = try XCTUnwrap(baselineSyntax.as(ExpressionStmtSyntax.self)) + try assertRefactor( + exprStmt.expression, + context: (), + provider: MigrateToNewIfLetSyntax.self, + expected: expectedSyntax + ) + } +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/OpaqueParameterToGenericTests.swift b/Tests/SwiftSyntaxCodeActionsTests/OpaqueParameterToGenericTests.swift new file mode 100644 index 000000000..6206af2c0 --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/OpaqueParameterToGenericTests.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class OpaqueParameterToGenericTests: XCTestCase { + func testRefactoringFunc() throws { + let baseline: DeclSyntax = """ + func f( + x: some P, + y: [some Hashable & Codable: some Any] + ) -> some Equatable { } + """ + + let expected: DeclSyntax = """ + func f( + x: T1, + y: [T2: T3] + ) -> some Equatable { } + """ + + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) + } + + func testRefactoringInit() throws { + let baseline: DeclSyntax = """ + init( + x: (some P), + y: [some Hashable & Codable: some Any] + ) { } + """ + + let expected: DeclSyntax = """ + init, T2: Hashable & Codable, T3>( + x: T1, + y: [T2: T3] + ) { } + """ + + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) + } + + func testRefactoringSubscript() throws { + let baseline: DeclSyntax = """ + subscript(index: some Hashable) -> String + """ + + let expected: DeclSyntax = """ + subscript(index: T1) -> String + """ + + try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected) + } +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/RefactorTestUtils.swift b/Tests/SwiftSyntaxCodeActionsTests/RefactorTestUtils.swift new file mode 100644 index 000000000..c94a87702 --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/RefactorTestUtils.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 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 SwiftRefactor +import SwiftSyntax +import XCTest + +func assertRefactor( + _ input: some SyntaxProtocol, + context: R.Context, + provider: R.Type, + expected: (some SyntaxProtocol)?, + file: StaticString = #filePath, + line: UInt = #line +) throws { + let typedInput = try XCTUnwrap(input.as(R.Input.self), file: file, line: line) + let typedExpected = try expected.map { try XCTUnwrap($0.as(R.Output.self), file: file, line: line) } + + let refactored = try? R.refactor(syntax: typedInput, in: context) + guard let refactored = refactored else { + if typedExpected != nil { + XCTFail( + """ + Refactoring failed, expected: + \(typedExpected?.description ?? "") + """, + file: file, + line: line + ) + } + return + } + guard let typedExpected = typedExpected else { + XCTFail( + """ + Expected nil result, actual: + \(refactored.description) + """, + file: file, + line: line + ) + return + } + assertStringsEqualWithDiff( + refactored.description, + typedExpected.description, + file: file, + line: line + ) +} + +/// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. +/// +/// - Parameters: +/// - actual: The actual string. +/// - expected: The expected string. +/// - message: An optional description of the failure. +/// - additionalInfo: Additional information about the failed test case that will be printed after the diff +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. +func assertStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) { + if actual == expected { + return + } + failStringsEqualWithDiff( + actual, + expected, + message, + additionalInfo: additionalInfo(), + file: file, + line: line + ) +} + +/// `XCTFail` with `diff`-style output. +func failStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) { + let stringComparison: String + + // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On + // older platforms, fall back to simple string comparison. + if #available(macOS 10.15, *) { + let actualLines = actual.components(separatedBy: .newlines) + let expectedLines = expected.components(separatedBy: .newlines) + + let difference = actualLines.difference(from: expectedLines) + + var result = "" + + var insertions = [Int: String]() + var removals = [Int: String]() + + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var expectedLine = 0 + var actualLine = 0 + + while expectedLine < expectedLines.count || actualLine < actualLines.count { + if let removal = removals[expectedLine] { + result += "–\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + + stringComparison = result + } else { + // Fall back to simple message on platforms that don't support CollectionDifference. + stringComparison = """ + Expected: + \(expected) + + Actual: + \(actual) + """ + } + + var fullMessage = """ + \(message.isEmpty ? "Actual output does not match the expected" : message) + \(stringComparison) + """ + if let additional = additionalInfo() { + fullMessage = """ + \(fullMessage) + \(additional) + """ + } + XCTFail(fullMessage, file: file, line: line) +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/ReformatIntegerLiteralTests.swift b/Tests/SwiftSyntaxCodeActionsTests/ReformatIntegerLiteralTests.swift new file mode 100644 index 000000000..9905f23fc --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/ReformatIntegerLiteralTests.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class ReformatIntegerLiteralTests: XCTestCase { + func testSeparatorPlacement() throws { + let tests: [(Int, literal: ExprSyntax, expectation: ExprSyntax)] = [ + (#line, literal: ExprSyntax("0b101010101"), expectation: ExprSyntax("0b1_0101_0101")), + (#line, literal: ExprSyntax("0xFFFFFFFF"), expectation: ExprSyntax("0xFFFF_FFFF")), + (#line, literal: ExprSyntax("0xFFFFF"), expectation: ExprSyntax("0xF_FFFF")), + (#line, literal: ExprSyntax("0o777777"), expectation: ExprSyntax("0o777_777")), + (#line, literal: ExprSyntax("424242424242"), expectation: ExprSyntax("424_242_424_242")), + (#line, literal: ExprSyntax("100"), expectation: ExprSyntax("100")), + (#line, literal: ExprSyntax("0xF_F_F_F_F_F_F_F"), expectation: ExprSyntax("0xFFFF_FFFF")), + (#line, literal: ExprSyntax("0xFF_F_FF"), expectation: ExprSyntax("0xF_FFFF")), + (#line, literal: ExprSyntax("0o7_77777"), expectation: ExprSyntax("0o777_777")), + (#line, literal: ExprSyntax("4_24242424242"), expectation: ExprSyntax("424_242_424_242")), + ] + + for (line, literal, expectation) in tests { + try assertRefactor( + literal.cast(IntegerLiteralExprSyntax.self), + context: (), + provider: AddSeparatorsToIntegerLiteral.self, + expected: expectation.cast(IntegerLiteralExprSyntax.self), + line: UInt(line) + ) + } + } + + func testSeparatorRemoval() throws { + let tests: [(Int, literal: ExprSyntax, expectation: ExprSyntax)] = [ + (#line, literal: ExprSyntax("0b1_0_1_0_1_0_1_0_1"), expectation: ExprSyntax("0b101010101")), + (#line, literal: ExprSyntax("0xFFF_F_FFFF"), expectation: ExprSyntax("0xFFFFFFFF")), + (#line, literal: ExprSyntax("0xFF_FFF"), expectation: ExprSyntax("0xFFFFF")), + (#line, literal: ExprSyntax("0o777_777"), expectation: ExprSyntax("0o777777")), + (#line, literal: ExprSyntax("424_242_424_242"), expectation: ExprSyntax("424242424242")), + (#line, literal: ExprSyntax("100"), expectation: ExprSyntax("100")), + ] + + for (line, literal, expectation) in tests { + try assertRefactor( + literal.cast(IntegerLiteralExprSyntax.self), + context: (), + provider: RemoveSeparatorsFromIntegerLiteral.self, + expected: expectation.cast(IntegerLiteralExprSyntax.self), + line: UInt(line) + ) + } + } +} diff --git a/Tests/SwiftSyntaxCodeActionsTests/RemoveRedundantParenthesesTests.swift b/Tests/SwiftSyntaxCodeActionsTests/RemoveRedundantParenthesesTests.swift new file mode 100644 index 000000000..1148d24d7 --- /dev/null +++ b/Tests/SwiftSyntaxCodeActionsTests/RemoveRedundantParenthesesTests.swift @@ -0,0 +1,370 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 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 SwiftParser +import SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxCodeActions +import XCTest + +final class RemoveRedundantParenthesesTests: XCTestCase { + + func testRemovesRedundantParentheses() throws { + try assertParenRemoval("((1))", expected: "1") + try assertParenRemoval("((x))", expected: "x") + try assertParenRemoval("((x + y))", expected: "(x + y)") + try assertParenRemoval("(x)", expected: "x") + try assertParenRemoval("(1)", expected: "1") + try assertParenRemoval("(\"s\")", expected: "\"s\"") + try assertParenRemoval("(true)", expected: "true") + try assertParenRemoval("(x.y)", expected: "x.y") + try assertParenRemoval("(f(x))", expected: "f(x)") + try assertParenRemoval("(x[0])", expected: "x[0]") + try assertParenRemoval("([1, 2])", expected: "[1, 2]") + try assertParenRemoval("([:])", expected: "[:]") + try assertParenRemoval("({ x in x })", expected: "{ x in x }") + try assertParenRemoval( + "(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 1))", + expected: "#colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)" + ) + try assertParenRemoval("(try! f())", expected: "try! f()") + try assertParenRemoval("(try? f())", expected: "try? f()") + try assertParenRemoval("(await f())", expected: "await f()") + try assertParenRemoval("(x?.y)", expected: "x?.y") + try assertParenRemoval("(x!)", expected: "x!") + try assertParenRemoval("(nil)", expected: "nil") + } + + func testPreservesNecessaryParentheses() throws { + try assertParenRemoval("(1 + 2)") + try assertParenRemoval("(x as T)") + try assertParenRemoval("(x ? y : z)") + try assertParenRemoval("({ true }())") + // try without ! or ? requires parentheses for precedence + try assertParenRemoval("(try f())") + // await with complex expression requires parentheses + try assertParenRemoval("(await 1 + 2)") + + // Complex called expressions in function calls need parentheses + try assertParenRemoval("(a + b)()") + try assertParenRemoval("(a as! () -> Void)()") + // Outer parentheses should still be removable if the inner one is preserved + try assertParenRemoval("((a + b)())", expected: "(a + b)()") + + // IIFE must keep parentheses around the closure + try assertParenRemoval("({ 1 })()") + } + + func testTupleHandling() throws { + // Nested tuple: outer parens removed, inner tuple preserved + try assertParenRemoval("((x, y))", expected: "(x, y)") + // Single element with trailing comma: treated as parentheses, removed + try assertParenRemoval("(x,)", expected: "x") + // Two-element tuple: preserved + try assertParenRemoval("(x, y)", expected: "(x, y)") + } + + func testPreservesTrivia() throws { + try assertParenRemoval( + "/* a */ (( /* b */ x /* c */ )) /* d */", + expected: "/* a */ /* b */ x /* c */ /* d */" + ) + } + + func testInitializerClauseRemovesParentheses() throws { + // `let x = (a + b)` removes parens because InitializerClauseSyntax context + try assertParenRemoval("let x = (a + b)", expected: "let x = a + b") + try assertParenRemoval("let x = ((1))", expected: "let x = 1") + + // `if let` and `guard let` initializers also remove parentheses + try assertParenRemoval("if let x = (a + b) {}", expected: "if let x = a + b {}") + try assertParenRemoval("if var x = (a + b) {}", expected: "if var x = a + b {}") + try assertParenRemoval("guard let x = (a + b) else {}", expected: "guard let x = a + b else {}") + + // `try f()` is not a "simple expression", but in an initializer clause the parentheses are still redundant. + try assertParenRemoval("let x = (try f())", expected: "let x = try f()") + } + + func testPreservesParenthesesInConditions() throws { + // Closures in conditions need parentheses + try assertParenRemoval("if ({ true }) {}") + try assertParenRemoval("if (call { true }) {}") + try assertParenRemoval("while ({ true }) {}") + try assertParenRemoval("guard (call { true }) else {}") + // Nested in sequence expressions + try assertParenRemoval("if ({ true }) == ({ true }) {}") + // Macro expansions with trailing closures + try assertParenRemoval("if (#macro { true }) == false {}") + // Subscripts with trailing closures + try assertParenRemoval("if (array[0] { true }) == false {}") + + // Complex trailing closures in conditions + try assertParenRemoval("if (call { true }) == false {}") + try assertParenRemoval("if let x: () -> Bool = ({ true }) {}") + + // Immediately-invoked closures in conditions must keep parentheses. + try assertParenRemoval("if ({ true }()) {}") + + // Trivia around parentheses should be preserved when parentheses are required. + try assertParenRemoval( + "if /*a*/ ( /*b*/ { true }() /*c*/ ) /*d*/ {}" + ) + + // Repeat-while conditions with nested or trailing closures + try assertParenRemoval("repeat {} while call(({ true }))") + try assertParenRemoval("repeat {} while (call { true })") + } + + func testPreservesParenthesesForMetatypes() throws { + // e.g., `(any Equatable).self` must not become `any Equatable.self` + try assertParenRemoval("(any Equatable).self") + try assertParenRemoval("(some P).self") + try assertParenRemoval("(A & B).self") + try assertParenRemoval("(any Equatable).Type") + try assertParenRemoval("(some P).Type") + try assertParenRemoval("(A & B).Type") + try assertParenRemoval("(any Equatable).Protocol") + try assertParenRemoval("(A & B).Protocol") + try assertParenRemoval("(@escaping () -> Int).self") + try assertParenRemoval("(T...).self") + + // Simple types allow removing parentheses + try assertParenRemoval("(MyStruct).self", expected: "MyStruct.self") + try assertParenRemoval("(Int).Type", expected: "Int.Type") + try assertParenRemoval("(Double).Protocol", expected: "Double.Protocol") + } + + func testPreservesParenthesesForPostfixExpressions() throws { + // `try?` binds looser than member access. + // `(try? f()).description` operates on `Optional`, while `try? f().description` operates on `T`. + try assertParenRemoval("(try? f()).description") + try assertParenRemoval("(try! f()).description") + + // `try?` also binds looser than optional chaining. + // `(try? f())?.bar` is different from `try? f()?.bar`. + try assertParenRemoval("(try? f())?.bar") + try assertParenRemoval("(try! f())?.bar") + + // `await` also binds looser than member access. + try assertParenRemoval("(await f()).description") + + // `consume` and `copy` also bind looser than member access. + try assertParenRemoval("(consume x).property") + try assertParenRemoval("(copy x).property") + + // Infix operators bind tighter than effects + // `(try? f()) + 1` is `Optional + Int` while `try? f() + 1` is `Int + Int`. + try assertParenRemoval("(try? f()) + 1") + try assertParenRemoval("(try! f()) + 1") + try assertParenRemoval("(await f()) + 1") + + // Type casting binds tighter than effects + // `(try? f()) as Int` is different from `try? f() as Int`. + try assertParenRemoval("(try? f()) as Int") + try assertParenRemoval("(try! f()) as Int") + try assertParenRemoval("(await f()) as Int") + // `is` check + try assertParenRemoval("(try? f()) is Int") + + // Ternary operator binds tighter than effects + // `(try? f()) ? x : y` is different from `try? f() ? x : y`. + try assertParenRemoval("(try? f()) ? x : y") + try assertParenRemoval("(await f()) ? x : y") + + // Force unwrap binds tighter than effects + // `(try? f())!` is different from `try? f()!`. + try assertParenRemoval("(try? f())!") + try assertParenRemoval("(await f())!") + } + + func testPreservesParenthesesForEffectsInConditionsAndStatements() throws { + // Conditions should not drop parentheses that preserve effect binding. + try assertParenRemoval( + "if (try? f()).description == \"x\" {}" + ) + try assertParenRemoval( + "if (await f()).description == \"x\" {}" + ) + + // Return/throw should not drop parentheses that preserve effect binding. + try assertParenRemoval("return (try? f()).description") + try assertParenRemoval("throw (try? f()).description") + + // Switch subject should not drop parentheses that preserve effect binding. + try assertParenRemoval( + "switch (try? f()).description { default: break }" + ) + } + + func testRedundantParenthesesInControlFlow() throws { + // Control flow conditions + try assertParenRemoval("if (x == y) {}", expected: "if x == y {}") + try assertParenRemoval("while (x > 10) {}", expected: "while x > 10 {}") + try assertParenRemoval("guard (x && y) else { return }", expected: "guard x && y else { return }") + try assertParenRemoval("repeat {} while (x || y)", expected: "repeat {} while x || y") + + // Switch statement + try assertParenRemoval("switch (x) { default: break }", expected: "switch x { default: break }") + + // Return and Throw + try assertParenRemoval("return (x + y)", expected: "return x + y") + try assertParenRemoval("throw (e)", expected: "throw e") + + // Compound expressions in conditions + try assertParenRemoval("if (x + y > z) {}", expected: "if x + y > z {}") + + // Multiple conditions + try assertParenRemoval("if (x), (y) {}", expected: "if x, y {}") + } + + func testPreservesParenthesesInSwitchSubject() throws { + // A closure literal as the switch subject requires parentheses. + // `switch { true } {}` is invalid syntax. + try assertParenRemoval( + "switch ({ true }) { default: break }" + ) + + // Trailing closures in switch subjects should keep parentheses to avoid ambiguity warnings. + try assertParenRemoval( + "switch (call { true }) { default: break }" + ) + + // Macro expansions with trailing closures in switch subjects should keep parentheses. + try assertParenRemoval( + "switch (#macro { true }) { default: break }" + ) + + // Subscripts with trailing closures in switch subjects should keep parentheses. + try assertParenRemoval( + "switch (array[0] { true }) { default: break }" + ) + + // Trailing closures inside switch subject expressions should keep parentheses. + try assertParenRemoval( + "switch (call { true }) == false { default: break }" + ) + + // Immediately-invoked closures in switch subjects must keep parentheses. + try assertParenRemoval( + "switch ({ true }()) { default: break }" + ) + } + + func testPreservesParenthesesInForInSequence() throws { + // Trailing closures in for-in sequences should keep parentheses to avoid ambiguity warnings. + try assertParenRemoval( + "for _ in (call { true }) {}" + ) + + // Macro expansions with trailing closures in for-in sequences should keep parentheses. + try assertParenRemoval( + "for _ in (#macro { true }) {}" + ) + + // Subscripts with trailing closures in for-in sequences should keep parentheses. + try assertParenRemoval( + "for _ in (array[0] { true }) {}" + ) + + // Immediately-invoked closures in for-in sequences must keep parentheses. + try assertParenRemoval( + "for _ in ({ true }()) {}" + ) + + // Trivia around parentheses should be preserved when parentheses are required. + try assertParenRemoval( + "for _ in /*a*/ ( /*b*/ call { true } /*c*/ ) /*d*/ {}" + ) + } + + func testPreservesParenthesesInWhereClauses() throws { + // Trailing closures in catch-where clauses should keep parentheses. + try assertParenRemoval( + "do {} catch where (call { true }) { }" + ) + + // Trailing closures in for-in where clauses should keep parentheses. + try assertParenRemoval( + "for _ in [1] where (call { true }) {}" + ) + + // Trivia around parentheses should be preserved when parentheses are required. + try assertParenRemoval( + "for _ in [1] where /*a*/ ( /*b*/ call { true } /*c*/ ) /*d*/ {}" + ) + } + + func testSequenceExpressions() throws { + // Sequence expressions (before SwiftOperators folding) bind tighter than effects. + // In `(try? f()) + 1`, the parentheses must be preserved because the sequence + // expression structure makes `try? f()` the left operand of `+`. + try assertParenRemoval("(try? f()) - 1") + try assertParenRemoval("(try? f()) * 1") + + // Complex sequence expressions + try assertParenRemoval("(try? f()) + g() + h()") + try assertParenRemoval("(await f()) + g()") + } + + func testParenthesesInRepeatWhileBody() throws { + try assertParenRemoval( + "repeat { (x) } while true", + expected: "repeat { x } while true" + ) + } + +} + +// MARK: - Test Helper + +/// Applies `RemoveRedundantParentheses` to all tuple expressions in the input and compares to expected. +/// When `expected` is `nil`, asserts that the input is unchanged. +private func assertParenRemoval( + _ input: String, + expected: String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) throws { + var parser = Parser(input) + let inputSyntax = SourceFileSyntax.parse(from: &parser) + + let rewriter = ParenRemovalRewriter() + let result = rewriter.visit(inputSyntax) + + if let error = rewriter.unexpectedError { + throw error + } + + let resultString = result.description.trimmingCharacters(in: .newlines) + assertStringsEqualWithDiff(resultString, expected ?? input, file: file, line: line) +} + +/// A SyntaxRewriter that applies `RemoveRedundantParentheses` to all tuple expressions. +private class ParenRemovalRewriter: SyntaxRewriter { + var unexpectedError: (any Error)? + + override func visit(_ node: TupleExprSyntax) -> ExprSyntax { + let visited = super.visit(node) + guard let tuple = visited.as(TupleExprSyntax.self) else { + return visited + } + do { + return try RemoveRedundantParentheses.refactor(syntax: tuple, in: ()) + } catch is RefactoringNotApplicableError { + return ExprSyntax(tuple) + } catch { + unexpectedError = error + return ExprSyntax(tuple) + } + } +}