Skip to content

Conversation

@PhantomInTheWire
Copy link
Member

fixes: #1089

This PR implements a new code action, AddMissingImports, which provides quick fixes to automatically add import statements for unresolved types or values. It leverages the semantic index to identify defining modules and intelligently inserts the necessary imports into the source file.

Key Features

  • Automatically detects cannot find '...' in scope and cannot find type '...' in scope diagnostics.
  • Queries the semantic index to find which modules define the missing type or value.
  • Filtering strategy
    • Filters out modules that are already imported in the current file.
    • Excludes the current module to avoid self-imports.
  • Insertion Strategy:
    • Inserts new imports after the last existing import statement if present.
    • If no imports exist, inserts at the beginning of the file (after leading trivia/headers).
    • Handles spacing and newlines correctly to maintain file formatting.
  • Sorting: Provides multiple suggestions sorted alphabetically by module name when a type is defined in multiple accessible modules.

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice. Left some thoughts inline.

@PhantomInTheWire PhantomInTheWire changed the title Add missing imports Code action to add missing import declarations Jan 17, 2026
@PhantomInTheWire
Copy link
Member Author

ping @ahoppen in case this got lost

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the ping, @PhantomInTheWire. I indeed missed this PR.

//
//===----------------------------------------------------------------------===//

internal import BuildServerIntegration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove the internal access modifiers in this file.

Comment on lines +48 to +53
guard case .dictionary(let documentDict)? = dictionary[CodingKeys.textDocument.stringValue] else {
return nil
}
guard let textDocument = TextDocumentIdentifier(fromLSPDictionary: documentDict) else {
return nil
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
guard case .dictionary(let documentDict)? = dictionary[CodingKeys.textDocument.stringValue] else {
return nil
}
guard let textDocument = TextDocumentIdentifier(fromLSPDictionary: documentDict) else {
return nil
}
guard let textDocument = TextDocumentIdentifier(fromLSPAny: dictionary[CodingKeys.textDocument.stringValue]) else {
return nil
}


// If no educational note available, fall back to diagnostic ID (e.g., "cannot_find_in_scope")
if code == nil, let diagnosticID {
code = .string(diagnosticID)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LSP documentation for code says

The diagnostic's code, which might appear in the user interface.

I don’t want to show the diagnostic IDs in the user interface, so I think we should store the diagnostic ID in the data dictionary.

let relevantDiagnostics = request.context.diagnostics.filter(AddMissingImportsHelper.isMissingTypeOrValueDiagnostic)
guard !relevantDiagnostics.isEmpty else { return [] }

guard let buildSettings = await self.compileCommand(for: request.textDocument.uri, fallbackAfterTimeout: true),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only fall back to fallback build settings after timeout if we listen for updated build settings to refresh results (eg. for diagnostics). This isn’t the case here.

Suggested change
guard let buildSettings = await self.compileCommand(for: request.textDocument.uri, fallbackAfterTimeout: true),
guard let buildSettings = await self.compileCommand(for: request.textDocument.uri, fallbackAfterTimeout: false),


guard
let workspace = await self.sourceKitLSPServer?.workspaceForDocument(uri: request.textDocument.uri),
let index = await workspace.index(checkedFor: .modifiedFiles)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if a file is modified, it’s reasonable to assume that the declarations within it are still the same, so we can relax the index check here a little bit.

Suggested change
let index = await workspace.index(checkedFor: .modifiedFiles)
let index = await workspace.index(checkedFor: .deletedFiles)

Comment on lines +252 to +261
if isSorted,
let insertBeforeImport = importDecls.first(where: { importDecl in
guard let firstPath = importDecl.path.first?.name.text else { return false }
return firstPath > newModule
})
{
return snapshot.position(of: insertBeforeImport.position)
} else if let lastImport = importDecls.last {
return snapshot.position(of: lastImport.endPosition)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can always use the isSorted case here. That way we try and find some reasonable sorted-ish location even if a single import statement doesn’t match alphabetical order.

extension SwiftLanguageService {
/// Finds missing import code actions for diagnostics indicating unresolved symbols.
/// Exposed for unit testing.
package static func findMissingImports(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I blind or is this only called by tests? In which case we aren’t testing the actual implementation in the tests this one. I would have probably relied a lot more on tests that use SwiftPMTestProject instead of writing tests that exercise this alternate code path.

That being said, if you manage to write more unit-style tests with minimal amount of alternative code, that would of course be great.

Comment on lines +32 to +34
// This test relies on the index, so it needs to run where we can build and index.
try SkipUnless.longTestsEnabled()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this test is any slower than other tests that we have, relying on the index. Generally a slow test is a test that takes more than 1 or 2 seconds to execute, which I’d be surprised if this one does. So, I’d remove this check.

DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
diagnostic = diags.fullReport?.items.first { $0.message.contains("LibStruct") }
XCTAssert(diagnostic != nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary because we XCTUnwrap below.

Comment on lines +92 to +96
[
uri: [
TextEdit(range: Range(positions["1️⃣"]), newText: "import Lib\n")
]
]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[
uri: [
TextEdit(range: Range(positions["1️⃣"]), newText: "import Lib\n")
]
]
[
uri: [TextEdit(range: Range(positions["1️⃣"]), newText: "import Lib\n")]
]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Code action to add missing import declarations

2 participants