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
18 changes: 18 additions & 0 deletions source/compiler/qsc_frontend/src/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ pub type Dependencies = [(PackageId, Option<Arc<str>>)];
#[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 {
Expand Down
8 changes: 8 additions & 0 deletions source/language_service/src/code_action.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

mod auto_import;
mod wrapper_refactor;

use miette::Diagnostic;
Expand All @@ -25,6 +26,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::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<CodeAction>.
actions.extend(wrapper_refactor::operation_refactors(
Expand Down
105 changes: 105 additions & 0 deletions source/language_service/src/code_action/auto_import.rs
Original file line number Diff line number Diff line change
@@ -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<CodeAction> {
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<String> {
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<String> = namespaces.into_iter().collect();
namespaces.sort();
namespaces
}
177 changes: 177 additions & 0 deletions source/language_service/src/code_action/auto_import/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// 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<String> {
let (compilation, _targets) =
compile_project_with_markers_no_cursor(&[("<source>", source)], true);
let range = whole_document_range(source);
let actions = code_action::get_code_actions(&compilation, "<source>", 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 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 {
operation Main() : Unit {
Fake();
}
}";
let (compilation, _targets) =
compile_project_with_markers_no_cursor(&[("<source>", source)], true);
let range = whole_document_range(source);
let actions = code_action::get_code_actions(&compilation, "<source>", 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, "<source>");
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<String> = actions
.into_iter()
.filter(|a| a.title.starts_with("Import "))
.map(|a| a.title)
.collect();
expect![[r#"
[
"Import FakeStdLib.Fake",
]"#]]
.assert_eq(&format!("{titles:#?}"));
}
2 changes: 1 addition & 1 deletion source/language_service/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion source/language_service/src/completion/text_edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Range>,
/// The indentation level for the auto-import text edits.
Expand Down