Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Documentation/Refactoring Actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The specific refactorings available depend on what code is selected or where the
| **Add documentation** | Cursor on a function, type, property, or macro declaration |
| **Convert comment to doc comment** | Cursor on a regular comment preceding a declaration |
| **Toggle Test Enabled/Disabled** | Cursor on a Swift Testing `@Test` function or an XCTest test method |
| **Inline Variable** | Cursor on a local `let` variable declaration or one of its usages to replace all references with its initializer |

### Async/Await

Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ var targets: [Target] = [
]
+ swiftSyntaxDependencies([
"SwiftBasicFormat",
"SwiftLexicalLookup",
"SwiftOperators",
"SwiftParser",
"SwiftRefactor",
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftSyntaxCodeActions/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ add_library(SwiftSyntaxCodeActions STATIC
FormatRawStringLiteral.swift
GenerateEnumCaseAccessors.swift
IndentationRemover.swift
InlineVariable.swift
IntegerLiteralUtilities.swift
MigrateToNewIfLetSyntax.swift
OpaqueParameterToGeneric.swift
Expand All @@ -40,6 +41,7 @@ target_link_libraries(SwiftSyntaxCodeActions PRIVATE
SwiftToolsProtocols::SKLogging
SwiftExtensions
SwiftSyntax::SwiftBasicFormat
SwiftSyntax::SwiftLexicalLookup
SwiftSyntax::SwiftOperators
SwiftSyntax::SwiftParser
SwiftSyntax::SwiftRefactor
Expand Down
270 changes: 270 additions & 0 deletions Sources/SwiftSyntaxCodeActions/InlineVariable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
//===----------------------------------------------------------------------===//
//
// 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 Foundation
internal import LanguageServerProtocol
internal import SourceKitLSP
import SwiftBasicFormat
import SwiftExtensions
@_spi(Experimental) import SwiftLexicalLookup
import SwiftParser
import SwiftRefactor
import SwiftSyntax

/// A code action that replaces a local variable with its assigned value at all usage sites.
struct InlineVariable: SyntaxRefactoringCodeActionProvider {
package static let title: String = "Inline variable"

package static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> PatternBindingSyntax? {
guard let node = scope.innermostNodeContainingRange else { return nil }
var current: Syntax? = Syntax(node)
while let syntax = current {
if let binding = syntax.as(PatternBindingSyntax.self) {
guard let variableDecl = binding.parent?.parent?.as(VariableDeclSyntax.self),
variableDecl.bindingSpecifier.tokenKind == .keyword(.let)
else {
return nil
}
return binding
}
if let variableDecl = syntax.as(VariableDeclSyntax.self) {
guard variableDecl.bindingSpecifier.tokenKind == .keyword(.let) else {
return nil
}
return variableDecl.bindings.first
}
if syntax.is(CodeBlockSyntax.self) || syntax.is(MemberBlockSyntax.self) {
return nil
}
current = syntax.parent
}
return nil
}

package static func textRefactor(syntax binding: PatternBindingSyntax, in context: Void) throws -> [SourceEdit] {
guard let variableDecl = binding.parent?.parent?.as(VariableDeclSyntax.self),
let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self),
let initializer = binding.initializer?.value
else {
throw RefactoringNotApplicableError("Could not extract variable information")
}

guard let targetIdentifier = Identifier(identifierPattern.identifier) else {
throw RefactoringNotApplicableError("Could not parse the target identifier")
}

guard let blockScope = variableDecl.parent?.parent?.as(CodeBlockItemListSyntax.self) else {
throw RefactoringNotApplicableError("Could not find the lexical scope for this variable")
}

// Resolve usage sites via SwiftLexicalLookup
let usageSites = findValidUsages(
targetIdentifier: targetIdentifier,
targetBinding: binding,
in: blockScope
)

guard !usageSites.isEmpty else {
throw RefactoringNotApplicableError("Variable is never used")
}

var edits: [SourceEdit] = []
for usageNode in usageSites {
let replacementText = formatReplacement(
initializer: initializer,
usageNode: usageNode
)

// Exclude trailing trivia so range precisely matches the token
let editRange = usageNode.positionAfterSkippingLeadingTrivia..<usageNode.endPositionBeforeTrailingTrivia
edits.append(SourceEdit(range: editRange, replacement: replacementText))
}

// If this is the only variable in the declaration (e.g., `let x = 1`),
// remove the entire code block item.
if variableDecl.bindings.count == 1,
let codeBlockItem = variableDecl.parent?.as(CodeBlockItemSyntax.self)
{
let range = codeBlockItem.position..<codeBlockItem.endPosition
edits.append(SourceEdit(range: range, replacement: ""))
} else {
// If there are multiple bindings (e.g., `let x = 1, y = 2`),
// remove only the target binding and clean up the trailing commas.
var newBindingsArray = Array(variableDecl.bindings)
newBindingsArray.removeAll { $0.id == binding.id }
if !newBindingsArray.isEmpty {
newBindingsArray[0].leadingTrivia = []
newBindingsArray[newBindingsArray.count - 1].trailingComma = nil
}
var newDecl = variableDecl.with(\.bindings, PatternBindingListSyntax(newBindingsArray))
newDecl.leadingTrivia = []
let range = variableDecl.positionAfterSkippingLeadingTrivia..<variableDecl.endPosition
edits.append(SourceEdit(range: range, replacement: newDecl.description))
}

return edits
}

private static func findValidUsages(
targetIdentifier: Identifier,
targetBinding: PatternBindingSyntax,
in scope: CodeBlockItemListSyntax
) -> [DeclReferenceExprSyntax] {
let finder = ReferenceCollector(
targetIdentifier: targetIdentifier,
targetBinding: targetBinding
)
finder.walk(scope)
return finder.candidates
}

static func formatReplacement(
initializer: ExprSyntax,
usageNode: DeclReferenceExprSyntax
) -> String {
if needsParentheses(initializer: initializer, usageNode: usageNode) {
let parenthesized = TupleExprSyntax(
leftParen: .leftParenToken(leadingTrivia: initializer.leadingTrivia),
elements: [
LabeledExprSyntax(
expression:
initializer
.with(\.leadingTrivia, [])
.with(\.trailingTrivia, [])
)
],
rightParen: .rightParenToken(trailingTrivia: initializer.trailingTrivia)
)
return parenthesized.description
}
return initializer.description
}

static func needsParentheses(
initializer: ExprSyntax,
usageNode: DeclReferenceExprSyntax
) -> Bool {
guard let codeBlockItem = usageNode.ancestorOrSelf(mapping: { $0.as(CodeBlockItemSyntax.self) }) else {
return true
}
let scopeText = codeBlockItem.description
let initializerText = initializer.description

let scopeStartOffset = codeBlockItem.position.utf8Offset
let startPos = usageNode.positionAfterSkippingLeadingTrivia.utf8Offset - scopeStartOffset
let endPos = usageNode.endPositionBeforeTrailingTrivia.utf8Offset - scopeStartOffset

let startIdx = scopeText.utf8.index(scopeText.utf8.startIndex, offsetBy: startPos)
let endIdx = scopeText.utf8.index(scopeText.utf8.startIndex, offsetBy: endPos)

var rewrittenText = scopeText
rewrittenText.replaceSubrange(startIdx..<endIdx, with: initializerText)

var parser = Parser(rewrittenText)
let parsedScope = CodeBlockItemSyntax.parse(from: &parser)
if parsedScope.hasError {
return true
}

let targetSpanStart = AbsolutePosition(utf8Offset: startPos)
let targetSpanEnd = AbsolutePosition(utf8Offset: startPos + initializerText.utf8.count)
let targetSpan = targetSpanStart..<targetSpanEnd

return !TargetNodeFinder.containsNode(
in: Syntax(parsedScope),
withSpan: targetSpan,
matching: initializerText
)
}
}

/// A visitor that checks if a syntax node with a matching span and description exists.
private final class TargetNodeFinder: SyntaxAnyVisitor {
let targetSpan: Range<AbsolutePosition>
let targetText: String
var found = false

init(targetSpan: Range<AbsolutePosition>, targetText: String) {
self.targetSpan = targetSpan
self.targetText = targetText
super.init(viewMode: .sourceAccurate)
}

static func containsNode(
in node: Syntax,
withSpan span: Range<AbsolutePosition>,
matching text: String
) -> Bool {
Comment thread
somiljain2006 marked this conversation as resolved.
let finder = TargetNodeFinder(targetSpan: span, targetText: text)
finder.walk(node)
return finder.found
}

override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
if found {
return .skipChildren
}

// Prune subtree traversal if the node does not contain the target span.
guard node.range.contains(targetSpan) else {
return .skipChildren
}

if node.trimmedRange == targetSpan {
if node.trimmedDescription == targetText {
found = true
return .skipChildren
}
}

return .visitChildren
}
}

/// A syntax visitor that finds all references to a specific identifier name
/// and verifies they resolve to the target binding using `SwiftLexicalLookup`.
final class ReferenceCollector: SyntaxVisitor {
let targetIdentifier: Identifier
let targetBinding: PatternBindingSyntax
var candidates: [DeclReferenceExprSyntax] = []

init(targetIdentifier: Identifier, targetBinding: PatternBindingSyntax) {
self.targetIdentifier = targetIdentifier
self.targetBinding = targetBinding
super.init(viewMode: .sourceAccurate)
}

override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind {
guard let nodeIdentifier = Identifier(node.baseName),
nodeIdentifier == targetIdentifier
else {
return .visitChildren
}

let lookupResults = node.lookup(nodeIdentifier)
for result in lookupResults {
switch result {
case .fromScope(_, let names):
let matchesTarget = names.contains { name in
name.syntax.id == targetBinding.pattern.id
}
if matchesTarget {
candidates.append(node)
}
return .visitChildren
default:
continue
}
}
return .visitChildren
}
}
1 change: 1 addition & 0 deletions Sources/SwiftSyntaxCodeActions/SyntaxCodeActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ package let allSyntaxCodeActionProviders: [any SyntaxCodeActionProvider.Type] =
FormatRawStringLiteral.self,
GenerateEnumCaseAsAccessors.self,
GenerateEnumCaseIsAccessors.self,
InlineVariable.self,
MigrateToNewIfLetSyntax.self,
OpaqueParameterToGeneric.self,
RemoveRedundantParentheses.self,
Expand Down
Loading