Skip to content

Commit 58deab0

Browse files
committed
Add a code action to remove unused imports in a source file
The idea is pretty simple: When `MemberImportVisibility` is enabled, we know that imports can only affect the current source file. So, we can just try and remove every single `import` declaration in the file, check if a new error occurred and if not, we can safely remove it.
1 parent 16c60b5 commit 58deab0

File tree

7 files changed

+474
-59
lines changed

7 files changed

+474
-59
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ var targets: [Target] = [
559559
"SKUtilities",
560560
"SourceKitD",
561561
"SourceKitLSP",
562+
"SwiftLanguageService",
562563
"ToolchainRegistry",
563564
.product(name: "IndexStoreDB", package: "indexstore-db"),
564565
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),

Sources/SwiftExtensions/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ set(sources
1515
Platform.swift
1616
Process+terminate.swift
1717
ResultExtensions.swift
18+
RunWithCleanup.swift
1819
Sequence+AsyncMap.swift
1920
Sequence+ContainsAnyIn.swift
2021
Task+WithPriorityChangedHandler.swift
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// Run `body` and always ensure that `cleanup` gets run, independently of whether `body` threw an error or returned a
14+
/// value.
15+
package func run<T>(
16+
_ body: () async throws -> T,
17+
cleanup: () async -> Void
18+
) async throws -> T {
19+
do {
20+
let result = try await body()
21+
await cleanup()
22+
return result
23+
} catch {
24+
await cleanup()
25+
throw error
26+
}
27+
}

Sources/SwiftLanguageService/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ add_library(SwiftLanguageService STATIC
77
CodeActions/ConvertJSONToCodableStruct.swift
88
CodeActions/ConvertStringConcatenationToStringInterpolation.swift
99
CodeActions/PackageManifestEdits.swift
10+
CodeActions/RemoveUnusedImports.swift
1011
CodeActions/SyntaxCodeActionProvider.swift
1112
CodeActions/SyntaxCodeActions.swift
1213
CodeActions/SyntaxRefactoringCodeActionProvider.swift
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import BuildServerIntegration
14+
import Csourcekitd
15+
import Foundation
16+
package import LanguageServerProtocol
17+
import SKLogging
18+
import SourceKitD
19+
import SourceKitLSP
20+
import SwiftExtensions
21+
import SwiftSyntax
22+
23+
package struct RemoveUnusedImportsCommand: SwiftCommand {
24+
package static let identifier: String = "remove.unused.imports.command"
25+
package var title: String = "Remove Unused Imports"
26+
27+
/// The text document related to the refactoring action.
28+
package var textDocument: TextDocumentIdentifier
29+
30+
internal init(textDocument: TextDocumentIdentifier) {
31+
self.textDocument = textDocument
32+
}
33+
34+
package init?(fromLSPDictionary dictionary: [String: LanguageServerProtocol.LSPAny]) {
35+
guard case .dictionary(let documentDict)? = dictionary[CodingKeys.textDocument.stringValue] else {
36+
return nil
37+
}
38+
guard let textDocument = TextDocumentIdentifier(fromLSPDictionary: documentDict) else {
39+
return nil
40+
}
41+
42+
self.init(
43+
textDocument: textDocument
44+
)
45+
}
46+
47+
package func encodeToLSPAny() -> LSPAny {
48+
return .dictionary([
49+
CodingKeys.textDocument.stringValue: textDocument.encodeToLSPAny()
50+
])
51+
}
52+
}
53+
54+
extension SwiftLanguageService {
55+
func retrieveRemoveUnusedImportsCodeAction(_ request: CodeActionRequest) async throws -> [CodeAction] {
56+
let snapshot = try await self.latestSnapshot(for: request.textDocument.uri)
57+
58+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
59+
guard
60+
let node = SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request)?
61+
.innermostNodeContainingRange,
62+
node.findParentOfSelf(ofType: ImportDeclSyntax.self, stoppingIf: { _ in false }) != nil
63+
else {
64+
// Only offer the remove unused imports code action on an import statement.
65+
return []
66+
}
67+
68+
guard let buildSettings = await self.compileCommand(for: request.textDocument.uri, fallbackAfterTimeout: true),
69+
!buildSettings.isFallback,
70+
buildSettings.compilerArgs.contains(["-enable-upcoming-feature", "MemberImportVisibility"])
71+
|| buildSettings.compilerArgs.contains(["-swift-version", "7"])
72+
else {
73+
// We can only offer the remove unused imports code action if `MemberImportVisibility` is enabled because
74+
// otherwise imports of this source file might be necessary for other files to compile.
75+
// We assume that `MemberImportVisibility` will be enabled in Swift version 7.
76+
return []
77+
}
78+
79+
guard
80+
try await !diagnosticReportManager.diagnosticReport(for: snapshot, buildSettings: buildSettings).items
81+
.contains(where: { $0.severity == .error })
82+
else {
83+
// If the source file contains errors, we can't remove unused imports because we can't tell if removing import
84+
// decls would introduce an error in the source file.
85+
return []
86+
}
87+
88+
let command = RemoveUnusedImportsCommand(textDocument: request.textDocument)
89+
return [
90+
CodeAction(
91+
title: command.title,
92+
kind: .sourceOrganizeImports,
93+
diagnostics: nil,
94+
edit: nil,
95+
command: command.asCommand()
96+
)
97+
]
98+
}
99+
100+
func removeUnusedImports(_ command: RemoveUnusedImportsCommand) async throws {
101+
let snapshot = try await self.latestSnapshot(for: command.textDocument.uri)
102+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
103+
guard let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false) else {
104+
throw ResponseError.unknown(
105+
"Cannot remove unused imports because the build settings for the file could not be determined"
106+
)
107+
}
108+
109+
// We need to fake a file path instead of some other URI scheme because the sourcekitd diagnostics request complains
110+
// that the source file is not part of the input files for arbitrary scheme URLs.
111+
let temporaryDocUri = DocumentURI(
112+
filePath: "/sourcekit-lsp-remove-unused-imports/\(UUID().uuidString).swift",
113+
isDirectory: false
114+
)
115+
let patchedCompileCommand = SwiftCompileCommand(
116+
FileBuildSettings(
117+
compilerArguments: compileCommand.compilerArgs,
118+
language: .swift,
119+
isFallback: compileCommand.isFallback
120+
)
121+
.patching(newFile: temporaryDocUri, originalFile: snapshot.uri)
122+
)
123+
124+
func temporaryDocumentHasErrorDiagnostic() async throws -> Bool {
125+
let response = try await self.send(
126+
sourcekitdRequest: \.diagnostics,
127+
sourcekitd.dictionary([
128+
keys.sourceFile: temporaryDocUri.pseudoPath,
129+
keys.compilerArgs: patchedCompileCommand.compilerArgs as [SKDRequestValue],
130+
]),
131+
snapshot: nil
132+
)
133+
guard let diagnostics = (response[sourcekitd.keys.diagnostics] as SKDResponseArray?) else {
134+
return true
135+
}
136+
// swift-format-ignore: ReplaceForEachWithForLoop
137+
// Reference is to `SKDResponseArray.forEach`, not `Array.forEach`.
138+
let hasErrorDiagnostic = !diagnostics.forEach { _, diagnostic in
139+
switch diagnostic[sourcekitd.keys.severity] as sourcekitd_api_uid_t? {
140+
case sourcekitd.values.diagError: return false
141+
case sourcekitd.values.diagWarning: return true
142+
case sourcekitd.values.diagNote: return true
143+
case sourcekitd.values.diagRemark: return true
144+
default: return false
145+
}
146+
}
147+
148+
return hasErrorDiagnostic
149+
}
150+
151+
let openRequest = openDocumentSourcekitdRequest(snapshot: snapshot, compileCommand: patchedCompileCommand)
152+
openRequest.set(sourcekitd.keys.name, to: temporaryDocUri.pseudoPath)
153+
_ = try await self.send(
154+
sourcekitdRequest: \.editorOpen,
155+
openRequest,
156+
snapshot: nil
157+
)
158+
159+
return try await run {
160+
guard try await !temporaryDocumentHasErrorDiagnostic() else {
161+
// If the source file has errors to start with, we can't check if removing an import declaration would introduce
162+
// a new error, give up. This really shouldn't happen anyway because the remove unused imports code action is
163+
// only offered if the source file is free of error.
164+
throw ResponseError.unknown("Failed to remove unused imports because the document currently contains errors")
165+
}
166+
167+
// Only consider import declarations at the top level and ignore ones eg. inside `#if` clauses since those might
168+
// be inactive in the current build configuration and thus we can't reliably check if they are needed.
169+
let importDecls = syntaxTree.statements.compactMap { $0.item.as(ImportDeclSyntax.self) }
170+
171+
var declsToRemove: [ImportDeclSyntax] = []
172+
173+
// Try removing the import decls and see if the file still compiles without syntax errors. Do this in revers order
174+
// of the import declarations so we don't need to adjust offsets of the import decls as we iterate through them.
175+
for importDecl in importDecls.reversed() {
176+
let startOffset = snapshot.utf8Offset(of: snapshot.position(of: importDecl.position))
177+
let endOffset = snapshot.utf8Offset(of: snapshot.position(of: importDecl.endPosition))
178+
let removeImportReq = sourcekitd.dictionary([
179+
keys.name: temporaryDocUri.pseudoPath,
180+
keys.enableSyntaxMap: 0,
181+
keys.enableStructure: 0,
182+
keys.enableDiagnostics: 0,
183+
keys.syntacticOnly: 1,
184+
keys.offset: startOffset,
185+
keys.length: endOffset - startOffset,
186+
keys.sourceText: "",
187+
])
188+
189+
_ = try await self.send(sourcekitdRequest: \.editorReplaceText, removeImportReq, snapshot: nil)
190+
191+
if try await temporaryDocumentHasErrorDiagnostic() {
192+
// The file now has syntax error where it didn't before. Add the import decl back in again.
193+
let addImportReq = sourcekitd.dictionary([
194+
keys.name: temporaryDocUri.pseudoPath,
195+
keys.enableSyntaxMap: 0,
196+
keys.enableStructure: 0,
197+
keys.enableDiagnostics: 0,
198+
keys.syntacticOnly: 1,
199+
keys.offset: startOffset,
200+
keys.length: 0,
201+
keys.sourceText: importDecl.description,
202+
])
203+
_ = try await self.send(sourcekitdRequest: \.editorReplaceText, addImportReq, snapshot: nil)
204+
205+
continue
206+
}
207+
208+
declsToRemove.append(importDecl)
209+
}
210+
211+
guard let sourceKitLSPServer else {
212+
throw ResponseError.unknown("Connection to the editor closed")
213+
}
214+
215+
let edits = declsToRemove.reversed().map { importDecl in
216+
var range = snapshot.range(of: importDecl)
217+
218+
let isAtStartOfFile = importDecl.previousToken(viewMode: .sourceAccurate) == nil
219+
220+
if isAtStartOfFile {
221+
// If this is at the start of the source file, keep its leading trivia since we should consider those as a
222+
// file header instead of belonging to the import decl.
223+
range = snapshot.position(of: importDecl.positionAfterSkippingLeadingTrivia)..<range.upperBound
224+
}
225+
226+
// If we are removing the first import statement in the file and it is followed by a newline (which will belong
227+
// to the next token), remove that newline as well so we are not left with an empty line at the start of the
228+
// source file.
229+
if isAtStartOfFile,
230+
let nextToken = importDecl.nextToken(viewMode: .sourceAccurate),
231+
nextToken.leadingTrivia.first?.isNewline ?? false
232+
{
233+
let nextTokenWillBeRemoved =
234+
nextToken.ancestorOrSelf(mapping: { (node) -> Syntax? in
235+
guard let importDecl = node.as(ImportDeclSyntax.self), declsToRemove.contains(importDecl) else {
236+
return nil
237+
}
238+
return node
239+
}) != nil
240+
if !nextTokenWillBeRemoved {
241+
range = range.lowerBound..<snapshot.position(of: nextToken.position.advanced(by: 1))
242+
}
243+
}
244+
245+
return TextEdit(range: range, newText: "")
246+
}
247+
let applyResponse = try await sourceKitLSPServer.sendRequestToClient(
248+
ApplyEditRequest(
249+
edit: WorkspaceEdit(
250+
changes: [snapshot.uri: edits]
251+
)
252+
)
253+
)
254+
if !applyResponse.applied {
255+
let reason: String
256+
if let failureReason = applyResponse.failureReason {
257+
reason = " reason: \(failureReason)"
258+
} else {
259+
reason = ""
260+
}
261+
logger.error("client refused to apply edit for removing unused imports: \(reason)")
262+
}
263+
} cleanup: {
264+
let req = closeDocumentSourcekitdRequest(uri: temporaryDocUri)
265+
await orLog("Closing temporary sourcekitd document to remove unused imports") {
266+
_ = try await self.send(sourcekitdRequest: \.editorClose, req, snapshot: nil)
267+
}
268+
}
269+
}
270+
}

Sources/SwiftLanguageService/SwiftLanguageService.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ package actor SwiftLanguageService: LanguageService, Sendable {
149149

150150
private var stateChangeHandlers: [(_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void] = []
151151

152-
private let diagnosticReportManager: DiagnosticReportManager
152+
let diagnosticReportManager: DiagnosticReportManager
153153

154154
/// - Note: Implicitly unwrapped optional so we can pass a reference of `self` to `MacroExpansionManager`.
155155
private(set) var macroExpansionManager: MacroExpansionManager! {
@@ -686,11 +686,6 @@ extension SwiftLanguageService {
686686
cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri)
687687

688688
let keys = self.keys
689-
struct Edit {
690-
let offset: Int
691-
let length: Int
692-
let replacement: String
693-
}
694689

695690
for edit in edits {
696691
let req = sourcekitd.dictionary([
@@ -896,14 +891,15 @@ extension SwiftLanguageService {
896891
(retrieveSyntaxCodeActions, nil),
897892
(retrieveRefactorCodeActions, .refactor),
898893
(retrieveQuickFixCodeActions, .quickFix),
894+
(retrieveRemoveUnusedImportsCodeAction, .sourceOrganizeImports),
899895
]
900896
let wantedActionKinds = req.context.only
901-
let providers: [CodeActionProvider] = providersAndKinds.compactMap {
902-
if let wantedActionKinds, let kind = $0.1, !wantedActionKinds.contains(kind) {
897+
let providers: [CodeActionProvider] = providersAndKinds.compactMap { (provider, kind) in
898+
if let wantedActionKinds, let kind = kind, !wantedActionKinds.contains(kind) {
903899
return nil
904900
}
905901

906-
return $0.provider
902+
return provider
907903
}
908904
let codeActionCapabilities = capabilityRegistry.clientCapabilities.textDocument?.codeAction
909905
let codeActions = try await retrieveCodeActions(req, providers: providers)
@@ -1097,6 +1093,8 @@ extension SwiftLanguageService {
10971093
try await semanticRefactoring(command)
10981094
} else if let command = req.swiftCommand(ofType: ExpandMacroCommand.self) {
10991095
try await expandMacro(command)
1096+
} else if let command = req.swiftCommand(ofType: RemoveUnusedImportsCommand.self) {
1097+
try await removeUnusedImports(command)
11001098
} else {
11011099
throw ResponseError.unknown("unknown command \(req.command)")
11021100
}

0 commit comments

Comments
 (0)