From 29a0d8d8626beb67c8aac4078a2014e0797a1844 Mon Sep 17 00:00:00 2001 From: Aman Date: Mon, 2 Mar 2026 19:28:46 +0530 Subject: [PATCH 1/3] added a code action inlineTempVariable --- .../CodeActions/InlineTempVariable.swift | 232 ++++++++++++++++++ .../CodeActions/SyntaxCodeActions.swift | 1 + .../InlineTempVariableTests.swift | 151 ++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift create mode 100644 Tests/SourceKitLSPTests/InlineTempVariableTests.swift diff --git a/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift new file mode 100644 index 000000000..604cb667f --- /dev/null +++ b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift @@ -0,0 +1,232 @@ +//===----------------------------------------------------------------------===// +// +// 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(SourceKitLSP) import LanguageServerProtocol +import SourceKitLSP +import SwiftExtensions +import SwiftSyntax + +/// Inline temp variable: replace a temporary `let` binding with its value at all usage sites, +/// then remove the declaration. +/// +/// ## Before +/// ```swift +/// func example() { +/// let basePrice = item.price +/// let total = basePrice * quantity +/// } +/// ``` +/// +/// ## After +/// ```swift +/// func example() { +/// let total = item.price * quantity +/// } +/// ``` +/// +/// When the inlined value is an expression that may need parentheses for precedence (e.g. `1 + 2` +/// inlined into `basePrice * 3`), parentheses are added: `(1 + 2) * 3`. +@_spi(Testing) public struct InlineTempVariable: SyntaxCodeActionProvider { + static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { + guard let (variableDecl, name, initializer, codeBlock, declItem) = findInlineableBinding(in: scope) else { + return [] + } + + let references = collectReferences(to: name, after: variableDecl.endPosition, in: codeBlock) + guard !references.isEmpty else { + return [] + } + + let snapshot = scope.snapshot + var textEdits: [TextEdit] = [] + + // Replace each reference with the initializer value, adding parentheses when needed for precedence. + for ref in references { + let replacementText = replacementTextForInlining( + initializer: initializer, + at: ref, + in: codeBlock + ) + textEdits.append(TextEdit( + range: snapshot.range(of: ref), + newText: replacementText + )) + } + + // Remove the declaration (the entire code block item so we remove the newline too). + textEdits.append(TextEdit( + range: snapshot.range(of: declItem), + newText: "" + )) + + // Apply edits from end to start so earlier edits don't invalidate positions. + textEdits.sort { $0.range.lowerBound > $1.range.lowerBound } + + return [ + CodeAction( + title: "Inline variable", + kind: .refactorInline, + edit: WorkspaceEdit(changes: [snapshot.uri: textEdits]) + ) + ] + } + + /// Finds a `let name = expr` binding that can be inlined, and its enclosing code block and item. + private static func findInlineableBinding(in scope: SyntaxCodeActionScope) + -> (VariableDeclSyntax, name: String, initializer: ExprSyntax, CodeBlockSyntax, CodeBlockItemSyntax)? { + guard let node = scope.innermostNodeContainingRange else { + return nil + } + + let variableDecl = node.findParentOfSelf( + ofType: VariableDeclSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) + guard let variableDecl, variableDecl.bindingSpecifier.tokenKind == .keyword(.let) else { + return nil + } + + guard let binding = variableDecl.bindings.only, + let pattern = binding.pattern.as(IdentifierPatternSyntax.self), + let initializer = binding.initializer?.value + else { + return nil + } + + let name = pattern.identifier.text + guard !name.isEmpty else { + return nil + } + + guard let codeBlockItem = variableDecl.parent?.as(CodeBlockItemSyntax.self), + let codeBlockItemList = codeBlockItem.parent?.as(CodeBlockItemListSyntax.self), + let codeBlock = codeBlockItemList.parent?.as(CodeBlockSyntax.self) + else { + return nil + } + + return (variableDecl, name, initializer, codeBlock, codeBlockItem) + } + + /// Collects all `DeclReferenceExprSyntax` in `block` that reference `name` and occur after `afterPosition`. + private static func collectReferences( + to name: String, + after afterPosition: AbsolutePosition, + in block: CodeBlockSyntax + ) -> [DeclReferenceExprSyntax] { + let collector = DeclReferenceCollector(name: name, afterPosition: afterPosition) + collector.walk(block) + return collector.references + } + + /// Returns the text to use when inlining `initializer` at the given reference site. + /// Adds parentheses when needed to preserve precedence (e.g. `1 + 2` inlined into `x * 3` → `(1 + 2) * 3`). + private static func replacementTextForInlining( + initializer: ExprSyntax, + at reference: DeclReferenceExprSyntax, + in codeBlock: CodeBlockSyntax + ) -> String { + let needsParens = initializerNeedsParenthesesAtUseSite(initializer, reference: reference) + if needsParens { + return "(\(initializer.trimmed))" + } + return initializer.trimmed.description + } + + /// Returns true if the initializer expression should be wrapped in parentheses when inlined at the reference. + /// This preserves correctness when the initializer contains operators with lower precedence than the context + /// (e.g. inlining `1 + 2` into `basePrice * 3` must yield `(1 + 2) * 3`). + private static func initializerNeedsParenthesesAtUseSite( + _ initializer: ExprSyntax, + reference: DeclReferenceExprSyntax + ) -> Bool { + // Simple expressions (literals, single identifiers, member access) don't need parens. + if !initializer.isCompositeForInlining { + return false + } + + // The reference is used as an operand if its parent is an expression that has multiple children + // (e.g. binary op, function call). In those cases we need parens for the inlined value. + guard let parent = reference.parent else { + return false + } + + if parent.is(InfixOperatorExprSyntax.self) { + return true + } + if parent.is(SequenceExprSyntax.self) { + return true + } + if parent.is(TernaryExprSyntax.self) { + return true + } + if parent.is(FunctionCallExprSyntax.self) { + return true + } + if parent.is(SubscriptCallExprSyntax.self) { + return true + } + if parent.is(AwaitExprSyntax.self) || parent.is(TryExprSyntax.self) { + return true + } + + return false + } +} + +// MARK: - DeclReferenceCollector + +private final class DeclReferenceCollector: SyntaxVisitor { + private let name: String + private let afterPosition: AbsolutePosition + private(set) var references: [DeclReferenceExprSyntax] = [] + + init(name: String, afterPosition: AbsolutePosition) { + self.name = name + self.afterPosition = afterPosition + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { + if node.baseName.text == name, node.position >= afterPosition { + references.append(node) + } + return .visitChildren + } +} + +// MARK: - Helpers + +private extension ExprSyntax { + /// Whether this expression is "composite" for the purpose of inlining: if inlined into another + /// expression, it may need parentheses to preserve meaning (e.g. `1 + 2` in `x * 3`). + var isCompositeForInlining: Bool { + switch self.kind { + case .arrayExpr, .booleanLiteralExpr, .closureExpr, .declReferenceExpr, .dictionaryExpr, + .floatLiteralExpr, .integerLiteralExpr, .nilLiteralExpr, .stringLiteralExpr, .superExpr: + return false + case .memberAccessExpr: + return false + case .tupleExpr: + if let single = self.as(TupleExprSyntax.self)?.elements.only, single.label == nil { + return single.expression.isCompositeForInlining + } + return true + default: + return true + } + } + + var trimmed: ExprSyntax { + self.with(\.leadingTrivia, []).with(\.trailingTrivia, []) + } +} diff --git a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift index 8330dc826..207afd282 100644 --- a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift +++ b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift @@ -19,6 +19,7 @@ let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = { AddDocumentation.self, AddSeparatorsToIntegerLiteral.self, ApplyDeMorganLaw.self, + InlineTempVariable.self, ConvertComputedPropertyToZeroParameterFunction.self, ConvertIfLetToGuard.self, ConvertIntegerLiteral.self, diff --git a/Tests/SourceKitLSPTests/InlineTempVariableTests.swift b/Tests/SourceKitLSPTests/InlineTempVariableTests.swift new file mode 100644 index 000000000..e7823a738 --- /dev/null +++ b/Tests/SourceKitLSPTests/InlineTempVariableTests.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// +// 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 LanguageServerProtocol +import SKTestSupport +import SKUtilities +import SourceKitLSP +import XCTest + +private typealias CodeActionCapabilities = TextDocumentClientCapabilities.CodeAction +private typealias CodeActionLiteralSupport = CodeActionCapabilities.CodeActionLiteralSupport +private typealias CodeActionKindCapabilities = CodeActionLiteralSupport.CodeActionKindValueSet + +private let clientCapabilitiesWithCodeActionSupport: ClientCapabilities = { + var documentCapabilities = TextDocumentClientCapabilities() + var codeActionCapabilities = CodeActionCapabilities() + codeActionCapabilities.codeActionLiteralSupport = .init( + codeActionKind: .init(valueSet: [.refactorInline]) + ) + documentCapabilities.codeAction = codeActionCapabilities + documentCapabilities.completion = .init(completionItem: .init(snippetSupport: true)) + return ClientCapabilities(workspace: nil, textDocument: documentCapabilities) +}() + +final class InlineTempVariableTests: SourceKitLSPTestCase { + private func validateCodeAction( + input: String, + expectedOutput: String?, + title: String = "Inline variable", + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) + let uri = DocumentURI(for: .swift) + let positions = testClient.openDocument(input, uri: uri) + + let range: Range + if input.contains("1️⃣") && input.contains("2️⃣") { + range = positions["1️⃣"].. Date: Mon, 2 Mar 2026 23:59:57 +0530 Subject: [PATCH 2/3] Fixes 001 --- .../CodeActions/InlineTempVariable.swift | 62 +++++++++---------- scripts/build-linux.sh | 19 ++++++ 2 files changed, 50 insertions(+), 31 deletions(-) create mode 100755 scripts/build-linux.sh diff --git a/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift index 604cb667f..b8b3e74c7 100644 --- a/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift +++ b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift @@ -50,16 +50,22 @@ import SwiftSyntax var textEdits: [TextEdit] = [] // Replace each reference with the initializer value, adding parentheses when needed for precedence. + // Preserve the original trailing trivia (eg. spaces before the following operator) so surrounding + // spacing stays unchanged. for ref in references { - let replacementText = replacementTextForInlining( + let token = ref.baseName + let replacementCore = replacementTextForInlining( initializer: initializer, at: ref, in: codeBlock ) - textEdits.append(TextEdit( - range: snapshot.range(of: ref), - newText: replacementText - )) + let replacementText = replacementCore + token.trailingTrivia.description + textEdits.append( + TextEdit( + range: snapshot.range(of: token), + newText: replacementText + ) + ) } // Remove the declaration (the entire code block item so we remove the newline too). @@ -154,29 +160,23 @@ import SwiftSyntax return false } - // The reference is used as an operand if its parent is an expression that has multiple children - // (e.g. binary op, function call). In those cases we need parens for the inlined value. - guard let parent = reference.parent else { - return false - } - - if parent.is(InfixOperatorExprSyntax.self) { - return true - } - if parent.is(SequenceExprSyntax.self) { - return true - } - if parent.is(TernaryExprSyntax.self) { - return true - } - if parent.is(FunctionCallExprSyntax.self) { - return true - } - if parent.is(SubscriptCallExprSyntax.self) { - return true - } - if parent.is(AwaitExprSyntax.self) || parent.is(TryExprSyntax.self) { - return true + // Walk up the ancestor chain: the reference may be nested (e.g. inside LabeledExprSyntax in a tuple), + // so we need to find if we're used as an operand in a binary/sequence expression. + var node: Syntax? = Syntax(reference) + while let n = node { + if n.is(CodeBlockItemSyntax.self) || n.is(CodeBlockSyntax.self) || n.is(MemberBlockItemSyntax.self) { + break + } + if n.is(InfixOperatorExprSyntax.self) || n.is(SequenceExprSyntax.self) { + return true + } + if n.is(TernaryExprSyntax.self) || n.is(FunctionCallExprSyntax.self) || n.is(SubscriptCallExprSyntax.self) { + return true + } + if n.is(AwaitExprSyntax.self) || n.is(TryExprSyntax.self) { + return true + } + node = n.parent } return false @@ -212,9 +212,9 @@ private extension ExprSyntax { var isCompositeForInlining: Bool { switch self.kind { case .arrayExpr, .booleanLiteralExpr, .closureExpr, .declReferenceExpr, .dictionaryExpr, - .floatLiteralExpr, .integerLiteralExpr, .nilLiteralExpr, .stringLiteralExpr, .superExpr: - return false - case .memberAccessExpr: + .floatLiteralExpr, .forceUnwrapExpr, .functionCallExpr, .integerLiteralExpr, .memberAccessExpr, + .nilLiteralExpr, .optionalChainingExpr, .postfixOperatorExpr, .stringLiteralExpr, .superExpr, + .subscriptCallExpr: return false case .tupleExpr: if let single = self.as(TupleExprSyntax.self)?.elements.only, single.label == nil { diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 000000000..2d33e6f6e --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Build sourcekit-lsp on Linux. The indexstore-db dependency needs dispatch and Block +# headers from the Swift toolchain; this script passes the right include paths. +set -e +RUNTIME=$(swift -print-target-info 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['paths']['runtimeLibraryPaths'][0])" 2>/dev/null) +SWIFT_LIB=$(dirname "$RUNTIME") +if [[ -z "$SWIFT_LIB" || ! -d "$SWIFT_LIB" ]]; then + echo "Could not detect Swift runtime library path. Run: swift -print-target-info" + exit 1 +fi +# indexstore-db's Concurrency-Mac.cpp expects and +INCLUDES="-Xcxx -I$SWIFT_LIB -Xcxx -I$SWIFT_LIB/Block" +echo "Using Swift lib path: $SWIFT_LIB" +cd "$(dirname "$0")/.." +if [[ "${1:-}" == "test" ]]; then + swift test $INCLUDES "${@:2}" +else + swift build $INCLUDES "$@" +fi From 20eca9ead2f0d350f0896085ebff2313e12c5470 Mon Sep 17 00:00:00 2001 From: Aman Date: Tue, 10 Mar 2026 02:03:14 +0530 Subject: [PATCH 3/3] SwiftLexicalLookup used --- Package.swift | 1 + .../CodeActions/InlineTempVariable.swift | 31 ++++++++++++++----- scripts/build-linux.sh | 19 ------------ 3 files changed, 24 insertions(+), 27 deletions(-) delete mode 100755 scripts/build-linux.sh diff --git a/Package.swift b/Package.swift index 95ba74f46..db96a810b 100644 --- a/Package.swift +++ b/Package.swift @@ -508,6 +508,7 @@ var targets: [Target] = [ "SwiftBasicFormat", "SwiftDiagnostics", "SwiftIDEUtils", + "SwiftLexicalLookup", "SwiftOperators", "SwiftParser", "SwiftParserDiagnostics", diff --git a/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift index b8b3e74c7..391bbc9f5 100644 --- a/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift +++ b/Sources/SwiftLanguageService/CodeActions/InlineTempVariable.swift @@ -13,6 +13,7 @@ @_spi(SourceKitLSP) import LanguageServerProtocol import SourceKitLSP import SwiftExtensions +import SwiftLexicalLookup import SwiftSyntax /// Inline temp variable: replace a temporary `let` binding with its value at all usage sites, @@ -37,11 +38,11 @@ import SwiftSyntax /// inlined into `basePrice * 3`), parentheses are added: `(1 + 2) * 3`. @_spi(Testing) public struct InlineTempVariable: SyntaxCodeActionProvider { static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { - guard let (variableDecl, name, initializer, codeBlock, declItem) = findInlineableBinding(in: scope) else { + guard let (variableDecl, name, initializer, codeBlock, declItem, declarationPattern) = findInlineableBinding(in: scope) else { return [] } - let references = collectReferences(to: name, after: variableDecl.endPosition, in: codeBlock) + let references = collectReferences(to: name, declaredBy: declarationPattern, after: variableDecl.endPosition, in: codeBlock) guard !references.isEmpty else { return [] } @@ -88,7 +89,7 @@ import SwiftSyntax /// Finds a `let name = expr` binding that can be inlined, and its enclosing code block and item. private static func findInlineableBinding(in scope: SyntaxCodeActionScope) - -> (VariableDeclSyntax, name: String, initializer: ExprSyntax, CodeBlockSyntax, CodeBlockItemSyntax)? { + -> (VariableDeclSyntax, name: String, initializer: ExprSyntax, CodeBlockSyntax, CodeBlockItemSyntax, IdentifierPatternSyntax)? { guard let node = scope.innermostNodeContainingRange else { return nil } @@ -120,18 +121,25 @@ import SwiftSyntax return nil } - return (variableDecl, name, initializer, codeBlock, codeBlockItem) + return (variableDecl, name, initializer, codeBlock, codeBlockItem, pattern) } - /// Collects all `DeclReferenceExprSyntax` in `block` that reference `name` and occur after `afterPosition`. + /// Collects all `DeclReferenceExprSyntax` in `block` that refer to the given declaration (by lexical lookup) + /// and occur after `afterPosition`. Uses SwiftLexicalLookup so shadowing and name lookup rules are respected. private static func collectReferences( to name: String, + declaredBy declarationPattern: IdentifierPatternSyntax, after afterPosition: AbsolutePosition, in block: CodeBlockSyntax ) -> [DeclReferenceExprSyntax] { - let collector = DeclReferenceCollector(name: name, afterPosition: afterPosition) - collector.walk(block) - return collector.references + let candidates = DeclReferenceCollector(name: name, afterPosition: afterPosition).collect(in: block) + let declId = declarationPattern.id + let config = LookupConfig(finishInSequentialScope: true) + return candidates.filter { ref in + guard let identifier = Identifier(ref.baseName) else { return false } + let results = ref.lookup(identifier, with: config) + return results.first?.names.contains(where: { $0.syntax.id == declId }) ?? false + } } /// Returns the text to use when inlining `initializer` at the given reference site. @@ -185,6 +193,8 @@ import SwiftSyntax // MARK: - DeclReferenceCollector +/// Collects DeclReferenceExprSyntax nodes that match the name and occur after the given position. +/// Used only to gather candidates; resolution to the specific declaration is done via SwiftLexicalLookup. private final class DeclReferenceCollector: SyntaxVisitor { private let name: String private let afterPosition: AbsolutePosition @@ -196,6 +206,11 @@ private final class DeclReferenceCollector: SyntaxVisitor { super.init(viewMode: .sourceAccurate) } + func collect(in block: CodeBlockSyntax) -> [DeclReferenceExprSyntax] { + walk(block) + return references + } + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { if node.baseName.text == name, node.position >= afterPosition { references.append(node) diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh deleted file mode 100755 index 2d33e6f6e..000000000 --- a/scripts/build-linux.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Build sourcekit-lsp on Linux. The indexstore-db dependency needs dispatch and Block -# headers from the Swift toolchain; this script passes the right include paths. -set -e -RUNTIME=$(swift -print-target-info 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['paths']['runtimeLibraryPaths'][0])" 2>/dev/null) -SWIFT_LIB=$(dirname "$RUNTIME") -if [[ -z "$SWIFT_LIB" || ! -d "$SWIFT_LIB" ]]; then - echo "Could not detect Swift runtime library path. Run: swift -print-target-info" - exit 1 -fi -# indexstore-db's Concurrency-Mac.cpp expects and -INCLUDES="-Xcxx -I$SWIFT_LIB -Xcxx -I$SWIFT_LIB/Block" -echo "Using Swift lib path: $SWIFT_LIB" -cd "$(dirname "$0")/.." -if [[ "${1:-}" == "test" ]]; then - swift test $INCLUDES "${@:2}" -else - swift build $INCLUDES "$@" -fi