Skip to content

Commit 79964c8

Browse files
authored
Merge pull request #2326 from ahoppen/remove-unused-imports
Add a code action to remove unused imports in a source file
2 parents f5209ee + 4c95750 commit 79964c8

File tree

7 files changed

+486
-59
lines changed

7 files changed

+486
-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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
#if swift(>=6.4)
14+
#warning("Remove this in favor of SE-0493 (Support async calls in defer bodies) when possible")
15+
#endif
16+
/// Run `body` and always ensure that `cleanup` gets run, independently of whether `body` threw an error or returned a
17+
/// value.
18+
package func run<T>(
19+
_ body: () async throws -> T,
20+
cleanup: () async -> Void
21+
) async throws -> T {
22+
do {
23+
let result = try await body()
24+
await cleanup()
25+
return result
26+
} catch {
27+
await cleanup()
28+
throw error
29+
}
30+
}

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: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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+
/// The remove unused imports command tries to remove unnecessary imports in a file on a best-effort basis by deleting
24+
/// imports in reverse source order and seeing if the file still builds. Note that while this works in most cases, there
25+
/// are a few edge cases, in which this isn't correct. We decided that those are rare enough that the benefits of the
26+
/// refactoring action outweigh these potential issues.
27+
///
28+
/// ### 1. Overload resolution changing
29+
///
30+
/// LibA.swift
31+
/// ```swift
32+
/// func foo(_ x: Int) -> Int { "Wrong" }
33+
/// ```
34+
///
35+
/// LibB.swift
36+
/// ```swift
37+
/// func foo(_ x: Double) -> Int { "Correct" }
38+
/// ```
39+
///
40+
/// Test.swift
41+
/// ```swift
42+
/// import LibA
43+
/// import LibB
44+
///
45+
/// print(foo(1.2))
46+
/// ```
47+
///
48+
/// The action will remove the import to LibB because the code still compiles fine without it (we now pick the
49+
/// `foo(_:Int)` overload instead of `foo(_:Double)`). This seems pretty unlikely though.
50+
///
51+
/// ### 2. Loaded extension used by other source file
52+
///
53+
/// Importing a module in this file might make members and conformances available to other source files as well, so just
54+
/// checking the current source file for issues is not technically enough. The former of those issues is fixed by the
55+
/// upcoming `MemberImportVisibility` language feature and importing a module and only using a conformance from it in a
56+
/// different file seems pretty unlikely.
57+
package struct RemoveUnusedImportsCommand: SwiftCommand {
58+
package static let identifier: String = "remove.unused.imports.command"
59+
package var title: String = "Remove Unused Imports"
60+
61+
/// The text document related to the refactoring action.
62+
package var textDocument: TextDocumentIdentifier
63+
64+
internal init(textDocument: TextDocumentIdentifier) {
65+
self.textDocument = textDocument
66+
}
67+
68+
package init?(fromLSPDictionary dictionary: [String: LanguageServerProtocol.LSPAny]) {
69+
guard case .dictionary(let documentDict)? = dictionary[CodingKeys.textDocument.stringValue] else {
70+
return nil
71+
}
72+
guard let textDocument = TextDocumentIdentifier(fromLSPDictionary: documentDict) else {
73+
return nil
74+
}
75+
76+
self.init(
77+
textDocument: textDocument
78+
)
79+
}
80+
81+
package func encodeToLSPAny() -> LSPAny {
82+
return .dictionary([
83+
CodingKeys.textDocument.stringValue: textDocument.encodeToLSPAny()
84+
])
85+
}
86+
}
87+
88+
extension SwiftLanguageService {
89+
func retrieveRemoveUnusedImportsCodeAction(_ request: CodeActionRequest) async throws -> [CodeAction] {
90+
let snapshot = try await self.latestSnapshot(for: request.textDocument.uri)
91+
92+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
93+
guard
94+
let node = SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request)?
95+
.innermostNodeContainingRange,
96+
node.findParentOfSelf(ofType: ImportDeclSyntax.self, stoppingIf: { _ in false }) != nil
97+
else {
98+
// Only offer the remove unused imports code action on an import statement.
99+
return []
100+
}
101+
102+
guard
103+
let buildSettings = await self.compileCommand(for: request.textDocument.uri, fallbackAfterTimeout: true),
104+
!buildSettings.isFallback,
105+
try await !diagnosticReportManager.diagnosticReport(for: snapshot, buildSettings: buildSettings).items
106+
.contains(where: { $0.severity == .error })
107+
else {
108+
// If the source file contains errors, we can't remove unused imports because we can't tell if removing import
109+
// decls would introduce an error in the source file.
110+
return []
111+
}
112+
113+
let command = RemoveUnusedImportsCommand(textDocument: request.textDocument)
114+
return [
115+
CodeAction(
116+
title: command.title,
117+
kind: .sourceOrganizeImports,
118+
diagnostics: nil,
119+
edit: nil,
120+
command: command.asCommand()
121+
)
122+
]
123+
}
124+
125+
func removeUnusedImports(_ command: RemoveUnusedImportsCommand) async throws {
126+
let snapshot = try await self.latestSnapshot(for: command.textDocument.uri)
127+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
128+
guard let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false) else {
129+
throw ResponseError.unknown(
130+
"Cannot remove unused imports because the build settings for the file could not be determined"
131+
)
132+
}
133+
134+
// We need to fake a file path instead of some other URI scheme because the sourcekitd diagnostics request complains
135+
// that the source file is not part of the input files for arbitrary scheme URLs.
136+
// https://github.com/swiftlang/swift/issues/85003
137+
#if os(Windows)
138+
let temporaryDocUri = DocumentURI(
139+
filePath: #"C:\sourcekit-lsp-remove-unused-imports\\#(UUID().uuidString).swift"#,
140+
isDirectory: false
141+
)
142+
#else
143+
let temporaryDocUri = DocumentURI(
144+
filePath: "/sourcekit-lsp-remove-unused-imports/\(UUID().uuidString).swift",
145+
isDirectory: false
146+
)
147+
#endif
148+
let patchedCompileCommand = SwiftCompileCommand(
149+
FileBuildSettings(
150+
compilerArguments: compileCommand.compilerArgs,
151+
language: .swift,
152+
isFallback: compileCommand.isFallback
153+
)
154+
.patching(newFile: temporaryDocUri, originalFile: snapshot.uri)
155+
)
156+
157+
func temporaryDocumentHasErrorDiagnostic() async throws -> Bool {
158+
let response = try await self.send(
159+
sourcekitdRequest: \.diagnostics,
160+
sourcekitd.dictionary([
161+
keys.sourceFile: temporaryDocUri.pseudoPath,
162+
keys.compilerArgs: patchedCompileCommand.compilerArgs as [SKDRequestValue],
163+
]),
164+
snapshot: nil
165+
)
166+
guard let diagnostics = (response[sourcekitd.keys.diagnostics] as SKDResponseArray?) else {
167+
return true
168+
}
169+
// swift-format-ignore: ReplaceForEachWithForLoop
170+
// Reference is to `SKDResponseArray.forEach`, not `Array.forEach`.
171+
let hasErrorDiagnostic = !diagnostics.forEach { _, diagnostic in
172+
switch diagnostic[sourcekitd.keys.severity] as sourcekitd_api_uid_t? {
173+
case sourcekitd.values.diagError: return false
174+
case sourcekitd.values.diagWarning: return true
175+
case sourcekitd.values.diagNote: return true
176+
case sourcekitd.values.diagRemark: return true
177+
default: return false
178+
}
179+
}
180+
181+
return hasErrorDiagnostic
182+
}
183+
184+
let openRequest = openDocumentSourcekitdRequest(snapshot: snapshot, compileCommand: patchedCompileCommand)
185+
openRequest.set(sourcekitd.keys.name, to: temporaryDocUri.pseudoPath)
186+
_ = try await self.send(
187+
sourcekitdRequest: \.editorOpen,
188+
openRequest,
189+
snapshot: nil
190+
)
191+
192+
return try await run {
193+
guard try await !temporaryDocumentHasErrorDiagnostic() else {
194+
// If the source file has errors to start with, we can't check if removing an import declaration would introduce
195+
// a new error, give up. This really shouldn't happen anyway because the remove unused imports code action is
196+
// only offered if the source file is free of error.
197+
throw ResponseError.unknown("Failed to remove unused imports because the document currently contains errors")
198+
}
199+
200+
// Only consider import declarations at the top level and ignore ones eg. inside `#if` clauses since those might
201+
// be inactive in the current build configuration and thus we can't reliably check if they are needed.
202+
let importDecls = syntaxTree.statements.compactMap { $0.item.as(ImportDeclSyntax.self) }
203+
204+
var declsToRemove: [ImportDeclSyntax] = []
205+
206+
// Try removing the import decls and see if the file still compiles without syntax errors. Do this in reverse
207+
// order of the import declarations so we don't need to adjust offsets of the import decls as we iterate through
208+
// them.
209+
for importDecl in importDecls.reversed() {
210+
let startOffset = snapshot.utf8Offset(of: snapshot.position(of: importDecl.position))
211+
let endOffset = snapshot.utf8Offset(of: snapshot.position(of: importDecl.endPosition))
212+
let removeImportReq = sourcekitd.dictionary([
213+
keys.name: temporaryDocUri.pseudoPath,
214+
keys.enableSyntaxMap: 0,
215+
keys.enableStructure: 0,
216+
keys.enableDiagnostics: 0,
217+
keys.syntacticOnly: 1,
218+
keys.offset: startOffset,
219+
keys.length: endOffset - startOffset,
220+
keys.sourceText: "",
221+
])
222+
223+
_ = try await self.send(sourcekitdRequest: \.editorReplaceText, removeImportReq, snapshot: nil)
224+
225+
if try await temporaryDocumentHasErrorDiagnostic() {
226+
// The file now has syntax error where it didn't before. Add the import decl back in again.
227+
let addImportReq = sourcekitd.dictionary([
228+
keys.name: temporaryDocUri.pseudoPath,
229+
keys.enableSyntaxMap: 0,
230+
keys.enableStructure: 0,
231+
keys.enableDiagnostics: 0,
232+
keys.syntacticOnly: 1,
233+
keys.offset: startOffset,
234+
keys.length: 0,
235+
keys.sourceText: importDecl.description,
236+
])
237+
_ = try await self.send(sourcekitdRequest: \.editorReplaceText, addImportReq, snapshot: nil)
238+
239+
continue
240+
}
241+
242+
declsToRemove.append(importDecl)
243+
}
244+
245+
guard let sourceKitLSPServer else {
246+
throw ResponseError.unknown("Connection to the editor closed")
247+
}
248+
249+
let edits = declsToRemove.reversed().map { importDecl in
250+
var range = snapshot.range(of: importDecl)
251+
252+
let isAtStartOfFile = importDecl.previousToken(viewMode: .sourceAccurate) == nil
253+
254+
if isAtStartOfFile {
255+
// If this is at the start of the source file, keep its leading trivia since we should consider those as a
256+
// file header instead of belonging to the import decl.
257+
range = snapshot.position(of: importDecl.positionAfterSkippingLeadingTrivia)..<range.upperBound
258+
}
259+
260+
// If we are removing the first import statement in the file and it is followed by a newline (which will belong
261+
// to the next token), remove that newline as well so we are not left with an empty line at the start of the
262+
// source file.
263+
if isAtStartOfFile,
264+
let nextToken = importDecl.nextToken(viewMode: .sourceAccurate),
265+
nextToken.leadingTrivia.first?.isNewline ?? false
266+
{
267+
let nextTokenWillBeRemoved =
268+
nextToken.ancestorOrSelf(mapping: { (node) -> Syntax? in
269+
guard let importDecl = node.as(ImportDeclSyntax.self), declsToRemove.contains(importDecl) else {
270+
return nil
271+
}
272+
return node
273+
}) != nil
274+
if !nextTokenWillBeRemoved {
275+
range = range.lowerBound..<snapshot.position(of: nextToken.position.advanced(by: 1))
276+
}
277+
}
278+
279+
return TextEdit(range: range, newText: "")
280+
}
281+
let applyResponse = try await sourceKitLSPServer.sendRequestToClient(
282+
ApplyEditRequest(
283+
edit: WorkspaceEdit(
284+
changes: [snapshot.uri: edits]
285+
)
286+
)
287+
)
288+
if !applyResponse.applied {
289+
let reason: String
290+
if let failureReason = applyResponse.failureReason {
291+
reason = " reason: \(failureReason)"
292+
} else {
293+
reason = ""
294+
}
295+
logger.error("client refused to apply edit for removing unused imports: \(reason)")
296+
}
297+
} cleanup: {
298+
let req = closeDocumentSourcekitdRequest(uri: temporaryDocUri)
299+
await orLog("Closing temporary sourcekitd document to remove unused imports") {
300+
_ = try await self.send(sourcekitdRequest: \.editorClose, req, snapshot: nil)
301+
}
302+
}
303+
}
304+
}

0 commit comments

Comments
 (0)