diff --git a/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift index b375b755c..da4a81ad9 100644 --- a/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift +++ b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift @@ -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 @@ -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] = [] @@ -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).. [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 diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index d36dde054..d66a053a3 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -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")]) ] ) """, @@ -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)