Skip to content
Open
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
124 changes: 120 additions & 4 deletions Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
// 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
Expand Down Expand Up @@ -197,9 +197,14 @@ extension SwiftLanguageService {
throw ResponseError.unknown("Failed to remove unused imports because the document currently contains errors")
}

// Only consider import declarations at the top level and ignore ones eg. inside `#if` clauses since those might
// be inactive in the current build configuration and thus we can't reliably check if they are needed.
let importDecls = syntaxTree.statements.compactMap { $0.item.as(ImportDeclSyntax.self) }
// Fetch active regions from sourcekitd before collecting imports
let activeRegions = try await fetchActiveRegions(snapshot: snapshot, compileCommand: compileCommand)

let importDecls = try await collectImportDecls(
from: syntaxTree,
snapshot: snapshot,
activeRegions: activeRegions ?? []
)

var declsToRemove: [ImportDeclSyntax] = []

Expand Down Expand Up @@ -301,4 +306,115 @@ extension SwiftLanguageService {
}
}
}

/// Fetches active regions from sourcekitd for the given file and constructs byte ranges
/// for active code within conditional compilation blocks. The activeRegions request
/// returns a list of entries in `results` containing `offset` and an optional
/// `is_active` flag. Each entry marks the start of a region; the next entry’s offset
/// is the end boundary.
///
/// - Parameters:
/// - snapshot: The document snapshot.
/// - compileCommand: The compile command for the file.
/// - Returns: An array of source ranges representing active regions, or nil if the API doesn't return usable data.
private func fetchActiveRegions(
snapshot: DocumentSnapshot,
compileCommand: SwiftCompileCommand
) async throws -> [SourceRange]? {
let skreq = sourcekitd.dictionary([
keys.sourceFile: snapshot.uri.sourcekitdSourceFile,
keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath,
keys.compilerArgs: compileCommand.compilerArgs as [any SKDRequestValue],
])

let dict = try await send(sourcekitdRequest: \.activeRegions, skreq, snapshot: snapshot)

// Build ranges by pairing each entry with the next entry's offset; include only active ones.
guard let results = dict[keys.results] as SKDResponseArray? else {
return nil
}

let entries: [(offset: Int, isActive: Bool?)] = results.compactMap { entry in
guard let off = entry[keys.offset] as Int? else {
return nil
}
let activeBool = entry[keys.isActive] as Bool?
let activeInt = entry[keys.isActive] as Int?
return (offset: off, isActive: activeBool ?? activeInt.map { $0 != 0 })
}
guard !entries.isEmpty else { return [] }
let sortedEntries = entries.sorted { $0.offset < $1.offset }

var ranges: [SourceRange] = []
let fileEnd = snapshot.text.utf8.count
for (current, next) in zip(sortedEntries, sortedEntries.dropFirst()) {
let start = current.offset
let end = next.offset
let isActive = current.isActive ?? false
if isActive, start < end {
ranges.append(AbsolutePosition(utf8Offset: start)..<AbsolutePosition(utf8Offset: end))
}
}
if let last = sortedEntries.last {
let start = last.offset
let end = fileEnd
let isActive = last.isActive ?? false
if isActive, start < end {
ranges.append(AbsolutePosition(utf8Offset: start)..<AbsolutePosition(utf8Offset: end))
}
}
return ranges
}

/// Collects all import declarations that are active.
/// This includes top-level imports and imports from #if clauses.
///
/// - Parameters:
/// - syntaxTree: The root syntax tree to traverse.
/// - snapshot: The document snapshot.
/// - activeRegions: Array of source ranges representing active #if regions.
/// - Returns: An array of import declarations.
private func collectImportDecls(
from syntaxTree: SourceFileSyntax,
snapshot: DocumentSnapshot,
activeRegions: [SourceRange]
) async throws -> [ImportDeclSyntax] {
let visitor = ImportCollectorVisitor(activeRegions: activeRegions, snapshot: snapshot)
visitor.walk(syntaxTree)
return visitor.collectedImports
}
}

/// A syntax visitor that collects import declarations from both top-level and #if clauses.
/// Uses activeRegions from sourcekitd to determine which #if clauses are active.
private class ImportCollectorVisitor: SyntaxVisitor {
private(set) var collectedImports: [ImportDeclSyntax] = []
private let activeRegions: [SourceRange]
private let snapshot: DocumentSnapshot

init(activeRegions: [SourceRange], snapshot: DocumentSnapshot, viewMode: SyntaxTreeViewMode = .sourceAccurate) {
self.activeRegions = activeRegions
self.snapshot = snapshot
super.init(viewMode: viewMode)
}

override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind {
let startOffset = snapshot.utf8Offset(of: snapshot.position(of: node.positionAfterSkippingLeadingTrivia))
let isInIfConfig =
node.findParentOfSelf(ofType: IfConfigDeclSyntax.self, stoppingIf: { _ in false }) != nil
if !isInIfConfig || isOffsetInActiveRegion(startOffset) {
collectedImports.append(node)
}
return .skipChildren
}

private func isOffsetInActiveRegion(_ offset: Int) -> Bool {
guard !activeRegions.isEmpty else {
return true
}
let position = AbsolutePosition(utf8Offset: offset)
return activeRegions.contains { $0.contains(position) }
}
}

private typealias SourceRange = Range<AbsolutePosition>
97 changes: 96 additions & 1 deletion Tests/SourceKitLSPTests/CodeActionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ final class CodeActionTests: SourceKitLSPTestCase {
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "Test", swiftSettings: [.enableUpcomingFeature("MemberImportVisibility")]c)
.target(name: "Test", swiftSettings: [.enableUpcomingFeature("MemberImportVisibility")])
]
)
""",
Expand All @@ -1545,6 +1545,101 @@ final class CodeActionTests: SourceKitLSPTestCase {
)
}

func testRemoveUnusedImportsHandlesActiveIfClauses() async throws {
let project = try await SwiftPMTestProject(
files: [
"LibA/LibA.swift": "",
"LibB/LibB.swift": "",
"Test/Test.swift": """
#if canImport(Darwin)
1️⃣import Darwin // Unused, should be removed on macOS
#elseif canImport(Glibc)
2️⃣import Glibc3️⃣ // Inactive on macOS, must NOT be removed
#endif

4️⃣import LibA
5️⃣import LibB6️⃣

func test() {
print("Hello")
}
""",
],
manifest: """
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "LibA"),
.target(name: "LibB"),
.target(
name: "Test",
dependencies: ["LibA", "LibB"],
swiftSettings: [.enableUpcomingFeature("MemberImportVisibility")]
)
]
)
""",
capabilities: clientCapabilitiesWithCodeActionSupport,
enableBackgroundIndexing: true
)

let (uri, positions) = try project.openDocument("Test.swift")

let importResult = try await project.testClient.send(
CodeActionRequest(
range: Range(positions["1️⃣"]),
context: CodeActionContext(),
textDocument: TextDocumentIdentifier(uri)
)
)
let removeUnusedImportsCommand = try XCTUnwrap(
importResult?.codeActions?.first(where: {
$0.command?.command == "remove.unused.imports.command"
})?.command
)

project.testClient.handleSingleRequest { (request: ApplyEditRequest) -> ApplyEditResponse in
// Should remove Darwin and LibA/LibB imports, but NOT the Glibc import (which is in an inactive #if clause)
guard let changesDict = request.edit.changes,
let changes = changesDict[uri]
else {
XCTFail("Expected edits for the test file")
return ApplyEditResponse(applied: false, failureReason: "No edits")
}

// Verify we have 3 edits (Darwin, LibA, LibB)
XCTAssertEqual(changes.count, 3, "Expected 3 import removals")

let expectedPositions = [
positions["1️⃣"],
positions["4️⃣"],
positions["5️⃣"],
]

for expected in expectedPositions {
XCTAssertTrue(
changes.contains(where: { $0.range.contains(expected) }),
"Expected an edit containing \(expected)"
)
}

// Verify Glibc import range is NOT in the edits
XCTAssertFalse(
changes.contains(where: { $0.range.contains(positions["2️⃣"]) }),
"Glibc import should not be removed (it's in an inactive clause)"
)

return ApplyEditResponse(applied: true, failureReason: nil)
}

_ = try await project.testClient.send(
ExecuteCommandRequest(
command: removeUnusedImportsCommand.command,
arguments: removeUnusedImportsCommand.arguments
)
)
}

func testConvertFunctionZeroParameterToComputedProperty() async throws {
let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport)
let uri = DocumentURI(for: .swift)
Expand Down