From e8d7f28fb70253aa6ca9fd412f54ca90b01413c6 Mon Sep 17 00:00:00 2001 From: William Laverty Date: Sun, 8 Mar 2026 04:06:19 -0700 Subject: [PATCH] Add Remove unused parameter code action Add a syntactic code action that removes a function parameter that is not referenced in the function body and updates call sites within the same file. The action is offered when the cursor is on a parameter in a function declaration and that parameter's local name is not referenced anywhere in the function body. Call site detection matches function calls by name and argument label at the expected position, removing the corresponding argument. Resolves #2513 --- .../CodeActions/RemoveUnusedParameter.swift | 251 ++++++++++++++++++ .../CodeActions/SyntaxCodeActions.swift | 1 + Tests/SourceKitLSPTests/CodeActionTests.swift | 53 ++++ 3 files changed, 305 insertions(+) create mode 100644 Sources/SwiftLanguageService/CodeActions/RemoveUnusedParameter.swift diff --git a/Sources/SwiftLanguageService/CodeActions/RemoveUnusedParameter.swift b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedParameter.swift new file mode 100644 index 000000000..bc8f51082 --- /dev/null +++ b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedParameter.swift @@ -0,0 +1,251 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax + +/// A code action that removes a function parameter that is not used in the +/// function body and updates call sites within the same file. +/// +/// The action is offered when the cursor is on a parameter in a function +/// declaration and that parameter's name is not referenced in the function body. +/// +/// **Before:** +/// ```swift +/// func greet(name: String, title: String) { +/// print("Hello, \(name)") +/// } +/// +/// greet(name: "Alice", title: "Ms.") +/// ``` +/// +/// **After:** +/// ```swift +/// func greet(name: String) { +/// print("Hello, \(name)") +/// } +/// +/// greet(name: "Alice") +/// ``` +struct RemoveUnusedParameter: SyntaxCodeActionProvider { + static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { + // Find the parameter the cursor is on. + guard let paramSyntax = scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: FunctionParameterSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) else { + return [] + } + + // Find the enclosing function declaration. + guard let funcDecl = paramSyntax.findParentOfSelf( + ofType: FunctionDeclSyntax.self, + stoppingIf: { _ in false } + ) else { + return [] + } + + // Get the parameter's local name (the name used in the function body). + let localName = paramSyntax.secondName?.text ?? paramSyntax.firstName.text + + // If the local name is `_`, we can't detect usage — skip. + if localName == "_" { + return [] + } + + // Check if the parameter is used in the function body. + guard let body = funcDecl.body else { + return [] + } + + let collector = ReferenceCounter(variableName: localName) + collector.walk(body) + if collector.count > 0 { + return [] + } + + // The parameter is unused. Build the edit to remove it. + let paramList = funcDecl.signature.parameterClause.parameters + + // Find the index of this parameter. + guard let paramIndex = paramList.firstIndex(where: { $0.id == paramSyntax.id }) else { + return [] + } + + let externalName = paramSyntax.firstName.text + let funcName = funcDecl.name.text + + var textEdits: [TextEdit] = [] + + // Remove the parameter from the declaration. + let paramCount = paramList.count + if paramCount == 1 { + // Only parameter — replace the entire parameter list content with empty. + let startPos = scope.snapshot.position(of: paramSyntax.positionAfterSkippingLeadingTrivia) + let endPos = scope.snapshot.position(of: paramSyntax.endPositionBeforeTrailingTrivia) + textEdits.append(TextEdit(range: startPos.. SyntaxVisitorContinueKind { + if node.baseName.text == variableName && node.argumentNames == nil { + count += 1 + } + return .visitChildren + } +} + +private struct CallSiteEdit { + let start: AbsolutePosition + let end: AbsolutePosition + let replacement: String +} + +private class CallSiteCollector: SyntaxVisitor { + let functionName: String + let parameterExternalName: String + let parameterIndex: Int + var edits: [CallSiteEdit] = [] + + init(functionName: String, parameterExternalName: String, parameterIndex: Int) { + self.functionName = functionName + self.parameterExternalName = parameterExternalName + self.parameterIndex = parameterIndex + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + // Check if this call matches the function name. + guard let callee = node.calledExpression.as(DeclReferenceExprSyntax.self), + callee.baseName.text == functionName + else { + return .visitChildren + } + + let args = node.arguments + let argCount = args.count + + // Try to find the argument by matching the external name at the expected index. + guard parameterIndex < argCount else { + return .visitChildren + } + + let argIndex = args.index(args.startIndex, offsetBy: parameterIndex) + let arg = args[argIndex] + + // Verify the label matches (or both are unlabeled). + let argLabel = arg.label?.text ?? "_" + if argLabel != parameterExternalName { + return .visitChildren + } + + if argCount == 1 { + // Only argument — remove it entirely. + edits.append( + CallSiteEdit( + start: arg.positionAfterSkippingLeadingTrivia, + end: arg.endPositionBeforeTrailingTrivia, + replacement: "" + ) + ) + } else { + let isLast = args.index(after: argIndex) == args.endIndex + + if isLast { + // Remove comma from previous argument and this argument. + let prevIndex = args.index(before: argIndex) + let prevArg = args[prevIndex] + edits.append( + CallSiteEdit( + start: prevArg.endPositionBeforeTrailingTrivia, + end: arg.endPositionBeforeTrailingTrivia, + replacement: "" + ) + ) + } else { + // Remove this argument and its trailing comma/space. + let nextIndex = args.index(after: argIndex) + let nextArg = args[nextIndex] + edits.append( + CallSiteEdit( + start: arg.positionAfterSkippingLeadingTrivia, + end: nextArg.positionAfterSkippingLeadingTrivia, + replacement: "" + ) + ) + } + } + + return .visitChildren + } +} + diff --git a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift index 8330dc826..225200429 100644 --- a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift +++ b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActions.swift @@ -29,6 +29,7 @@ let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = { MigrateToNewIfLetSyntax.self, OpaqueParameterToGeneric.self, RemoveRedundantParentheses.self, + RemoveUnusedParameter.self, RemoveSeparatorsFromIntegerLiteral.self, ] #if !NO_SWIFTPM_DEPENDENCY diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index 839396ca8..f786f8fe7 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -1835,6 +1835,59 @@ final class CodeActionTests: SourceKitLSPTestCase { } } + func testRemoveUnusedParameter() async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) + let uri = DocumentURI(for: .swift, testName: #function) + let positions = testClient.openDocument( + """ + func greet(name: String, 1️⃣title: String) { + print("Hello, \\(name)") + } + + greet(name: "Alice", title: "Ms.") + """, + uri: uri + ) + + let result = try await testClient.send( + CodeActionRequest( + range: Range(positions["1️⃣"]), + context: .init(), + textDocument: TextDocumentIdentifier(uri) + ) + ) + let codeActions = try XCTUnwrap(result?.codeActions) + let removeAction = try XCTUnwrap(codeActions.first { $0.title == "Remove unused parameter 'title'" }) + XCTAssertEqual(removeAction.kind, .refactorRewrite) + + let edits = try XCTUnwrap(removeAction.edit?.changes?[uri]) + // Should have edits for both the declaration and the call site. + XCTAssertGreaterThanOrEqual(edits.count, 2) + } + + func testRemoveUnusedParameterNotOfferedForUsedParam() async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) + let uri = DocumentURI(for: .swift, testName: #function) + let positions = testClient.openDocument( + """ + func greet(1️⃣name: String) { + print("Hello, \\(name)") + } + """, + uri: uri + ) + + let result = try await testClient.send( + CodeActionRequest( + range: Range(positions["1️⃣"]), + context: .init(), + textDocument: TextDocumentIdentifier(uri) + ) + ) + let codeActions = try XCTUnwrap(result?.codeActions) + XCTAssertNil(codeActions.first { $0.title.starts(with: "Remove unused parameter") }) + } + /// Retrieves the code action at a set of markers and asserts that it matches a list of expected code actions. /// /// - Parameters: