From 67b198ff3c2c94536db1864a6218ba4f811e79ce Mon Sep 17 00:00:00 2001 From: sorin-bolos Date: Thu, 4 Jun 2026 22:56:11 +0300 Subject: [PATCH 1/4] Add import quick fix --- source/language_service/src/code_action.rs | 121 ++++++++++++++ .../language_service/src/code_action/tests.rs | 153 ++++++++++++++++++ source/language_service/src/completion.rs | 2 +- .../src/completion/text_edits.rs | 2 +- 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 source/language_service/src/code_action/tests.rs diff --git a/source/language_service/src/code_action.rs b/source/language_service/src/code_action.rs index 00c82348bb..a2ccccf389 100644 --- a/source/language_service/src/code_action.rs +++ b/source/language_service/src/code_action.rs @@ -3,19 +3,28 @@ mod wrapper_refactor; +#[cfg(test)] +mod tests; + use miette::Diagnostic; use qsc::{ Span, compile::ErrorKind, error::WithSource, line_column::{Encoding, Range}, + resolve::NameKind, }; +use rustc_hash::FxHashSet; use crate::{ compilation::Compilation, + completion::text_edits::TextEditRange, protocol::{CodeAction, CodeActionKind, TextEdit, WorkspaceEdit}, }; +/// Diagnostic code emitted by the resolver for an unresolved name. +const RESOLVE_NOT_FOUND_CODE: &str = "Qsc.Resolve.NotFound"; + pub(crate) fn get_code_actions( compilation: &Compilation, source_name: &str, @@ -25,6 +34,13 @@ pub(crate) fn get_code_actions( // Compute quick fixes (lint-based) and refactor actions and merge. let span = compilation.source_range_to_package_span(source_name, range, position_encoding); let mut actions = quick_fixes(compilation, source_name, span, position_encoding); + // Add auto-import quick fixes for unresolved names (e.g. `DumpMachine` -> `import Std.Diagnostics.DumpMachine;`). + actions.extend(auto_import_fixes( + compilation, + source_name, + span, + position_encoding, + )); // Add operation refactor actions (wrapper generation, etc.). Additional refactor providers // should be added here, each returning their own Vec. actions.extend(wrapper_refactor::operation_refactors( @@ -36,6 +52,111 @@ pub(crate) fn get_code_actions( actions } +/// Produces auto-import quick fixes for unresolved names within `span`. +/// +/// For each `Qsc.Resolve.NotFound` diagnostic that overlaps the requested range, the +/// unresolved (unqualified) name is looked up in the global term and type tables. Every +/// namespace that exports a matching name yields a separate `QuickFix` code action that +/// inserts an `import {namespace}.{name};` statement at the start of the enclosing namespace. +fn auto_import_fixes( + compilation: &Compilation, + source_name: &str, + span: Span, + encoding: Encoding, +) -> Vec { + let source = compilation + .user_unit() + .sources + .find_by_name(source_name) + .expect("source should exist"); + + let mut code_actions = Vec::new(); + // Dedupe by title, since the same name may be unresolved at multiple offsets in range. + let mut seen = FxHashSet::default(); + + let not_found_errors = compilation + .compile_errors + .iter() + .filter(|error| is_error_relevant(error, span)) + .filter(|error| { + error + .code() + .is_some_and(|code| code.to_string() == RESOLVE_NOT_FOUND_CODE) + }); + + for error in not_found_errors { + let Some(error_span) = resolve_span(error) else { + continue; + }; + + // Extract the unresolved name from the source text at the error's span. + let lo = (error_span.lo - source.offset) as usize; + let hi = (error_span.hi - source.offset) as usize; + let Some(name) = source.contents.get(lo..hi) else { + continue; + }; + + // v1 only handles unqualified names; partial paths are deferred. + if name.is_empty() || name.contains('.') { + continue; + } + + // Determine where an import would be inserted for the enclosing namespace. + let edit_range = TextEditRange::init(error_span.lo, compilation, encoding); + let Some(insert_at) = edit_range.insert_import_at else { + continue; + }; + + for namespace_name in matching_namespaces(compilation, name) { + let title = format!("Import {namespace_name}.{name}"); + if !seen.insert(title.clone()) { + continue; + } + + let new_text = format!("import {namespace_name}.{name};{}", edit_range.indent); + code_actions.push(CodeAction { + title, + edit: Some(WorkspaceEdit { + changes: vec![( + source_name.to_string(), + vec![TextEdit { + new_text, + range: insert_at, + }], + )], + }), + kind: Some(CodeActionKind::QuickFix), + is_preferred: None, + }); + } + } + + code_actions +} + +/// Returns the fully-qualified names of all namespaces that export an item named `name` +/// in either an expression (term) or type context. Results are sorted for determinism. +fn matching_namespaces(compilation: &Compilation, name: &str) -> Vec { + let global_scope = &compilation.user_unit().ast.globals; + let mut namespaces = FxHashSet::default(); + + for name_kind in [NameKind::Term, NameKind::Ty] { + for (namespace_id, names) in global_scope.table(name_kind).iter() { + if names.contains_key(name) { + let namespace_name = global_scope.format_namespace_name(namespace_id); + // Don't suggest auto-imports for OpenQASM namespaces (mirrors completions). + if !namespace_name.starts_with("Std.OpenQASM") { + namespaces.insert(namespace_name); + } + } + } + } + + let mut namespaces: Vec = namespaces.into_iter().collect(); + namespaces.sort(); + namespaces +} + fn quick_fixes( compilation: &Compilation, source_name: &str, diff --git a/source/language_service/src/code_action/tests.rs b/source/language_service/src/code_action/tests.rs new file mode 100644 index 0000000000..8d84a4cd8b --- /dev/null +++ b/source/language_service/src/code_action/tests.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::code_action; +use crate::test_utils::{ + compile_notebook_with_fake_stdlib, compile_project_with_markers_no_cursor, +}; +use expect_test::{Expect, expect}; +use qsc::line_column::{Encoding, Position, Range}; + +/// Returns a range that spans the entire source, so all diagnostics are considered relevant. +fn whole_document_range(source: &str) -> Range { + let newline_count = u32::try_from(source.matches('\n').count()).expect("count fits"); + let end = if newline_count == 0 { + Position { + line: 0, + column: u32::try_from(source.len()).expect("len fits"), + } + } else { + Position { + line: newline_count + 1, + column: 0, + } + }; + Range { + start: Position { line: 0, column: 0 }, + end, + } +} + +/// Collects the titles of the auto-import code actions offered for `source`. +fn import_action_titles(source: &str) -> Vec { + let (compilation, _targets) = + compile_project_with_markers_no_cursor(&[("", source)], true); + let range = whole_document_range(source); + let actions = code_action::get_code_actions(&compilation, "", range, Encoding::Utf8); + actions + .into_iter() + .filter(|a| a.title.starts_with("Import ")) + .map(|a| a.title) + .collect() +} + +fn check_import_titles(source: &str, expect: &Expect) { + expect.assert_eq(&format!("{:#?}", import_action_titles(source))); +} + +#[test] +fn unresolved_term_offers_import() { + check_import_titles( + "namespace Test { + operation Main() : Unit { + Fake(); + } + }", + &expect![[r#" + [ + "Import FakeStdLib.Fake", + ]"#]], + ); +} + +#[test] +fn unresolved_type_offers_import() { + check_import_titles( + "namespace Test { + operation Main(x : Udt) : Unit {} + }", + &expect![[r#" + [ + "Import FakeStdLib.Udt", + ]"#]], + ); +} + +#[test] +fn resolved_name_offers_no_import() { + check_import_titles( + "namespace Test { + open FakeStdLib; + operation Main() : Unit { + Fake(); + } + }", + &expect![[r#" + []"#]], + ); +} + +#[test] +fn qualified_unresolved_name_is_skipped() { + // v1 only handles unqualified names; a partial path like `Wrong.Fake` should not + // produce an auto-import quick fix. + check_import_titles( + "namespace Test { + operation Main() : Unit { + Wrong.Fake(); + } + }", + &expect![[r#" + []"#]], + ); +} + +#[test] +fn import_edit_inserts_at_namespace_start() { + let source = "namespace Test { + operation Main() : Unit { + Fake(); + } + }"; + let (compilation, _targets) = + compile_project_with_markers_no_cursor(&[("", source)], true); + let range = whole_document_range(source); + let actions = code_action::get_code_actions(&compilation, "", range, Encoding::Utf8); + let action = actions + .iter() + .find(|a| a.title == "Import FakeStdLib.Fake") + .expect("expected an import action for Fake"); + + let edit = action.edit.as_ref().expect("expected an edit"); + assert_eq!(edit.changes.len(), 1); + let (file, edits) = &edit.changes[0]; + assert_eq!(file, ""); + assert_eq!(edits.len(), 1); + let text_edit = &edits[0]; + // Insertion (zero-length range) before the first item in the namespace. + assert_eq!(text_edit.range.start, text_edit.range.end); + assert!( + text_edit.new_text.contains("import FakeStdLib.Fake;"), + "unexpected edit text: {:?}", + text_edit.new_text + ); +} + +#[test] +fn notebook_unresolved_term_offers_import() { + let compilation = compile_notebook_with_fake_stdlib( + [("cell1", "Fake();")].into_iter(), + ); + let range = whole_document_range("Fake();"); + let actions = code_action::get_code_actions(&compilation, "cell1", range, Encoding::Utf8); + let titles: Vec = actions + .into_iter() + .filter(|a| a.title.starts_with("Import ")) + .map(|a| a.title) + .collect(); + expect![[r#" + [ + "Import FakeStdLib.Fake", + ]"#]] + .assert_eq(&format!("{titles:#?}")); +} diff --git a/source/language_service/src/completion.rs b/source/language_service/src/completion.rs index 1e436f37f6..5aff174c57 100644 --- a/source/language_service/src/completion.rs +++ b/source/language_service/src/completion.rs @@ -9,7 +9,7 @@ mod openqasm; mod qsharp; #[cfg(test)] mod tests; -mod text_edits; +pub(crate) mod text_edits; use crate::{ compilation::{Compilation, CompilationKind, source_position_to_package_offset}, diff --git a/source/language_service/src/completion/text_edits.rs b/source/language_service/src/completion/text_edits.rs index 99fd8ef2e1..a310a3e0fa 100644 --- a/source/language_service/src/completion/text_edits.rs +++ b/source/language_service/src/completion/text_edits.rs @@ -12,7 +12,7 @@ use qsc::{ /// Provides information about where auto-imports should be inserted /// in the document based on the cursor offset. -pub(super) struct TextEditRange { +pub(crate) struct TextEditRange { /// Location to insert any auto-import text edits at. pub insert_import_at: Option, /// The indentation level for the auto-import text edits. From 54f59639018230be3688a3b9e9ace4d516c34628 Mon Sep 17 00:00:00 2001 From: sorin-bolos Date: Thu, 4 Jun 2026 23:14:09 +0300 Subject: [PATCH 2/4] Add test for multiple matches --- .../language_service/src/code_action/tests.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/source/language_service/src/code_action/tests.rs b/source/language_service/src/code_action/tests.rs index 8d84a4cd8b..38760fa928 100644 --- a/source/language_service/src/code_action/tests.rs +++ b/source/language_service/src/code_action/tests.rs @@ -102,6 +102,32 @@ fn qualified_unresolved_name_is_skipped() { ); } +#[test] +fn name_in_multiple_namespaces_offers_one_import_each() { + // The same unqualified name exists in two namespaces, neither of which is open, + // so a separate import action is offered for each (sorted by namespace name). + check_import_titles( + "namespace NsA { + operation Collide() : Unit {} + export Collide; + } + namespace NsB { + operation Collide() : Unit {} + export Collide; + } + namespace Test { + operation Main() : Unit { + Collide(); + } + }", + &expect![[r#" + [ + "Import NsA.Collide", + "Import NsB.Collide", + ]"#]], + ); +} + #[test] fn import_edit_inserts_at_namespace_start() { let source = "namespace Test { From 053d8c36cd0b6a08ef0ed8a34007ac38f0f11918 Mon Sep 17 00:00:00 2001 From: sorin-bolos Date: Thu, 4 Jun 2026 23:36:09 +0300 Subject: [PATCH 3/4] Fix failing test --- source/language_service/src/code_action/tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/source/language_service/src/code_action/tests.rs b/source/language_service/src/code_action/tests.rs index 38760fa928..59d283b992 100644 --- a/source/language_service/src/code_action/tests.rs +++ b/source/language_service/src/code_action/tests.rs @@ -161,9 +161,7 @@ fn import_edit_inserts_at_namespace_start() { #[test] fn notebook_unresolved_term_offers_import() { - let compilation = compile_notebook_with_fake_stdlib( - [("cell1", "Fake();")].into_iter(), - ); + let compilation = compile_notebook_with_fake_stdlib([("cell1", "Fake();")].into_iter()); let range = whole_document_range("Fake();"); let actions = code_action::get_code_actions(&compilation, "cell1", range, Encoding::Utf8); let titles: Vec = actions From 9a1a258a0c1abdd7bed5d355b1b1892815d79920 Mon Sep 17 00:00:00 2001 From: sorin-bolos Date: Sat, 6 Jun 2026 09:20:10 +0300 Subject: [PATCH 4/4] Create sub-module auto_import --- source/compiler/qsc_frontend/src/compile.rs | 18 +++ source/language_service/src/code_action.rs | 117 +----------------- .../src/code_action/auto_import.rs | 105 ++++++++++++++++ .../code_action/{ => auto_import}/tests.rs | 0 4 files changed, 125 insertions(+), 115 deletions(-) create mode 100644 source/language_service/src/code_action/auto_import.rs rename source/language_service/src/code_action/{ => auto_import}/tests.rs (100%) diff --git a/source/compiler/qsc_frontend/src/compile.rs b/source/compiler/qsc_frontend/src/compile.rs index e21356241c..750f011296 100644 --- a/source/compiler/qsc_frontend/src/compile.rs +++ b/source/compiler/qsc_frontend/src/compile.rs @@ -90,6 +90,24 @@ pub type Dependencies = [(PackageId, Option>)]; #[error(transparent)] pub struct Error(pub(super) ErrorKind); +impl Error { + /// If this is an unresolved-name error (diagnostic code `Qsc.Resolve.NotFound`), + /// returns the unresolved name and the span where it appears, otherwise `None`. + /// + /// This covers both a name that genuinely doesn't exist and a name that exists + /// but isn't available for the current compilation configuration, since both + /// surface under the same diagnostic. + #[must_use] + pub fn unresolved_name(&self) -> Option<(&str, Span)> { + match &self.0 { + ErrorKind::Resolve( + resolve::Error::NotFound(name, span) | resolve::Error::NotAvailable(name, _, span), + ) => Some((name.as_str(), *span)), + _ => None, + } + } +} + #[derive(Clone, Debug, Diagnostic, Error)] #[diagnostic(transparent)] pub(super) enum ErrorKind { diff --git a/source/language_service/src/code_action.rs b/source/language_service/src/code_action.rs index a2ccccf389..47e97acf31 100644 --- a/source/language_service/src/code_action.rs +++ b/source/language_service/src/code_action.rs @@ -1,30 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod auto_import; mod wrapper_refactor; -#[cfg(test)] -mod tests; - use miette::Diagnostic; use qsc::{ Span, compile::ErrorKind, error::WithSource, line_column::{Encoding, Range}, - resolve::NameKind, }; -use rustc_hash::FxHashSet; use crate::{ compilation::Compilation, - completion::text_edits::TextEditRange, protocol::{CodeAction, CodeActionKind, TextEdit, WorkspaceEdit}, }; -/// Diagnostic code emitted by the resolver for an unresolved name. -const RESOLVE_NOT_FOUND_CODE: &str = "Qsc.Resolve.NotFound"; - pub(crate) fn get_code_actions( compilation: &Compilation, source_name: &str, @@ -35,7 +27,7 @@ pub(crate) fn get_code_actions( let span = compilation.source_range_to_package_span(source_name, range, position_encoding); let mut actions = quick_fixes(compilation, source_name, span, position_encoding); // Add auto-import quick fixes for unresolved names (e.g. `DumpMachine` -> `import Std.Diagnostics.DumpMachine;`). - actions.extend(auto_import_fixes( + actions.extend(auto_import::auto_import_fixes( compilation, source_name, span, @@ -52,111 +44,6 @@ pub(crate) fn get_code_actions( actions } -/// Produces auto-import quick fixes for unresolved names within `span`. -/// -/// For each `Qsc.Resolve.NotFound` diagnostic that overlaps the requested range, the -/// unresolved (unqualified) name is looked up in the global term and type tables. Every -/// namespace that exports a matching name yields a separate `QuickFix` code action that -/// inserts an `import {namespace}.{name};` statement at the start of the enclosing namespace. -fn auto_import_fixes( - compilation: &Compilation, - source_name: &str, - span: Span, - encoding: Encoding, -) -> Vec { - let source = compilation - .user_unit() - .sources - .find_by_name(source_name) - .expect("source should exist"); - - let mut code_actions = Vec::new(); - // Dedupe by title, since the same name may be unresolved at multiple offsets in range. - let mut seen = FxHashSet::default(); - - let not_found_errors = compilation - .compile_errors - .iter() - .filter(|error| is_error_relevant(error, span)) - .filter(|error| { - error - .code() - .is_some_and(|code| code.to_string() == RESOLVE_NOT_FOUND_CODE) - }); - - for error in not_found_errors { - let Some(error_span) = resolve_span(error) else { - continue; - }; - - // Extract the unresolved name from the source text at the error's span. - let lo = (error_span.lo - source.offset) as usize; - let hi = (error_span.hi - source.offset) as usize; - let Some(name) = source.contents.get(lo..hi) else { - continue; - }; - - // v1 only handles unqualified names; partial paths are deferred. - if name.is_empty() || name.contains('.') { - continue; - } - - // Determine where an import would be inserted for the enclosing namespace. - let edit_range = TextEditRange::init(error_span.lo, compilation, encoding); - let Some(insert_at) = edit_range.insert_import_at else { - continue; - }; - - for namespace_name in matching_namespaces(compilation, name) { - let title = format!("Import {namespace_name}.{name}"); - if !seen.insert(title.clone()) { - continue; - } - - let new_text = format!("import {namespace_name}.{name};{}", edit_range.indent); - code_actions.push(CodeAction { - title, - edit: Some(WorkspaceEdit { - changes: vec![( - source_name.to_string(), - vec![TextEdit { - new_text, - range: insert_at, - }], - )], - }), - kind: Some(CodeActionKind::QuickFix), - is_preferred: None, - }); - } - } - - code_actions -} - -/// Returns the fully-qualified names of all namespaces that export an item named `name` -/// in either an expression (term) or type context. Results are sorted for determinism. -fn matching_namespaces(compilation: &Compilation, name: &str) -> Vec { - let global_scope = &compilation.user_unit().ast.globals; - let mut namespaces = FxHashSet::default(); - - for name_kind in [NameKind::Term, NameKind::Ty] { - for (namespace_id, names) in global_scope.table(name_kind).iter() { - if names.contains_key(name) { - let namespace_name = global_scope.format_namespace_name(namespace_id); - // Don't suggest auto-imports for OpenQASM namespaces (mirrors completions). - if !namespace_name.starts_with("Std.OpenQASM") { - namespaces.insert(namespace_name); - } - } - } - } - - let mut namespaces: Vec = namespaces.into_iter().collect(); - namespaces.sort(); - namespaces -} - fn quick_fixes( compilation: &Compilation, source_name: &str, diff --git a/source/language_service/src/code_action/auto_import.rs b/source/language_service/src/code_action/auto_import.rs new file mode 100644 index 0000000000..b79dcd6033 --- /dev/null +++ b/source/language_service/src/code_action/auto_import.rs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Auto-import code action logic: for unresolved names, offers quick fixes that insert an +// `import {namespace}.{name};` statement at the start of the enclosing namespace. + +#[cfg(test)] +mod tests; + +use qsc::{Span, compile::ErrorKind, line_column::Encoding, resolve::NameKind}; +use rustc_hash::FxHashSet; + +use super::is_error_relevant; +use crate::{ + compilation::Compilation, + completion::text_edits::TextEditRange, + protocol::{CodeAction, CodeActionKind, TextEdit, WorkspaceEdit}, +}; + +/// Produces auto-import quick fixes for unresolved names within `span`. +/// +/// For each unresolved-name diagnostic that overlaps the requested range, the unresolved +/// (unqualified) name is looked up in the global term and type tables. Every namespace that +/// exports a matching name yields a separate `QuickFix` code action that inserts an +/// `import {namespace}.{name};` statement at the start of the enclosing namespace. +pub(super) fn auto_import_fixes( + compilation: &Compilation, + source_name: &str, + span: Span, + encoding: Encoding, +) -> Vec { + let mut code_actions = Vec::new(); + // Dedupe by title, since the same name may be unresolved at multiple offsets in range. + let mut seen = FxHashSet::default(); + + let unresolved_names = compilation + .compile_errors + .iter() + .filter(|error| is_error_relevant(error, span)) + .filter_map(|error| match error.error() { + ErrorKind::Frontend(frontend_error) => frontend_error.unresolved_name(), + _ => None, + }); + + for (name, name_span) in unresolved_names { + // v1 only handles unqualified names; partial paths are deferred. + if name.is_empty() || name.contains('.') { + continue; + } + + // Determine where an import would be inserted for the enclosing namespace. + let edit_range = TextEditRange::init(name_span.lo, compilation, encoding); + let Some(insert_at) = edit_range.insert_import_at else { + continue; + }; + + for namespace_name in matching_namespaces(compilation, name) { + let title = format!("Import {namespace_name}.{name}"); + if !seen.insert(title.clone()) { + continue; + } + + let new_text = format!("import {namespace_name}.{name};{}", edit_range.indent); + code_actions.push(CodeAction { + title, + edit: Some(WorkspaceEdit { + changes: vec![( + source_name.to_string(), + vec![TextEdit { + new_text, + range: insert_at, + }], + )], + }), + kind: Some(CodeActionKind::QuickFix), + is_preferred: None, + }); + } + } + + code_actions +} + +/// Returns the fully-qualified names of all namespaces that export an item named `name` +/// in either an expression (term) or type context. Results are sorted for determinism. +fn matching_namespaces(compilation: &Compilation, name: &str) -> Vec { + let global_scope = &compilation.user_unit().ast.globals; + let mut namespaces = FxHashSet::default(); + + for name_kind in [NameKind::Term, NameKind::Ty] { + for (namespace_id, names) in global_scope.table(name_kind).iter() { + if names.contains_key(name) { + let namespace_name = global_scope.format_namespace_name(namespace_id); + // Don't suggest auto-imports for OpenQASM namespaces (mirrors completions). + if !namespace_name.starts_with("Std.OpenQASM") { + namespaces.insert(namespace_name); + } + } + } + } + + let mut namespaces: Vec = namespaces.into_iter().collect(); + namespaces.sort(); + namespaces +} diff --git a/source/language_service/src/code_action/tests.rs b/source/language_service/src/code_action/auto_import/tests.rs similarity index 100% rename from source/language_service/src/code_action/tests.rs rename to source/language_service/src/code_action/auto_import/tests.rs