From 1ec31a2aa1133fb8483e5ec158b8cb8bde073028 Mon Sep 17 00:00:00 2001 From: Ian Davis Date: Wed, 5 Aug 2026 17:52:39 -0700 Subject: [PATCH] Adding spec compat mode for editing openqasm files --- samples/OpenQASM/README.md | 28 ++ source/language_service/src/code_lens.rs | 4 + .../language_service/src/code_lens/tests.rs | 32 ++ source/language_service/src/compilation.rs | 99 +++- .../src/compilation/openqasm_mode_tests.rs | 454 ++++++++++++++++++ source/language_service/src/lib.rs | 31 +- source/language_service/src/protocol.rs | 25 + source/language_service/src/state.rs | 155 +++++- source/language_service/src/state/tests.rs | 236 ++++++++- .../src/test_utils/openqasm.rs | 2 + source/language_service/src/tests.rs | 2 + source/npm/qsharp/src/compiler/compiler.ts | 3 + .../src/language-service/language-service.ts | 49 +- source/npm/qsharp/src/main.ts | 1 + source/vscode/package.json | 295 +++++++----- source/vscode/src/config.ts | 17 + source/vscode/src/debugger/activate.ts | 4 +- .../vscode/src/language-service/activate.ts | 22 +- .../vscode/src/language-service/codeLens.ts | 27 +- .../src/language-service/openqasmMode.ts | 212 ++++++++ source/vscode/src/programConfig.ts | 10 + source/vscode/src/telemetry.ts | 5 + .../language-service/language-service.test.ts | 32 ++ .../test-workspace/spec-mode.qasm | 8 + source/wasm/src/language_service.rs | 62 ++- 25 files changed, 1661 insertions(+), 154 deletions(-) create mode 100644 samples/OpenQASM/README.md create mode 100644 source/language_service/src/compilation/openqasm_mode_tests.rs create mode 100644 source/vscode/src/language-service/openqasmMode.ts create mode 100644 source/vscode/test/suites/language-service/test-workspace/spec-mode.qasm diff --git a/samples/OpenQASM/README.md b/samples/OpenQASM/README.md new file mode 100644 index 00000000000..5dcb6382676 --- /dev/null +++ b/samples/OpenQASM/README.md @@ -0,0 +1,28 @@ +# OpenQASM Support + +The QDK supports a useful subset of OpenQASM 3 for simulation, debugging, +circuit generation, resource estimation, and Azure Quantum submission. Programs +using OpenQASM hardware-control features can still be edited in VS Code even +when the QDK cannot compile them. + +The QDK does not compile these construct families: + +* Calibration blocks and `defcal` definitions +* Timing and duration operations such as `delay` +* Hardware qubit addressing +* `extern` declarations +* Mutable array references + +Use the `qdk.openqasm.mode` setting to choose how the editor treats a file: + +* `auto` is the default. It uses QDK mode until the file contains a construct + the QDK cannot compile, then uses spec mode. +* `qdk` reports unsupported constructs as errors and enables QDK features such + as Run, Debug, circuit generation, resource estimation, and submission. +* `spec` reports OpenQASM syntax and semantic errors while disabling QDK-only + features. A code lens and Command Palette commands switch the file back to + QDK mode when those features are needed. + +The sample files in this directory are QDK-compatible examples. The editor's +spec mode is intended for OpenQASM programs that use the standard beyond the +subset the QDK currently compiles. diff --git a/source/language_service/src/code_lens.rs b/source/language_service/src/code_lens.rs index b0b50cb09d3..be7d6b9512a 100644 --- a/source/language_service/src/code_lens.rs +++ b/source/language_service/src/code_lens.rs @@ -25,6 +25,10 @@ pub(crate) fn get_code_lenses( return vec![]; // entrypoint actions don't work in notebooks } + if compilation.is_openqasm_spec_mode() { + return vec![]; // these lenses all run the program through the QDK + } + if !compilation.project_errors.is_empty() || compilation .compile_errors diff --git a/source/language_service/src/code_lens/tests.rs b/source/language_service/src/code_lens/tests.rs index 28b61627462..a67e447a7d1 100644 --- a/source/language_service/src/code_lens/tests.rs +++ b/source/language_service/src/code_lens/tests.rs @@ -4,11 +4,15 @@ use super::get_code_lenses; use crate::{ Encoding, + compilation::Compilation, + protocol::OpenQasmMode, test_utils::{ compile_notebook_with_fake_stdlib, compile_with_fake_stdlib_and_markers_no_cursor, }, }; use expect_test::{Expect, expect}; +use qsc::PackageType; +use std::sync::Arc; fn check(source_with_markers: &str, expect: &Expect) { let (compilation, expected_code_lens_ranges) = @@ -240,3 +244,31 @@ fn no_code_lenses_with_compilation_errors() { "code lenses should not be present when there are compilation errors" ); } + +const OPENQASM_PROGRAM: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +qubit[2] q; +bit[2] c; +h q[0]; +cx q[0], q[1]; +c = measure q; +"#; + +fn openqasm_lenses(mode: OpenQasmMode) -> Vec { + let compilation = Compilation::new_qasm( + PackageType::Exe, + vec![(Arc::from(""), Arc::from(OPENQASM_PROGRAM))], + vec![], + &Arc::from("test project"), + mode, + ); + get_code_lenses(&compilation, "", Encoding::Utf8) +} + +#[test] +fn no_code_lenses_for_openqasm_in_spec_mode() { + // The same program in qdk mode has lenses, so the suppression is the mode's + // doing rather than the program having nothing to offer. + assert!(!openqasm_lenses(OpenQasmMode::Qdk).is_empty()); + assert!(openqasm_lenses(OpenQasmMode::Spec).is_empty()); +} diff --git a/source/language_service/src/compilation.rs b/source/language_service/src/compilation.rs index d8849bb0fcb..4ca21e962c5 100644 --- a/source/language_service/src/compilation.rs +++ b/source/language_service/src/compilation.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::protocol::{EffectiveOpenQasmMode, OpenQasmMode}; use log::trace; use qsc::{ CompileUnit, LanguageFeatures, PackageStore, PackageType, PassContext, SourceMap, Span, ast, @@ -12,7 +13,8 @@ use qsc::{ line_column::{Encoding, Position, Range}, openqasm::{ CompileRawQasmResult, CompilerConfig, OutputSemantics, ProgramType, QubitSemantics, - compiler::compile_to_qsharp_ast_with_config, + compiler::compile_to_qsharp_ast_with_config, semantic::AnalysisResult, + source::SourceMap as ParseSourceMap, }, packages::{BuildableProgram, prepare_package_store}, project, resolve, @@ -23,6 +25,9 @@ use qsc_project::{PackageGraphSources, Project, ProjectType}; use std::mem::take; use std::sync::Arc; +#[cfg(test)] +mod openqasm_mode_tests; + /// Represents an immutable compilation state that can be used /// to implement language service features. #[derive(Debug)] @@ -56,6 +61,8 @@ pub(crate) enum CompilationKind { sources: Vec<(Arc, Arc)>, /// a human-readable name for the package (not a unique URI -- meant to be read by humans) friendly_name: Arc, + /// The mode this compilation actually ran in. + effective_mode: EffectiveOpenQasmMode, }, } @@ -244,6 +251,7 @@ impl Compilation { sources: Vec<(Arc, Arc)>, project_errors: Vec, friendly_name: &Arc, + requested_mode: OpenQasmMode, ) -> Self { let config = CompilerConfig::new( QubitSemantics::Qiskit, @@ -252,8 +260,14 @@ impl Compilation { Some("program".into()), None, ); + let res = qsc::openqasm::analyze_all(&sources); + let stage_one = stage_one_diagnostics(&res); let unit = compile_to_qsharp_ast_with_config(res, config); + // Lowering seeds the unit from the stage-1 set and appends, so a longer + // list means stage 2 rejected something the QDK cannot represent. + let stage_two_appended = unit.errors().len() > stage_one.len(); + let effective_mode = resolve_openqasm_mode(requested_mode, stage_two_appended); let target_profile = unit.profile().unwrap_or(Profile::Unrestricted); let CompileRawQasmResult(store, source_package_id, _, _sig, mut compile_errors, _) = qsc::openqasm::compile_openqasm(unit, package_type); @@ -262,13 +276,19 @@ impl Compilation { .get(source_package_id) .expect("expected to find user package"); - run_fir_passes( - &mut compile_errors, - target_profile, - &store, - source_package_id, - compile_unit, - ); + if effective_mode == EffectiveOpenQasmMode::Spec { + // Everything past semantic analysis describes the QDK's view of the + // program, which spec mode does not report on. + compile_errors = stage_one; + } else { + run_fir_passes( + &mut compile_errors, + target_profile, + &store, + source_package_id, + compile_unit, + ); + } Self { package_store: store, @@ -276,6 +296,7 @@ impl Compilation { kind: CompilationKind::OpenQASM { sources, friendly_name: friendly_name.clone(), + effective_mode, }, compile_errors, project_errors, @@ -283,6 +304,17 @@ impl Compilation { } } + /// Whether this compilation is an OpenQASM compilation running in spec mode. + pub(crate) fn is_openqasm_spec_mode(&self) -> bool { + matches!( + self.kind, + CompilationKind::OpenQASM { + effective_mode: EffectiveOpenQasmMode::Spec, + .. + } + ) + } + /// Returns a human-readable compilation name if one exists. /// Notebooks don't have human-readable compilation names. pub fn friendly_project_name(&self) -> Option> { @@ -346,6 +378,7 @@ impl Compilation { target_profile: Profile, language_features: LanguageFeatures, lints_config: &[LintOrGroupConfig], + openqasm_mode: OpenQasmMode, ) { let new = match self.kind { CompilationKind::OpenProject { @@ -378,11 +411,13 @@ impl Compilation { CompilationKind::OpenQASM { ref sources, ref friendly_name, + .. } => Self::new_qasm( package_type, sources.clone(), Vec::new(), // project errors will stay the same friendly_name, + openqasm_mode, ), }; @@ -390,9 +425,57 @@ impl Compilation { self.user_package_id = new.user_package_id; self.test_cases = new.test_cases; self.compile_errors = new.compile_errors; + // Carries the freshly resolved OpenQASM mode; equivalent to the old kind + // for the other compilation types. + self.kind = new.kind; } } +/// Applies the mode resolution order: an explicit `Qdk` or `Spec` wins, and +/// `Auto` selects `Spec` only when stage 2 rejected something. +/// +/// Detection is deliberately stage 2 only. A stage-3 or stage-4 failure means +/// the QDK tried and something else went wrong, and switching to spec mode +/// there would hide the only diagnostic explaining the failure. +fn resolve_openqasm_mode( + requested: OpenQasmMode, + stage_two_appended: bool, +) -> EffectiveOpenQasmMode { + match requested { + OpenQasmMode::Qdk => EffectiveOpenQasmMode::Qdk, + OpenQasmMode::Spec => EffectiveOpenQasmMode::Spec, + OpenQasmMode::Auto => { + if stage_two_appended { + EffectiveOpenQasmMode::Spec + } else { + EffectiveOpenQasmMode::Qdk + } + } + } +} + +/// Converts the OpenQASM semantic analysis diagnostics into the compilation's +/// error type. +fn stage_one_diagnostics(res: &AnalysisResult) -> Vec> { + res.all_errors() + .into_iter() + .map(|e| { + WithSource::from_map( + &to_qsharp_source_map(&res.source_map), + compile::ErrorKind::OpenQasm(e.into_error().into()), + ) + }) + .collect() +} + +fn to_qsharp_source_map(source_map: &ParseSourceMap) -> SourceMap { + let sources = source_map + .iter() + .map(|source| (source.name.clone(), source.contents.clone())); + let entry = source_map.entry().map(|source| source.contents.clone()); + SourceMap::new(sources, entry) +} + /// Runs the passes required for code generation /// appending any errors to the `errors` vector. /// This function only runs passes if there are no compile diff --git a/source/language_service/src/compilation/openqasm_mode_tests.rs b/source/language_service/src/compilation/openqasm_mode_tests.rs new file mode 100644 index 00000000000..6f106bb640c --- /dev/null +++ b/source/language_service/src/compilation/openqasm_mode_tests.rs @@ -0,0 +1,454 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Characterization tests recording which diagnostics reach an OpenQASM +//! `Compilation` today, and which pipeline stage produces them. +//! +//! Spec mode cuts diagnostics at stage 1 (semantic analysis) and detects +//! QDK incompatibility from stage 2 (lowering to Q# AST). Stage 3 (Q# +//! compilation) and stage 4 (FIR/capability passes) failures mean the QDK +//! tried and something else went wrong, so they must not select spec mode. +//! These fixtures pin one program per stage. + +use super::Compilation; +use super::stage_one_diagnostics; +use crate::compilation::CompilationKind; +use crate::protocol::{EffectiveOpenQasmMode, OpenQasmMode}; +use expect_test::{Expect, expect}; +use qsc::{PackageType, compile, error::WithSource}; +use std::sync::Arc; + +/// A pulse-level program. Every construct here is rejected during lowering +/// (stage 2) because the QDK has no representation for it. `stdgates.inc` is +/// included deliberately so the fixture produces no stage-1 diagnostics and +/// isolates stage 2. +pub(super) const PULSE_LEVEL: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +defcalgrammar "openpulse"; +cal { + extern frame drive_frame; +} +defcal x $0 { + delay[100ns] drive_frame; +} +x $0; +"#; + +/// A program the QDK compiles cleanly. No diagnostics from any stage. +pub(super) const QDK_COMPATIBLE: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +qubit[2] q; +bit[2] c; +h q[0]; +cx q[0], q[1]; +c = measure q; +"#; + +/// A program with a genuine OpenQASM error. Produced at stage 1, so it +/// survives into spec mode and does not select spec mode. +pub(super) const SPEC_ERROR: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +qubit[2] q; +h undefined_register[0]; +"#; + +/// A program that declares the Base profile then branches on a measurement +/// result, which Base does not permit. Clean through lowering, so the +/// diagnostic comes from stage 4. +pub(super) const CAPABILITY_VIOLATION: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +#pragma qdk.qir.profile Base +qubit q; +bit c; +c = measure q; +if (c == 1) { + x q; +} +"#; + +/// A program with a malformed declaration. The parser reports two distinct +/// errors for it, neither repeated. This fixture pins that count so a later +/// change that collects stage-1 errors a second time shows up as duplication. +pub(super) const SYNTAX_ERROR: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +qubit[2 q; +"#; + +/// A parent whose only error lives in an included file, used to prove an +/// include's diagnostic is reported once rather than once per stage. +pub(super) const INCLUDE_PARENT: &str = r#"OPENQASM 3.0; +include "stdgates.inc"; +include "broken.inc"; +"#; + +pub(super) const INCLUDE_CHILD: &str = "qubit[2 q;\n"; + +pub(super) fn compile(source: &str) -> Compilation { + compile_sources(&[("", source)]) +} + +pub(super) fn compile_sources(sources: &[(&str, &str)]) -> Compilation { + compile_sources_in_mode(sources, OpenQasmMode::Auto) +} + +pub(super) fn compile_in_mode(source: &str, mode: OpenQasmMode) -> Compilation { + compile_sources_in_mode(&[("", source)], mode) +} + +pub(super) fn compile_sources_in_mode(sources: &[(&str, &str)], mode: OpenQasmMode) -> Compilation { + Compilation::new_qasm( + PackageType::Lib, + sources + .iter() + .map(|(name, source)| (Arc::from(*name), Arc::from(*source))) + .collect(), + vec![], + &Arc::from("test project"), + mode, + ) +} + +/// The mode a fixture resolved to, for comparison in mode tests. +fn effective_mode(compilation: &Compilation) -> EffectiveOpenQasmMode { + match compilation.kind { + CompilationKind::OpenQASM { effective_mode, .. } => effective_mode, + _ => panic!("expected an OpenQASM compilation"), + } +} + +/// Renders code, message, and label spans, so a comparison catches a +/// conversion that keeps the code but loses the source attribution. +fn render(errors: &[WithSource]) -> Vec { + errors + .iter() + .map(|e| { + let code = miette::Diagnostic::code(e) + .map_or_else(|| "".to_string(), |code| code.to_string()); + let labels = miette::Diagnostic::labels(e).map_or_else(String::new, |labels| { + labels + .map(|l| format!("{}..{}", l.offset(), l.offset() + l.len())) + .collect::>() + .join(",") + }); + format!("{code} | {e} | [{labels}]") + }) + .collect() +} + +/// Renders each published diagnostic as its error code, or its message when +/// the diagnostic carries no code. Pinned to qdk mode so these keep recording +/// per-stage output rather than the mode's effect on it. +fn check_diagnostics(source: &str, expect: &Expect) { + let compilation = compile_in_mode(source, OpenQasmMode::Qdk); + let actual = compilation + .compile_errors + .iter() + .map(|e| miette::Diagnostic::code(e).map_or_else(|| e.to_string(), |code| code.to_string())) + .collect::>(); + expect.assert_debug_eq(&actual); +} + +#[test] +fn pulse_level_program_diagnostics() { + check_diagnostics( + PULSE_LEVEL, + &expect![[r#" + [ + "Qdk.Qasm.Compiler.NotSupported", + "Qdk.Qasm.Compiler.NotSupported", + "Qdk.Qasm.Compiler.NotSupported", + "Qdk.Qasm.Compiler.NotSupported", + ] + "#]], + ); +} + +#[test] +fn qdk_compatible_program_diagnostics() { + check_diagnostics( + QDK_COMPATIBLE, + &expect![[r#" + [] + "#]], + ); +} + +#[test] +fn spec_error_program_diagnostics() { + check_diagnostics( + SPEC_ERROR, + &expect![[r#" + [ + "Qdk.Qasm.Lowerer.UndefinedSymbol", + "Qdk.Qasm.Lowerer.CannotIndexType", + ] + "#]], + ); +} + +#[test] +fn capability_violation_program_diagnostics() { + check_diagnostics( + CAPABILITY_VIOLATION, + &expect![[r#" + [ + "Qdk.Qsc.CapabilitiesCk.UseOfDynamicBool", + "Qdk.Qsc.CapabilitiesCk.UseOfDynamicInt", + "Qdk.Qsc.CapabilitiesCk.UseOfDynamicBool", + "Qdk.Qsc.CapabilitiesCk.UseOfDynamicInt", + ] + "#]], + ); +} + +#[test] +fn syntax_error_program_diagnostics() { + check_diagnostics( + SYNTAX_ERROR, + &expect![[r#" + [ + "Qdk.Qasm.Parser.Token", + "Qdk.Qasm.Parser.Rule", + ] + "#]], + ); +} + +/// When a program's only errors come from stage 1, the converted stage-1 set +/// and the set published today must be indistinguishable. This is what proves +/// the conversion preserves code, message, and source attribution. +#[test] +fn stage_one_conversion_matches_published_diagnostics() { + for source in [SPEC_ERROR, SYNTAX_ERROR] { + let sources = vec![(Arc::from(""), Arc::from(source))]; + let res = qsc::openqasm::analyze_all(&sources); + let converted = stage_one_diagnostics(&res); + + let published = compile(source); + + assert_eq!(render(&converted), render(&published.compile_errors)); + assert!(!converted.is_empty(), "fixture should produce diagnostics"); + } +} + +/// Guards the stage-1 aggregation contract: parser errors must be included +/// exactly once alongside semantic errors. +#[test] +fn syntax_errors_are_not_duplicated() { + let sources = vec![(Arc::from(""), Arc::from(SYNTAX_ERROR))]; + let res = qsc::openqasm::analyze_all(&sources); + + let from_field = stage_one_diagnostics(&res); + let rendered = render(&from_field); + let mut deduped = rendered.clone(); + deduped.sort_unstable(); + deduped.dedup(); + + assert_eq!(rendered.len(), deduped.len(), "diagnostics were duplicated"); + assert_eq!(rendered.len(), res.all_errors().len()); + assert_eq!(rendered.len(), res.parse_errors().len()); +} + +#[test] +fn syntax_error_in_included_file_reported_once() { + let compilation = + compile_sources(&[("", INCLUDE_PARENT), ("broken.inc", INCLUDE_CHILD)]); + + let rendered = render(&compilation.compile_errors); + let mut deduped = rendered.clone(); + deduped.sort_unstable(); + deduped.dedup(); + + assert_eq!(rendered.len(), deduped.len(), "{rendered:#?}"); + expect![[r#" + [ + "Qdk.Qasm.Parser.Token | expected `]`, found identifier | [69..70]", + "Qdk.Qasm.Parser.Rule | expected identifier, found EOF | [72..72]", + ] + "#]] + .assert_debug_eq(&rendered); +} + +#[test] +fn auto_selects_spec_only_for_stage_two_failures() { + assert_eq!( + effective_mode(&compile(PULSE_LEVEL)), + EffectiveOpenQasmMode::Spec + ); + + // Every other fixture must stay in qdk mode. A stage-4 capability violation + // in particular is the QDK compiling the program and rejecting it for the + // selected profile, which spec mode would hide rather than explain. + for source in [QDK_COMPATIBLE, CAPABILITY_VIOLATION, SYNTAX_ERROR] { + assert_eq!( + effective_mode(&compile(source)), + EffectiveOpenQasmMode::Qdk, + "{source}" + ); + } +} + +/// The case that separates "your code is wrong" from "the QDK cannot compile +/// your code". A spec error is a stage-1 diagnostic, so it must not switch the +/// user into a mode where the QDK stops looking at their program. +#[test] +fn auto_keeps_qdk_mode_for_a_program_whose_only_error_is_a_spec_error() { + let compilation = compile(SPEC_ERROR); + + assert!(!compilation.compile_errors.is_empty()); + assert_eq!(effective_mode(&compilation), EffectiveOpenQasmMode::Qdk); +} + +#[test] +fn explicit_mode_ignores_detection() { + // PULSE_LEVEL would auto-detect as spec, QDK_COMPATIBLE as qdk. Neither + // detection result may override an explicit choice. + for source in [PULSE_LEVEL, QDK_COMPATIBLE] { + assert_eq!( + effective_mode(&compile_in_mode(source, OpenQasmMode::Qdk)), + EffectiveOpenQasmMode::Qdk, + "{source}" + ); + assert_eq!( + effective_mode(&compile_in_mode(source, OpenQasmMode::Spec)), + EffectiveOpenQasmMode::Spec, + "{source}" + ); + } +} + +/// Resolution order in isolation, including the precedence a session override +/// will rely on once it has a writer. +#[test] +fn override_beats_configuration_and_clearing_restores_it() { + let configured = OpenQasmMode::Qdk; + let resolve = |session_override: Option, stage_two_appended| { + super::resolve_openqasm_mode(session_override.unwrap_or(configured), stage_two_appended) + }; + + assert_eq!( + resolve(Some(OpenQasmMode::Spec), false), + EffectiveOpenQasmMode::Spec + ); + assert_eq!(resolve(None, false), EffectiveOpenQasmMode::Qdk); + assert_eq!( + resolve(Some(OpenQasmMode::Auto), true), + EffectiveOpenQasmMode::Spec + ); +} + +/// The mode must be re-resolved on recompilation. Without this, changing the +/// setting recompiles with whatever mode the first compile happened to use and +/// the setting looks inert. +#[test] +fn recompile_adopts_the_new_mode() { + let mut compilation = compile(QDK_COMPATIBLE); + assert_eq!(effective_mode(&compilation), EffectiveOpenQasmMode::Qdk); + + compilation.recompile( + PackageType::Lib, + qsc::target::Profile::Unrestricted, + qsc::LanguageFeatures::default(), + &[], + OpenQasmMode::Spec, + ); + + assert_eq!(effective_mode(&compilation), EffectiveOpenQasmMode::Spec); +} + +#[test] +fn spec_mode_publishes_nothing_for_a_qdk_unsupported_program() { + let compilation = compile_in_mode(PULSE_LEVEL, OpenQasmMode::Spec); + + assert_eq!( + render(&compilation.compile_errors), + Vec::::new(), + "spec mode must not report the QDK's inability to lower the program" + ); +} + +#[test] +fn spec_mode_still_publishes_spec_errors_exactly_once() { + for source in [SPEC_ERROR, SYNTAX_ERROR] { + let spec = render(&compile_in_mode(source, OpenQasmMode::Spec).compile_errors); + let qdk = render(&compile_in_mode(source, OpenQasmMode::Qdk).compile_errors); + + // These fixtures fail at stage 1, so both modes cut at the same place. + assert_eq!(spec, qdk, "{source}"); + + let mut deduped = spec.clone(); + deduped.sort_unstable(); + deduped.dedup(); + assert_eq!(spec.len(), deduped.len(), "{source}"); + } +} + +/// Spec mode must also drop stage-4 output, which `run_fir_passes` produces +/// after lowering has already succeeded. +#[test] +fn spec_mode_drops_capability_diagnostics() { + assert!( + !compile_in_mode(CAPABILITY_VIOLATION, OpenQasmMode::Qdk) + .compile_errors + .is_empty() + ); + assert!( + compile_in_mode(CAPABILITY_VIOLATION, OpenQasmMode::Spec) + .compile_errors + .is_empty() + ); +} + +/// Project errors describe file resolution failures, not the QDK's view of the +/// program, so spec mode's cut must not touch them. +#[test] +fn spec_mode_keeps_project_errors() { + let project_error = qsc::project::Error::FileSystem { + about_path: "missing.inc".to_string(), + error: "not found".to_string(), + }; + + let compilation = Compilation::new_qasm( + PackageType::Lib, + vec![(Arc::from(""), Arc::from(PULSE_LEVEL))], + vec![project_error], + &Arc::from("test project"), + OpenQasmMode::Spec, + ); + + assert!(compilation.compile_errors.is_empty()); + assert_eq!(compilation.project_errors.len(), 1); +} + +/// `add_unnecessary_code_diagnostics` runs on every compilation and derives +/// grey-out ranges from dropped spans. It is only inert for OpenQASM if +/// lowering never drops any, which is what makes it safe to leave alone in +/// spec mode. +#[test] +fn openqasm_lowering_drops_no_spans() { + for source in [ + PULSE_LEVEL, + QDK_COMPATIBLE, + SPEC_ERROR, + CAPABILITY_VIOLATION, + ] { + for mode in [OpenQasmMode::Qdk, OpenQasmMode::Spec] { + let compilation = compile_in_mode(source, mode); + assert!(compilation.user_unit().dropped_spans.is_empty(), "{source}"); + } + } +} + +/// Spec mode is only safe while QDK-only rejections remain after semantic +/// analysis. Moving one into stage 1 would make spec mode publish it. +#[test] +fn qdk_only_constructs_do_not_produce_stage_one_diagnostics() { + let sources = vec![(Arc::from(""), Arc::from(PULSE_LEVEL))]; + let res = qsc::openqasm::analyze_all(&sources); + + assert!( + stage_one_diagnostics(&res).is_empty(), + "PULSE_LEVEL must remain clean through stage 1 for spec mode to hide \ + only QDK-only diagnostics" + ); +} diff --git a/source/language_service/src/lib.rs b/source/language_service/src/lib.rs index 615838db0ec..4c375d5be11 100644 --- a/source/language_service/src/lib.rs +++ b/source/language_service/src/lib.rs @@ -27,8 +27,8 @@ use futures::channel::oneshot; use futures_util::StreamExt; use log::{trace, warn}; use protocol::{ - CodeAction, CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, - TestCallables, TextEdit, WorkspaceConfigurationUpdate, + CodeAction, CodeLens, CompletionList, DiagnosticUpdate, Hover, ModeResolved, NotebookMetadata, + OpenQasmMode, SignatureHelp, TestCallables, TextEdit, WorkspaceConfigurationUpdate, }; use qsc::{ line_column::{Encoding, Position, Range}, @@ -133,6 +133,7 @@ impl LanguageService { // Callback which receives detected test callables and does something with them // in the case of VS Code, updates the test explorer with them test_callable_receiver: impl Fn(TestCallables) + 'a, + mode_resolved_receiver: impl Fn(ModeResolved) + 'a, project_host: impl JSProjectHost + 'static, ) -> UpdateHandler<'a> { assert!(self.state_updater.is_none()); @@ -142,6 +143,7 @@ impl LanguageService { self.state.clone(), diagnostics_receiver, test_callable_receiver, + mode_resolved_receiver, project_host, self.position_encoding, ), @@ -289,6 +291,18 @@ impl LanguageService { } } + #[must_use] + pub fn get_openqasm_mode(&self, uri: &str) -> Option { + self.state.borrow().get_openqasm_mode(uri) + } + + pub fn set_openqasm_mode_override(&mut self, uri: &str, mode: Option) { + self.send_update(Update::OpenQasmModeOverride { + uri: uri.into(), + mode, + }); + } + #[must_use] pub fn get_code_actions(&self, uri: &str, range: Range) -> Vec { self.document_op( @@ -578,7 +592,8 @@ fn push_update(pending_updates: &mut Vec, update: Update) { } Update::Configuration { .. } | Update::CloseDocument { .. } - | Update::CloseNotebookDocument { .. } => (), // These events aren't noisy enough to bother deduping. + | Update::CloseNotebookDocument { .. } + | Update::OpenQasmModeOverride { .. } => (), // These events aren't noisy enough to bother deduping. } pending_updates.push(update); } @@ -619,6 +634,9 @@ async fn apply_update(updater: &mut CompilationStateUpdater<'_>, update: Update) Update::Configuration { changed } => { updater.update_configuration(changed); } + Update::OpenQasmModeOverride { uri, mode } => { + updater.set_openqasm_mode_override(&uri, mode); + } } } @@ -644,6 +662,10 @@ enum Update { CloseNotebookDocument { notebook_uri: String, }, + OpenQasmModeOverride { + uri: String, + mode: Option, + }, } impl Update { @@ -659,6 +681,9 @@ impl Update { Update::CloseNotebookDocument { notebook_uri } => { format!("CloseNotebookDocument({notebook_uri})") } + Update::OpenQasmModeOverride { uri, mode } => { + format!("OpenQasmModeOverride({uri}, {mode:?})") + } } } } diff --git a/source/language_service/src/protocol.rs b/source/language_service/src/protocol.rs index f3c80fe12de..9c7d743ff25 100644 --- a/source/language_service/src/protocol.rs +++ b/source/language_service/src/protocol.rs @@ -17,6 +17,31 @@ pub struct WorkspaceConfigurationUpdate { pub language_features: Option, pub lints_config: Option>, pub dev_diagnostics: Option, + pub openqasm_mode: Option, +} + +/// The configured OpenQASM editing mode. `Auto` is not an effective mode; it +/// defers the choice to per-compilation resolution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OpenQasmMode { + #[default] + Auto, + Qdk, + Spec, +} + +/// The mode a compilation actually ran in. Unlike `OpenQasmMode`, this has no +/// deferred value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EffectiveOpenQasmMode { + Qdk, + Spec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModeResolved { + pub uri: String, + pub mode: EffectiveOpenQasmMode, } #[derive(Clone, Debug, Diagnostic, Error)] diff --git a/source/language_service/src/state.rs b/source/language_service/src/state.rs index 41ed2bb609e..26283b5fb60 100644 --- a/source/language_service/src/state.rs +++ b/source/language_service/src/state.rs @@ -7,9 +7,10 @@ mod tests; use crate::protocol::{DocumentStatusDiagnostic, TestCallable, UnnecessaryCodeDiagnostic}; use crate::qsc_utils::into_range; -use super::compilation::Compilation; +use super::compilation::{Compilation, CompilationKind}; use super::protocol::{ - DiagnosticUpdate, ErrorKind, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate, + DiagnosticUpdate, EffectiveOpenQasmMode, ErrorKind, ModeResolved, NotebookMetadata, + OpenQasmMode, TestCallables, WorkspaceConfigurationUpdate, }; use log::{debug, trace}; use miette::Diagnostic; @@ -70,6 +71,8 @@ struct Configuration { pub lints_config: Vec, /// Enables non-user-facing developer diagnostics. pub dev_diagnostics: bool, + /// The configured OpenQASM mode. `Auto` defers to per-compilation resolution. + pub openqasm_mode: OpenQasmMode, } impl Default for Configuration { @@ -80,6 +83,7 @@ impl Default for Configuration { language_features: LanguageFeatures::default(), lints_config: Vec::default(), dev_diagnostics: false, + openqasm_mode: OpenQasmMode::Auto, } } } @@ -112,6 +116,10 @@ pub(super) struct CompilationStateUpdater<'a> { diagnostics_receiver: Box, /// Callback which will receive test callables whenever a (re-)compilation occurs. test_callable_receiver: Box, + /// Callback which receives resolved OpenQASM modes after every compilation. + mode_resolved_receiver: Box, + /// Session-scoped OpenQASM mode overrides, keyed by compilation URI. + openqasm_mode_overrides: FxHashMap, cache: RefCell, /// Functions to interact with the host filesystem for project system operations. project_host: Box, @@ -124,6 +132,7 @@ impl<'a> CompilationStateUpdater<'a> { state: Rc>, diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a, test_callable_receiver: impl Fn(TestCallables) + 'a, + mode_resolved_receiver: impl Fn(ModeResolved) + 'a, project_host: impl JSProjectHost + 'static, position_encoding: Encoding, ) -> Self { @@ -133,6 +142,8 @@ impl<'a> CompilationStateUpdater<'a> { documents_with_diagnostics: FxHashSet::default(), diagnostics_receiver: Box::new(diagnostics_receiver), test_callable_receiver: Box::new(test_callable_receiver), + mode_resolved_receiver: Box::new(mode_resolved_receiver), + openqasm_mode_overrides: FxHashMap::default(), cache: RefCell::default(), project_host: Box::new(project_host), position_encoding, @@ -194,6 +205,27 @@ impl<'a> CompilationStateUpdater<'a> { self.publish_diagnostics_and_test_callables(); } + pub(super) fn set_openqasm_mode_override( + &mut self, + document_uri: &str, + mode: Option, + ) { + let Some(compilation_uri) = self.openqasm_compilation_uri_for_document(document_uri) else { + return; + }; + + match mode { + Some(mode) => { + self.openqasm_mode_overrides.insert(compilation_uri, mode); + } + None => { + self.openqasm_mode_overrides.remove(&compilation_uri); + } + } + + self.recompile_all(); + } + async fn load_project_from_doc_uri( &mut self, doc_uri: &Arc, @@ -313,6 +345,7 @@ impl<'a> CompilationStateUpdater<'a> { sources, loaded_project.errors, &loaded_project.name, + self.openqasm_mode_for(&loaded_project.path), ), ProjectType::QSharp(package_graph_sources) => Compilation::new( configuration.package_type, @@ -329,6 +362,8 @@ impl<'a> CompilationStateUpdater<'a> { .compilations .insert(loaded_project.path, (compilation, compilation_overrides)); }); + + self.publish_openqasm_modes(); } pub(super) async fn close_document(&mut self, uri: &str, language_id: &str) { @@ -474,7 +509,13 @@ impl<'a> CompilationStateUpdater<'a> { let mut docs_with_diags = FxHashSet::default(); self.with_state(|state| { - for (compilation_uri, compilation) in &state.compilations { + // Spec-mode compilations are visited first so the documents they + // cover cannot be claimed by another compilation that would report + // QDK diagnostics for them. + let mut compilations: Vec<_> = state.compilations.iter().collect(); + compilations.sort_by_key(|(_, (compilation, _))| !compilation.is_openqasm_spec_mode()); + + for (compilation_uri, compilation) in compilations { trace!("publishing diagnostics for {compilation_uri}"); if compilation_uri.starts_with(qsc_project::GITHUB_SCHEME) { @@ -501,6 +542,21 @@ impl<'a> CompilationStateUpdater<'a> { &mut compilation_diags_by_doc, ); + if let CompilationKind::OpenQASM { + sources, + effective_mode: EffectiveOpenQasmMode::Spec, + .. + } = &compilation.0.kind + { + // A spec-mode compilation often has nothing to report, and a + // compilation that reports nothing claims no documents. Claim + // its sources explicitly so no other compilation publishes QDK + // diagnostics into a file this one covers. + for (name, _) in sources { + compilation_diags_by_doc.entry(name.clone()).or_default(); + } + } + if self.configuration.dev_diagnostics { // Add the document status diagnostic for all open documents too for (uri, open_document) in &state.open_documents { @@ -539,6 +595,36 @@ impl<'a> CompilationStateUpdater<'a> { self.documents_with_diagnostics = docs_with_diags; } + fn publish_openqasm_modes(&self) { + let updates = self.with_state(|state| { + state + .compilations + .values() + .filter_map(|(compilation, _)| match &compilation.kind { + CompilationKind::OpenQASM { + sources, + effective_mode, + .. + } => Some( + sources + .iter() + .map(|(uri, _)| ModeResolved { + uri: uri.to_string(), + mode: *effective_mode, + }) + .collect::>(), + ), + CompilationKind::OpenProject { .. } | CompilationKind::Notebook { .. } => None, + }) + .flatten() + .collect::>() + }); + + for update in updates { + (self.mode_resolved_receiver)(update); + } + } + fn publish_diagnostics_for_doc( &self, state: &CompilationState, @@ -585,6 +671,11 @@ impl<'a> CompilationStateUpdater<'a> { self.configuration.dev_diagnostics = dev_diagnostics; } + if let Some(openqasm_mode) = configuration.openqasm_mode { + need_recompile |= self.configuration.openqasm_mode != openqasm_mode; + self.configuration.openqasm_mode = openqasm_mode; + } + // Possible optimization: some projects will have overrides for these configurations, // so workspace updates won't impact them. We could exclude those projects // from recompilation, but we don't right now. @@ -597,7 +688,7 @@ impl<'a> CompilationStateUpdater<'a> { /// diagnostics for all documents. fn recompile_all(&mut self) { self.with_state_mut(|state| { - for (compilation, package_specific_configuration) in state.compilations.values_mut() { + for (uri, (compilation, package_specific_configuration)) in &mut state.compilations { let configuration = merge_configurations(package_specific_configuration, &self.configuration); let lints_config = package_specific_configuration.lints_config.clone(); @@ -606,13 +697,29 @@ impl<'a> CompilationStateUpdater<'a> { configuration.target_profile, configuration.language_features, &lints_config, + self.openqasm_mode_for(uri), ); } }); + self.publish_openqasm_modes(); self.publish_diagnostics_and_test_callables(); } + /// The mode to compile the given compilation root in. The single place + /// where a per-compilation override would take precedence over the + /// workspace setting. + fn openqasm_mode_for(&self, compilation_uri: &str) -> OpenQasmMode { + self.openqasm_mode_overrides + .get(compilation_uri) + .copied() + .unwrap_or(self.configuration.openqasm_mode) + } + + fn openqasm_compilation_uri_for_document(&self, document_uri: &str) -> Option { + self.with_state(|state| state.openqasm_compilation_uri_for_document(document_uri)) + } + /// Borrows the compilation state immutably and invokes `f`. /// Warning: This function is not reentrant. For dynamic borrow safety, /// don't call `with_state` from within `with_state` or `with_state_mut`. @@ -696,6 +803,45 @@ impl CompilationState { panic!("document associated with compilation that hasn't been initialized ({compilation_uri})") }).0) } + + pub(crate) fn get_openqasm_mode(&self, document_uri: &str) -> Option { + let compilation_uri = self.openqasm_compilation_uri_for_document(document_uri)?; + let compilation = &self.compilations.get(&compilation_uri)?.0; + let CompilationKind::OpenQASM { effective_mode, .. } = compilation.kind else { + return None; + }; + Some(effective_mode) + } + + fn openqasm_compilation_uri_for_document(&self, document_uri: &str) -> Option { + let spec_compilation = + self.compilations + .iter() + .find_map(|(compilation_uri, (compilation, _))| { + let CompilationKind::OpenQASM { + sources, + effective_mode: EffectiveOpenQasmMode::Spec, + .. + } = &compilation.kind + else { + return None; + }; + + sources + .iter() + .any(|(source_uri, _)| source_uri.as_ref() == document_uri) + .then(|| compilation_uri.clone()) + }); + + spec_compilation.or_else(|| { + let compilation_uri = self.open_documents.get(document_uri)?.compilation.clone(); + matches!( + self.compilations.get(&compilation_uri)?.0.kind, + CompilationKind::OpenQASM { .. } + ) + .then_some(compilation_uri) + }) + } } fn map_errors_to_docs( @@ -800,5 +946,6 @@ fn merge_configurations( .unwrap_or(workspace_scope.language_features), lints_config: merged_lints, dev_diagnostics: workspace_scope.dev_diagnostics, + openqasm_mode: workspace_scope.openqasm_mode, } } diff --git a/source/language_service/src/state/tests.rs b/source/language_service/src/state/tests.rs index 70afe529575..61ee7ed7c42 100644 --- a/source/language_service/src/state/tests.rs +++ b/source/language_service/src/state/tests.rs @@ -6,7 +6,11 @@ use super::{CompilationState, CompilationStateUpdater}; use crate::{ - protocol::{DiagnosticUpdate, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate}, + compilation::CompilationKind, + protocol::{ + DiagnosticUpdate, EffectiveOpenQasmMode, ModeResolved, NotebookMetadata, OpenQasmMode, + TestCallables, WorkspaceConfigurationUpdate, + }, tests::test_fs::{FsNode, TestProjectHost, dir, file}, }; use expect_test::{Expect, expect}; @@ -39,6 +43,44 @@ async fn no_error() { expect_errors(&errors, &expect!["[]"]); } +#[tokio::test] +async fn openqasm_mode_override_recompiles_and_notifies() { + let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let modes = RefCell::new(Vec::::new()); + let fs = Rc::new(RefCell::new(FsNode::Dir( + [dir("single", [file("test.qasm", "OPENQASM 3.0;")])] + .into_iter() + .collect(), + ))); + let mut updater = new_updater_with_modes(&errors, &test_cases, &modes, &fs); + + updater + .update_document("single/test.qasm", 1, "OPENQASM 3.0;", "openqasm") + .await; + + assert_eq!( + updater.openqasm_compilation_uri_for_document("single/test.qasm"), + Some("single/test.qasm".into()) + ); + assert_eq!( + modes.borrow().last().map(|update| update.mode), + Some(EffectiveOpenQasmMode::Qdk) + ); + + updater.set_openqasm_mode_override("single/test.qasm", Some(OpenQasmMode::Spec)); + assert_eq!( + modes.borrow().last().map(|update| update.mode), + Some(EffectiveOpenQasmMode::Spec) + ); + + updater.set_openqasm_mode_override("single/test.qasm", None); + assert_eq!( + modes.borrow().last().map(|update| update.mode), + Some(EffectiveOpenQasmMode::Qdk) + ); +} + #[tokio::test] async fn generic_function_returning_break_does_not_crash() { let errors = RefCell::new(Vec::new()); @@ -526,6 +568,180 @@ async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() { ); } +#[tokio::test] +async fn openqasm_mode_defaults_to_auto_and_recompiles_only_on_change() { + let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); + + assert_eq!(updater.configuration.openqasm_mode, OpenQasmMode::Auto); + + let changed_to_spec = updater.apply_configuration(WorkspaceConfigurationUpdate { + openqasm_mode: Some(OpenQasmMode::Spec), + ..WorkspaceConfigurationUpdate::default() + }); + assert!(changed_to_spec, "a new mode should trigger recompilation"); + assert_eq!(updater.configuration.openqasm_mode, OpenQasmMode::Spec); + + let set_again = updater.apply_configuration(WorkspaceConfigurationUpdate { + openqasm_mode: Some(OpenQasmMode::Spec), + ..WorkspaceConfigurationUpdate::default() + }); + assert!( + !set_again, + "an unchanged mode should not trigger recompilation" + ); + + let unrelated_update = updater.apply_configuration(WorkspaceConfigurationUpdate { + openqasm_mode: None, + ..WorkspaceConfigurationUpdate::default() + }); + assert!(!unrelated_update); + assert_eq!(updater.configuration.openqasm_mode, OpenQasmMode::Spec); +} + +/// Guards the recompilation path specifically: `update_configuration` reaches +/// existing compilations through `recompile_all`, so a mode not threaded there +/// would make the setting appear to work only until the first recompile. +#[tokio::test] +async fn openqasm_mode_change_reaches_existing_compilations() { + let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); + + updater + .update_document( + "openqasm_files/self-contained.qasm", + 1, + "include \"stdgates.inc\";\nqubit q;\nh q;\n", + "openqasm", + ) + .await; + + let mode_of = |updater: &CompilationStateUpdater| { + updater.with_state(|state| { + let (compilation, _) = state + .compilations + .get("openqasm_files/self-contained.qasm") + .expect("compilation should exist"); + match compilation.kind { + CompilationKind::OpenQASM { effective_mode, .. } => effective_mode, + _ => panic!("expected an OpenQASM compilation"), + } + }) + }; + + assert_eq!(mode_of(&updater), EffectiveOpenQasmMode::Qdk); + + updater.update_configuration(WorkspaceConfigurationUpdate { + openqasm_mode: Some(OpenQasmMode::Spec), + ..WorkspaceConfigurationUpdate::default() + }); + + assert_eq!(mode_of(&updater), EffectiveOpenQasmMode::Spec); +} + +/// A file can belong to a spec-mode parent compilation and also be open as its +/// own compilation. Spec mode's promise is that the QDK stops reporting on the +/// files it covers, so the second compilation must not publish into it. +#[tokio::test] +async fn spec_mode_parent_claims_its_included_files() { + let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); + + updater + .update_document( + "openqasm_files/imports.inc", + 1, + "#pragma qdk.qir.profile Base\nqubit qq;\nbit cc;\ncc = measure qq;\nif (cc == 1) { reset qq; }\n", + "openqasm", + ) + .await; + + expect_errors( + &errors, + &expect![[r#" + [ + uri: "openqasm_files/imports.inc" version: Some(1) errors: [ + cannot use a dynamic bool value + [openqasm_files/imports.inc] [if (cc == 1) { reset qq; }] + cannot use a dynamic integer value + [openqasm_files/imports.inc] [if (cc == 1) { reset qq; }] + cannot use a dynamic bool value + [openqasm_files/imports.inc] [cc == 1] + cannot use a dynamic integer value + [openqasm_files/imports.inc] [cc == 1] + ], + ]"#]], + ); + + updater + .update_document( + "openqasm_files/multifile.qasm", + 1, + "include \"stdgates.inc\";\ninclude \"imports.inc\";\ndefcalgrammar \"openpulse\";\nqubit q;\nh q;\n", + "openqasm", + ) + .await; + + expect_errors( + &errors, + &expect![[r#" + [ + uri: "openqasm_files/multifile.qasm" version: Some(1) errors: [], + + uri: "openqasm_files/imports.inc" version: Some(1) errors: [], + ]"#]], + ); + + assert_eq!( + updater.openqasm_compilation_uri_for_document("openqasm_files/imports.inc"), + Some("openqasm_files/multifile.qasm".into()) + ); +} + +#[tokio::test] +async fn standalone_openqasm_include_resolves_spec_mode_independently() { + let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); + + updater.update_configuration(WorkspaceConfigurationUpdate { + openqasm_mode: Some(OpenQasmMode::Spec), + ..WorkspaceConfigurationUpdate::default() + }); + + updater + .update_document( + "openqasm_files/imports.inc", + 1, + "OPENQASM 3.0;\ndefcalgrammar \"openpulse\";\nqubit q;\n", + "openqasm", + ) + .await; + + let mode = updater.with_state(|state| { + let (compilation, _) = state + .compilations + .get("openqasm_files/imports.inc") + .expect("standalone include should have a compilation"); + match compilation.kind { + CompilationKind::OpenQASM { effective_mode, .. } => effective_mode, + _ => panic!("expected an OpenQASM compilation"), + } + }); + + assert_eq!(mode, EffectiveOpenQasmMode::Spec); + expect_errors( + &errors, + &expect![[r#" + [ + uri: "openqasm_files/imports.inc" version: Some(1) errors: [], + ]"#]], + ); +} + #[tokio::test] async fn package_type_update_causes_error() { let errors = RefCell::new(Vec::new()); @@ -2894,6 +3110,7 @@ fn new_updater<'a>( Rc::new(RefCell::new(CompilationState::default())), diagnostic_receiver, test_callable_receiver, + |_| {}, TestProjectHost { fs: TEST_FS.with(Clone::clone), }, @@ -2901,6 +3118,22 @@ fn new_updater<'a>( ) } +fn new_updater_with_modes<'a>( + received_errors: &'a RefCell>, + received_test_cases: &'a RefCell>, + received_modes: &'a RefCell>, + fs: &Rc>, +) -> CompilationStateUpdater<'a> { + CompilationStateUpdater::new( + Rc::new(RefCell::new(CompilationState::default())), + move |update| received_errors.borrow_mut().push(update), + move |update| received_test_cases.borrow_mut().push(update), + move |update| received_modes.borrow_mut().push(update), + TestProjectHost { fs: fs.clone() }, + Encoding::Utf8, + ) +} + fn new_updater_with_file_system<'a>( received_errors: &'a RefCell>, received_test_cases: &'a RefCell>, @@ -2920,6 +3153,7 @@ fn new_updater_with_file_system<'a>( Rc::new(RefCell::new(CompilationState::default())), diagnostic_receiver, test_callable_receiver, + |_| {}, TestProjectHost { fs: fs.clone() }, Encoding::Utf8, ) diff --git a/source/language_service/src/test_utils/openqasm.rs b/source/language_service/src/test_utils/openqasm.rs index c3e1881861b..00a6af013a8 100644 --- a/source/language_service/src/test_utils/openqasm.rs +++ b/source/language_service/src/test_utils/openqasm.rs @@ -3,6 +3,7 @@ use super::get_sources_and_markers; use crate::Compilation; +use crate::protocol::OpenQasmMode; use qsc::{ PackageType, line_column::{Position, Range}, @@ -21,6 +22,7 @@ fn compile_project_with_markers_cursor_optional( sources, vec![], &Arc::from("test project"), + OpenQasmMode::Auto, ), cursor_location, target_spans, diff --git a/source/language_service/src/tests.rs b/source/language_service/src/tests.rs index 63e04ff12c4..faf7b8409c1 100644 --- a/source/language_service/src/tests.rs +++ b/source/language_service/src/tests.rs @@ -309,6 +309,7 @@ async fn package_aware_foreign_fir_transform_diagnostic() { })); }, |_| {}, + |_| {}, TestProjectHost { fs }, ); @@ -679,6 +680,7 @@ fn create_update_handler<'a>( let mut v = received_test_cases.borrow_mut(); v.push(update); }, + |_| {}, TestProjectHost { fs: TEST_FS.with(Clone::clone), }, diff --git a/source/npm/qsharp/src/compiler/compiler.ts b/source/npm/qsharp/src/compiler/compiler.ts index 1f34f2c47bf..ad999fe3293 100644 --- a/source/npm/qsharp/src/compiler/compiler.ts +++ b/source/npm/qsharp/src/compiler/compiler.ts @@ -132,6 +132,9 @@ export class Compiler implements ICompiler { () => { // do nothing; test callables are not reported in checkCode }, + () => { + // do nothing; OpenQASM mode resolution is not reported in checkCode + }, { readFile: async () => null, listDirectory: async () => [], diff --git a/source/npm/qsharp/src/language-service/language-service.ts b/source/npm/qsharp/src/language-service/language-service.ts index 94fa6087e10..fbd5611797b 100644 --- a/source/npm/qsharp/src/language-service/language-service.ts +++ b/source/npm/qsharp/src/language-service/language-service.ts @@ -43,9 +43,18 @@ export type LanguageServiceTestCallablesEvent = { }; }; +export type LanguageServiceModeResolvedEvent = { + type: "modeResolved"; + detail: { + uri: string; + mode: "qdk" | "spec"; + }; +}; + export type LanguageServiceEvent = | LanguageServiceDiagnosticEvent - | LanguageServiceTestCallablesEvent; + | LanguageServiceTestCallablesEvent + | LanguageServiceModeResolvedEvent; /** * A completion list, plus whether the caller should ask again as the user keeps typing. @@ -88,6 +97,11 @@ export interface ILanguageService { ): Promise; closeDocument(uri: string, languageId?: string): Promise; closeNotebookDocument(notebookUri: string): Promise; + getOpenQasmMode(documentUri: string): Promise<"qdk" | "spec" | undefined>; + setOpenQasmModeOverride( + documentUri: string, + mode: "qdk" | "spec" | undefined, + ): Promise; getCodeActions(documentUri: string, range: IRange): Promise; getCompletions( documentUri: string, @@ -196,6 +210,7 @@ export class QSharpLanguageService implements ILanguageService { this.updateLoop = this.languageService.start_update_loop( this.onDiagnostics.bind(this), this.onTestCallables.bind(this), + this.onModeResolved.bind(this), host, createHostYield(), ); @@ -236,6 +251,20 @@ export class QSharpLanguageService implements ILanguageService { this.languageService.close_notebook_document(documentUri); } + async getOpenQasmMode( + documentUri: string, + ): Promise<"qdk" | "spec" | undefined> { + const mode = this.languageService.get_openqasm_mode(documentUri); + return mode === "qdk" || mode === "spec" ? mode : undefined; + } + + async setOpenQasmModeOverride( + documentUri: string, + mode: "qdk" | "spec" | undefined, + ): Promise { + this.languageService.set_openqasm_mode_override(documentUri, mode); + } + async getCodeActions( documentUri: string, range: IRange, @@ -381,6 +410,18 @@ export class QSharpLanguageService implements ILanguageService { log.error("Error in onTestCallables", e); } } + + async onModeResolved(uri: string, mode: "qdk" | "spec") { + try { + const event = new Event( + "modeResolved", + ) as LanguageServiceModeResolvedEvent & Event; + event.detail = { uri, mode }; + this.eventHandler.dispatchEvent(event); + } catch (e) { + log.error("Error in onModeResolved", e); + } + } } /** @@ -390,7 +431,7 @@ export class QSharpLanguageService implements ILanguageService { */ export const languageServiceProtocol: ServiceProtocol< ILanguageService, - LanguageServiceDiagnosticEvent + LanguageServiceEvent > = { class: QSharpLanguageService, methods: { @@ -399,6 +440,8 @@ export const languageServiceProtocol: ServiceProtocol< updateNotebookDocument: "request", closeDocument: "request", closeNotebookDocument: "request", + getOpenQasmMode: "request", + setOpenQasmModeOverride: "request", getCodeActions: "request", getCompletions: "request", getFormatChanges: "request", @@ -413,5 +456,5 @@ export const languageServiceProtocol: ServiceProtocol< addEventListener: "addEventListener", removeEventListener: "removeEventListener", }, - eventNames: ["diagnostics"], + eventNames: ["diagnostics", "testCallables", "modeResolved"], }; diff --git a/source/npm/qsharp/src/main.ts b/source/npm/qsharp/src/main.ts index 8f67222683e..0b9e339a7ef 100644 --- a/source/npm/qsharp/src/main.ts +++ b/source/npm/qsharp/src/main.ts @@ -232,6 +232,7 @@ export type { ILanguageServiceWorker, LanguageServiceDiagnosticEvent, LanguageServiceEvent, + LanguageServiceModeResolvedEvent, LanguageServiceTestCallablesEvent, } from "./language-service/language-service.js"; export type { DiagnosticsPublisherImpl } from "./language-service/diagnosticsPublisher.js"; diff --git a/source/vscode/package.json b/source/vscode/package.json index ab4b90d50f9..b85fd9f2864 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -100,152 +100,186 @@ } ] }, - "configuration": { - "title": "Q#", - "properties": { - "Q#.circuits.config": { - "markdownDescription": "Circuit diagram options", - "type": "object", - "additionalProperties": false, - "default": { - "maxOperations": 10001, - "generationMethod": "static", - "sourceLocations": true, - "groupByScope": true - }, - "properties": { - "maxOperations": { - "type": "number", - "default": 10001, - "minimum": 1, - "description": "The maximum number of operations to include in the circuit diagram." - }, - "groupByScope": { - "type": "boolean", - "default": true, - "description": "Group operations based on the structure of the original source code." - }, - "generationMethod": { - "type": "string", - "default": "static", - "enum": [ - "static", - "classicalEval", - "simulate" - ], - "description": "The method to use for generating the circuit diagram. 'static' uses static analysis and partial evaluation, 'classicalEval' uses only classical evaluation, and 'simulate' uses quantum simulation." + "configuration": [ + { + "title": "Q#", + "properties": { + "Q#.circuits.config": { + "markdownDescription": "Circuit diagram options", + "type": "object", + "additionalProperties": false, + "default": { + "maxOperations": 10001, + "generationMethod": "static", + "sourceLocations": true, + "groupByScope": true }, - "sourceLocations": { - "type": "boolean", - "default": true, - "description": "Show the source code locations of operations and qubit declarations in the circuit diagram." + "properties": { + "maxOperations": { + "type": "number", + "default": 10001, + "minimum": 1, + "description": "The maximum number of operations to include in the circuit diagram." + }, + "groupByScope": { + "type": "boolean", + "default": true, + "description": "Group operations based on the structure of the original source code." + }, + "generationMethod": { + "type": "string", + "default": "static", + "enum": [ + "static", + "classicalEval", + "simulate" + ], + "description": "The method to use for generating the circuit diagram. 'static' uses static analysis and partial evaluation, 'classicalEval' uses only classical evaluation, and 'simulate' uses quantum simulation." + }, + "sourceLocations": { + "type": "boolean", + "default": true, + "description": "Show the source code locations of operations and qubit declarations in the circuit diagram." + } } - } - }, - "Q#.simulation.pauliNoise": { - "markdownDescription": "The Pauli noise to apply when running multiple shots via the Histogram command. This is applied for every gate or measurement on all qubits referenced.\n\nProbability values are in the range [0, 1].", - "type": "object", - "additionalProperties": false, - "properties": { - "X": { - "type": "number", - "default": 0, - "minimum": 0, - "maximum": 1, - "description": "The probability of a bit flip error occurring" - }, - "Y": { - "type": "number", - "default": 0, - "minimum": 0, - "maximum": 1, - "description": "The probability of a bit-and-phase flip error occurring" - }, - "Z": { - "type": "number", - "default": 0, - "minimum": 0, - "maximum": 1, - "description": "The probability of a phase flip error occurring" + }, + "Q#.simulation.pauliNoise": { + "markdownDescription": "The Pauli noise to apply when running multiple shots via the Histogram command. This is applied for every gate or measurement on all qubits referenced.\n\nProbability values are in the range [0, 1].", + "type": "object", + "additionalProperties": false, + "properties": { + "X": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 1, + "description": "The probability of a bit flip error occurring" + }, + "Y": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 1, + "description": "The probability of a bit-and-phase flip error occurring" + }, + "Z": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 1, + "description": "The probability of a phase flip error occurring" + } } - } - }, - "Q#.simulation.qubitLoss": { - "markdownDescription": "The probability of a qubit loss occurring when running multiple shots via the Histogram command. This is applied on every gate or measurement on all qubits referenced.\n\nProbability values are in the range [0, 1].", - "type": "number", - "default": 0, - "minimum": 0, - "maximum": 1, - "description": "The probability of a qubit loss occurring" - }, - "Q#.dev.showDevDiagnostics": { - "type": "boolean", - "default": false, - "description": "Show dev diagnostics in Problems view for each open document. This is for internal development and testing purposes.", - "tags": [ - "hidden" - ] - }, - "Q#.notifications.suppressUpdateNotifications": { - "type": "boolean", - "default": false, - "description": "Suppress notifications about new QDK updates. If true, you will not be prompted about new features after updates." - }, - "Q#.azure.uploadSupplementalData": { - "type": "boolean", - "default": true, - "tags": [ - "hidden" - ], - "description": "Upload supplemental input data (circuit diagram) to the storage container when submitting jobs to Azure Quantum." - }, - "Q#.azure.quantumOsRoot": { - "type": "string", - "default": "https://manage.quantum.microsoft.com", - "tags": [ - "hidden" - ], - "description": "The root URL of the Quantum OS web portal, used when generating deep links to V2 workspaces and jobs." - }, - "Q#.azure.targetJobParams": { - "type": "object", - "default": {}, - "tags": [ - "hidden" - ], - "markdownDescription": "Additional job parameters to include in `inputParams` when submitting to a specific target. Keys are target IDs (e.g. `ionq.simulator`), values are objects merged into the job's `inputParams`.", - "additionalProperties": { + }, + "Q#.simulation.qubitLoss": { + "markdownDescription": "The probability of a qubit loss occurring when running multiple shots via the Histogram command. This is applied on every gate or measurement on all qubits referenced.\n\nProbability values are in the range [0, 1].", + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 1, + "description": "The probability of a qubit loss occurring" + }, + "Q#.dev.showDevDiagnostics": { + "type": "boolean", + "default": false, + "description": "Show dev diagnostics in Problems view for each open document. This is for internal development and testing purposes.", + "tags": [ + "hidden" + ] + }, + "Q#.notifications.suppressUpdateNotifications": { + "type": "boolean", + "default": false, + "description": "Suppress notifications about new QDK updates. If true, you will not be prompted about new features after updates." + }, + "Q#.azure.uploadSupplementalData": { + "type": "boolean", + "default": true, + "tags": [ + "hidden" + ], + "description": "Upload supplemental input data (circuit diagram) to the storage container when submitting jobs to Azure Quantum." + }, + "Q#.azure.quantumOsRoot": { + "type": "string", + "default": "https://manage.quantum.microsoft.com", + "tags": [ + "hidden" + ], + "description": "The root URL of the Quantum OS web portal, used when generating deep links to V2 workspaces and jobs." + }, + "Q#.azure.targetJobParams": { "type": "object", - "additionalProperties": true + "default": {}, + "tags": [ + "hidden" + ], + "markdownDescription": "Additional job parameters to include in `inputParams` when submitting to a specific target. Keys are target IDs (e.g. `ionq.simulator`), values are objects merged into the job's `inputParams`.", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + } + } + }, + { + "title": "QDK", + "properties": { + "qdk.openqasm.mode": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "qdk", + "spec" + ], + "enumDescriptions": [ + "Use QDK mode, and switch a file to spec mode automatically when it uses OpenQASM the QDK cannot compile.", + "Always use QDK mode. OpenQASM the QDK cannot compile is reported as errors, and run, debug, estimate, and submission stay available.", + "Always use spec mode. Only OpenQASM specification errors are reported, and QDK-only features such as run, debug, estimate, and submission are unavailable." + ], + "markdownDescription": "How the editor treats OpenQASM files.\n\nThe QDK compiles a subset of OpenQASM. In **QDK mode** the whole toolchain is available, but constructs outside that subset are reported as errors. In **spec mode** your file is checked against the OpenQASM specification alone, so valid code reads as valid, at the cost of the QDK-only features.\n\n**auto** keeps QDK mode until a file actually needs spec mode. A code lens offers the way back." } } } - }, + ], "menus": { "editor/title/run": [ { "command": "qsharp-vscode.runProgram", - "when": "resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit", + "when": "(resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)", "group": "navigation@1" }, { "command": "qsharp-vscode.debugProgram", - "when": "resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit", + "when": "(resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)", "group": "navigation@2" } ], "commandPalette": [ + { + "command": "qsharp-vscode.openqasmSwitchToQdk", + "when": "resourceLangId == openqasm && qsharp-vscode.openqasmMode == spec" + }, + { + "command": "qsharp-vscode.openqasmSwitchToSpec", + "when": "resourceLangId == openqasm && qsharp-vscode.openqasmMode == qdk" + }, + { + "command": "qsharp-vscode.openqasmResetMode", + "when": "resourceLangId == openqasm" + }, { "command": "qsharp-vscode.runProgram", - "when": "resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.debugProgram", - "when": "resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm || resourceLangId == qsharpcircuit) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.runEditorContentsWithCircuit", - "when": "resourceLangId == qsharp || resourceLangId == openqasm" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.targetSubmit", @@ -281,15 +315,15 @@ }, { "command": "qsharp-vscode.getQir", - "when": "resourceLangId == qsharp || resourceLangId == openqasm" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.showHistogram", - "when": "resourceLangId == qsharp || resourceLangId == openqasm" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.showRe", - "when": "resourceLangId == qsharp || resourceLangId == openqasm" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.showHelp", @@ -300,7 +334,7 @@ }, { "command": "qsharp-vscode.showCircuit", - "when": "resourceLangId == qsharp || resourceLangId == openqasm" + "when": "(resourceLangId == qsharp || resourceLangId == openqasm) && (resourceLangId != openqasm || qsharp-vscode.openqasmMode != spec)" }, { "command": "qsharp-vscode.showDocumentation", @@ -583,6 +617,21 @@ "category": "QDK", "icon": "$(play)" }, + { + "command": "qsharp-vscode.openqasmSwitchToQdk", + "title": "Switch OpenQASM file to QDK mode", + "category": "QDK" + }, + { + "command": "qsharp-vscode.openqasmSwitchToSpec", + "title": "Switch OpenQASM file to spec mode", + "category": "QDK" + }, + { + "command": "qsharp-vscode.openqasmResetMode", + "title": "Reset OpenQASM file mode to workspace default", + "category": "QDK" + }, { "command": "qsharp-vscode.runEditorContentsWithCircuit", "title": "Run file and show circuit diagram", diff --git a/source/vscode/src/config.ts b/source/vscode/src/config.ts index 8fd4746dcc2..b884c7662b5 100644 --- a/source/vscode/src/config.ts +++ b/source/vscode/src/config.ts @@ -47,6 +47,23 @@ export function getShowDevDiagnostics(): boolean { .get("dev.showDevDiagnostics", false); } +export type OpenQasmMode = "auto" | "qdk" | "spec"; + +export function getOpenQasmMode(): OpenQasmMode { + const mode = vscode.workspace + .getConfiguration("qdk") + .get("openqasm.mode", "auto"); + switch (mode) { + case "auto": + case "qdk": + case "spec": + return mode; + default: + log.error("invalid OpenQASM mode found: {}", mode); + return "auto"; + } +} + export function getUploadSupplementalData(): boolean { return vscode.workspace .getConfiguration("Q#") diff --git a/source/vscode/src/debugger/activate.ts b/source/vscode/src/debugger/activate.ts index 8dfc2ad1d81..74769b46d66 100644 --- a/source/vscode/src/debugger/activate.ts +++ b/source/vscode/src/debugger/activate.ts @@ -217,7 +217,9 @@ class InlineDebugAdapterFactory const worker = debugServiceWorkerFactory(); const uri = vscode.Uri.parse(session.configuration.programUri); const file = await vscode.workspace.openTextDocument(uri); - const program = await getProgramForDocument(file); + const program = await getProgramForDocument(file, { + resumeAfterSpecModeSwitch: true, + }); if (!program.success) { throw new Error(program.errorMsg); } diff --git a/source/vscode/src/language-service/activate.ts b/source/vscode/src/language-service/activate.ts index cca7c5122a7..012880420be 100644 --- a/source/vscode/src/language-service/activate.ts +++ b/source/vscode/src/language-service/activate.ts @@ -14,7 +14,7 @@ import { openqasmLanguageId, qsharpLanguageId, } from "../common.js"; -import { getShowDevDiagnostics } from "../config.js"; +import { getOpenQasmMode, getShowDevDiagnostics } from "../config.js"; import { fetchGithubRaw, findManifestDirectory, @@ -36,6 +36,10 @@ import { startLanguageServiceDiagnostics } from "./diagnostics.js"; import { createFormattingProvider } from "./format.js"; import { createHoverProvider } from "./hover.js"; import { registerQdkNotebookCellUpdateHandlers } from "./notebook.js"; +import { + initializeOpenQasmModeService, + registerOpenQasmModeCommands, +} from "./openqasmMode.js"; import { createReferenceProvider } from "./references.js"; import { createRenameProvider } from "./rename.js"; import { createSignatureHelpProvider } from "./signature.js"; @@ -52,6 +56,14 @@ export async function activateLanguageService( const languageService = await loadLanguageService(extensionUri); + const openQasmModeService = initializeOpenQasmModeService(languageService); + subscriptions.push(openQasmModeService, ...registerOpenQasmModeCommands()); + subscriptions.push( + openQasmModeService.onDidResolveMode((event) => + sendTelemetryEvent(EventType.OpenQasmModeResolved, { mode: event.mode }), + ), + ); + // diagnostics subscriptions.push(...startLanguageServiceDiagnostics(languageService)); @@ -309,7 +321,10 @@ function registerConfigurationChangeHandlers( languageService: ILanguageService, ) { return vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("Q#.dev.showDevDiagnostics")) { + if ( + event.affectsConfiguration("Q#.dev.showDevDiagnostics") || + event.affectsConfiguration("qdk.openqasm.mode") + ) { updateLanguageServiceConfiguration(languageService); } }); @@ -319,12 +334,15 @@ async function updateLanguageServiceConfiguration( languageService: ILanguageService, ) { const showDevDiagnostics = getShowDevDiagnostics(); + const openqasmMode = getOpenQasmMode(); log.debug("Show dev diagnostics set to: " + showDevDiagnostics); + log.debug("OpenQASM mode set to: " + openqasmMode); // Update all configuration settings languageService.updateConfiguration({ devDiagnostics: showDevDiagnostics, + openqasmMode, lints: [{ lint: "needlessOperation", level: "warn" }], }); } diff --git a/source/vscode/src/language-service/codeLens.ts b/source/vscode/src/language-service/codeLens.ts index 54a41e70ad0..1ede6db3ed9 100644 --- a/source/vscode/src/language-service/codeLens.ts +++ b/source/vscode/src/language-service/codeLens.ts @@ -7,20 +7,25 @@ import { qsharpLibraryUriScheme, } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVsCodeRange } from "../common"; +import { isOpenQasmDocument, toVsCodeRange } from "../common"; +import { getOpenQasmModeService } from "./openqasmMode.js"; export function createQdkCodeLensProvider(languageService: ILanguageService) { return new CodeLensProvider(languageService, mapCodeLens); } class CodeLensProvider implements vscode.CodeLensProvider { + private readonly changedEmitter = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this.changedEmitter.event; + constructor( public languageService: ILanguageService, private commandMapper: (value: ICodeLens) => vscode.CodeLens, - ) {} - // We could raise events when code lenses change, - // but there's no need as the editor seems to query often enough. - // onDidChangeCodeLenses?: vscode.Event | undefined; + ) { + getOpenQasmModeService()?.onDidResolveMode(() => + this.changedEmitter.fire(), + ); + } async provideCodeLenses( document: vscode.TextDocument, ): Promise { @@ -34,6 +39,18 @@ class CodeLensProvider implements vscode.CodeLensProvider { document.uri.toString(), ); + if ( + isOpenQasmDocument(document) && + (await getOpenQasmModeService()?.getMode(document.uri)) === "spec" + ) { + return [ + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title: "QDK features are disabled in spec mode. Switch to QDK mode", + command: "qsharp-vscode.openqasmSwitchToQdk", + }), + ]; + } + return codeLenses.map((cl) => this.commandMapper(cl)); } } diff --git a/source/vscode/src/language-service/openqasmMode.ts b/source/vscode/src/language-service/openqasmMode.ts new file mode 100644 index 00000000000..eab5b3cdbc7 --- /dev/null +++ b/source/vscode/src/language-service/openqasmMode.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ILanguageService, + LanguageServiceModeResolvedEvent, +} from "qsharp-lang"; +import * as vscode from "vscode"; +import { isOpenQasmDocument } from "../common.js"; + +export type EffectiveOpenQasmMode = "qdk" | "spec"; + +export class OpenQasmModeService implements vscode.Disposable { + private readonly modes = new Map(); + private readonly resolvedEmitter = new vscode.EventEmitter<{ + uri: string; + mode: EffectiveOpenQasmMode; + }>(); + private readonly listener = (event: LanguageServiceModeResolvedEvent) => { + this.modes.set(event.detail.uri, event.detail.mode); + this.resolvedEmitter.fire(event.detail); + }; + + readonly onDidResolveMode = this.resolvedEmitter.event; + + constructor(private readonly languageService: ILanguageService) { + languageService.addEventListener("modeResolved", this.listener); + } + + async getMode(uri: vscode.Uri): Promise { + const uriString = uri.toString(); + const mode = await this.languageService.getOpenQasmMode(uriString); + if (mode) { + this.modes.set(uriString, mode); + } + return mode ?? this.modes.get(uriString); + } + + async setOverride( + uri: vscode.Uri, + mode: EffectiveOpenQasmMode | undefined, + ): Promise { + await this.languageService.setOpenQasmModeOverride(uri.toString(), mode); + } + + async awaitFirstResolution( + uri: vscode.Uri, + timeoutMs = 1_000, + ): Promise { + const known = await this.getMode(uri); + if (known) { + return known; + } + + const uriString = uri.toString(); + const cached = this.modes.get(uriString); + if (cached) { + return cached; + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + subscription.dispose(); + resolve(undefined); + }, timeoutMs); + const subscription = this.onDidResolveMode((event) => { + if (event.uri === uriString) { + clearTimeout(timer); + subscription.dispose(); + resolve(event.mode); + } + }); + }); + } + + async awaitMode( + uri: vscode.Uri, + mode: EffectiveOpenQasmMode, + timeoutMs = 1_000, + ): Promise { + const uriString = uri.toString(); + + // Fast path for already-resolved state, including queued language-service updates. + const cached = this.modes.get(uriString); + if (cached === mode) { + return true; + } + + const known = await this.getMode(uri); + if (known === mode) { + return true; + } + + return new Promise((resolve) => { + let settled = false; + const subscription = this.onDidResolveMode((event) => { + if (event.uri === uriString && event.mode === mode) { + finish(true); + } + }); + + const timer = setTimeout(() => { + finish(false); + }, timeoutMs); + + const finish = (value: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + subscription.dispose(); + resolve(value); + }; + + // Close the check/subscribe race: if mode resolved between the checks above + // and listener registration, treat it as success immediately. + if (this.modes.get(uriString) === mode) { + finish(true); + } + }); + } + + dispose(): void { + this.languageService.removeEventListener("modeResolved", this.listener); + this.resolvedEmitter.dispose(); + } +} + +let service: OpenQasmModeService | undefined; + +export function initializeOpenQasmModeService( + languageService: ILanguageService, +): OpenQasmModeService { + service?.dispose(); + service = new OpenQasmModeService(languageService); + return service; +} + +export function getOpenQasmModeService(): OpenQasmModeService | undefined { + return service; +} + +const modeContextKey = "qsharp-vscode.openqasmMode"; + +async function updateModeContext() { + const document = vscode.window.activeTextEditor?.document; + const mode = + document && isOpenQasmDocument(document) + ? await service?.getMode(document.uri) + : undefined; + await vscode.commands.executeCommand("setContext", modeContextKey, mode); +} + +export function registerOpenQasmModeCommands(): vscode.Disposable[] { + const updateContext = () => void updateModeContext(); + const switchMode = async (mode: EffectiveOpenQasmMode | undefined) => { + const document = vscode.window.activeTextEditor?.document; + if (!document || !isOpenQasmDocument(document)) { + return; + } + await service?.setOverride(document.uri, mode); + }; + + const subscriptions = [ + vscode.commands.registerCommand("qsharp-vscode.openqasmSwitchToQdk", () => + switchMode("qdk"), + ), + vscode.commands.registerCommand("qsharp-vscode.openqasmSwitchToSpec", () => + switchMode("spec"), + ), + vscode.commands.registerCommand("qsharp-vscode.openqasmResetMode", () => + switchMode(undefined), + ), + vscode.window.onDidChangeActiveTextEditor(updateContext), + service?.onDidResolveMode(updateContext), + ].filter((subscription): subscription is vscode.Disposable => !!subscription); + + updateContext(); + return subscriptions; +} + +export async function ensureQdkFeaturesAvailable( + document: vscode.TextDocument, + resumeAfterSwitch = false, +): Promise { + if (!isOpenQasmDocument(document)) { + return undefined; + } + + const mode = await service?.awaitFirstResolution(document.uri); + if (mode === "qdk") { + return undefined; + } + + const selection = await vscode.window.showInformationMessage( + mode === "spec" + ? "QDK features are unavailable while this OpenQASM file is in spec mode." + : "OpenQASM mode has not resolved yet. Try again once the file finishes loading.", + "Switch to QDK mode", + ); + if (selection === "Switch to QDK mode" && mode === "spec") { + await service?.setOverride(document.uri, "qdk"); + if (resumeAfterSwitch && (await service?.awaitMode(document.uri, "qdk"))) { + return undefined; + } + } + + return mode === "spec" + ? "QDK features are unavailable while this OpenQASM file is in spec mode." + : "OpenQASM mode has not resolved yet. Try again once the file finishes loading."; +} diff --git a/source/vscode/src/programConfig.ts b/source/vscode/src/programConfig.ts index 9e508cce0f1..46fbdd0018e 100644 --- a/source/vscode/src/programConfig.ts +++ b/source/vscode/src/programConfig.ts @@ -11,6 +11,7 @@ import { import * as vscode from "vscode"; import { isOpenQasmDocument, isQdkDocument } from "./common"; import { invokeAndReportCommandDiagnostics } from "./diagnostics"; +import { ensureQdkFeaturesAvailable } from "./language-service/openqasmMode"; import { loadOpenQasmProject, loadProject } from "./projectSystem"; /** @@ -110,8 +111,17 @@ export async function getProgramForDocument( options: { showModalError?: boolean; targetProfileFallback?: TargetProfile; + resumeAfterSpecModeSwitch?: boolean; } = {}, ): Promise { + const modeError = await ensureQdkFeaturesAvailable( + doc, + options.resumeAfterSpecModeSwitch, + ); + if (modeError) { + return { success: false, errorMsg: modeError }; + } + // Project configs come from the document try { const program = await invokeAndReportCommandDiagnostics( diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index b66928debb9..9dee0784efc 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -43,6 +43,7 @@ export enum EventType { DebugSessionEvent = "Qsharp.DebugSessionEvent", Launch = "Qsharp.Launch", OpenedDocument = "Qsharp.OpenedDocument", + OpenQasmModeResolved = "Qsharp.OpenQasmModeResolved", TriggerResourceEstimation = "Qsharp.TriggerResourceEstimation", ResourceEstimationStart = "Qsharp.ResourceEstimationStart", ResourceEstimationEnd = "Qsharp.ResourceEstimationEnd", @@ -97,6 +98,10 @@ type EventTypes = { timeToStartMs: number; }; }; + [EventType.OpenQasmModeResolved]: { + properties: { mode: "qdk" | "spec" }; + measurements: Empty; + }; [EventType.ReturnCompletionList]: { properties: DocumentEventProperties; measurements: { timeToCompletionMs: number; completionListLength: number }; diff --git a/source/vscode/test/suites/language-service/language-service.test.ts b/source/vscode/test/suites/language-service/language-service.test.ts index 70b7d6f70e3..d69d81d5dcf 100644 --- a/source/vscode/test/suites/language-service/language-service.test.ts +++ b/source/vscode/test/suites/language-service/language-service.test.ts @@ -21,6 +21,7 @@ suite("Q# Language Service Tests", function suite() { const testQs = joinPath(workspaceFolderUri, "test.qs"); const noErrorsQs = joinPath(workspaceFolderUri, "no-errors.qs"); + const specModeQasm = joinPath(workspaceFolderUri, "spec-mode.qasm"); const mainPackageMainQs = joinPath(packages, "MainPackage", "src", "Main.qs"); const depPackageMainQs = joinPath(packages, "DepPackage", "src", "Main.qs"); const missingDepMainQs = joinPath(packages, "MissingDep", "src", "Main.qs"); @@ -222,6 +223,37 @@ suite("Q# Language Service Tests", function suite() { } }); + test("OpenQASM spec mode shows the switch lens", async () => { + const config = vscode.workspace.getConfiguration("qdk"); + await config.update( + "openqasm.mode", + "spec", + vscode.ConfigurationTarget.Workspace, + ); + + try { + const doc = await openDocumentAndWaitForProcessing(specModeQasm); + await waitForDiagnosticsToBeEmpty(specModeQasm); + + const lenses = (await vscode.commands.executeCommand( + "vscode.executeCodeLensProvider", + doc.uri, + )) as vscode.CodeLens[]; + + assert.lengthOf(lenses, 1); + assert.equal( + lenses[0].command?.command, + "qsharp-vscode.openqasmSwitchToQdk", + ); + } finally { + await config.update( + "openqasm.mode", + undefined, + vscode.ConfigurationTarget.Workspace, + ); + } + }); + test("Package dependencies", async () => { const doc = await openDocumentAndWaitForProcessing(mainPackageMainQs); diff --git a/source/vscode/test/suites/language-service/test-workspace/spec-mode.qasm b/source/vscode/test/suites/language-service/test-workspace/spec-mode.qasm new file mode 100644 index 00000000000..53d4057cac4 --- /dev/null +++ b/source/vscode/test/suites/language-service/test-workspace/spec-mode.qasm @@ -0,0 +1,8 @@ +OPENQASM 3.0; +include "stdgates.inc"; +defcalgrammar "openpulse"; +qubit q; +defcal x $0 { + delay[100ns] $0; +} +x q; diff --git a/source/wasm/src/language_service.rs b/source/wasm/src/language_service.rs index 824d97e8698..ffcffac8ca4 100644 --- a/source/wasm/src/language_service.rs +++ b/source/wasm/src/language_service.rs @@ -14,7 +14,11 @@ use qsc::{ }; use qsc_project::Manifest; use qsls::VersionWaitResult; -use qsls::protocol::{DiagnosticUpdate, TestCallable, TestCallables}; +use qsls::protocol::{ + DiagnosticUpdate, EffectiveOpenQasmMode, ModeResolved, OpenQasmMode, TestCallable, + TestCallables, +}; + use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -53,6 +57,7 @@ impl LanguageService { &mut self, diagnostics_callback: &DiagnosticsCallback, test_callables_callback: &TestCallableCallback, + mode_resolved_callback: &ModeResolvedCallback, host: ProjectHost, yield_to_host: &js_sys::Function, ) -> js_sys::Promise { @@ -113,9 +118,27 @@ impl LanguageService { ) .expect("callback should succeed"); }; - let mut handler = - self.0 - .create_update_handler(diagnostics_callback, test_callables_callback, host); + + let mode_resolved_callback = mode_resolved_callback + .dyn_ref::() + .expect("expected a valid JS function") + .clone(); + + let mode_resolved_callback = move |update: ModeResolved| { + let mode = match update.mode { + EffectiveOpenQasmMode::Qdk => "qdk", + EffectiveOpenQasmMode::Spec => "spec", + }; + let _ = mode_resolved_callback + .call2(&JsValue::NULL, &update.uri.into(), &mode.into()) + .expect("callback should succeed"); + }; + let mut handler = self.0.create_update_handler( + diagnostics_callback, + test_callables_callback, + mode_resolved_callback, + host, + ); let yield_to_host = yield_to_host.clone(); let yield_to_host = move || { @@ -155,6 +178,13 @@ impl LanguageService { .map(|features| features.iter().collect::()), lints_config: config.lints, dev_diagnostics: config.devDiagnostics, + openqasm_mode: config.openqasmMode.as_deref().and_then(|mode| match mode { + "auto" => Some(qsls::protocol::OpenQasmMode::Auto), + "qdk" => Some(qsls::protocol::OpenQasmMode::Qdk), + "spec" => Some(qsls::protocol::OpenQasmMode::Spec), + // Leave the current mode in place rather than failing the whole update. + _ => None, + }), }); } @@ -206,6 +236,22 @@ impl LanguageService { self.0.close_notebook_document(notebook_uri); } + pub fn get_openqasm_mode(&self, uri: &str) -> Option { + self.0.get_openqasm_mode(uri).map(|mode| match mode { + EffectiveOpenQasmMode::Qdk => "qdk".to_string(), + EffectiveOpenQasmMode::Spec => "spec".to_string(), + }) + } + + pub fn set_openqasm_mode_override(&mut self, uri: &str, mode: Option) { + let mode = mode.and_then(|mode| match mode.as_str() { + "qdk" => Some(OpenQasmMode::Qdk), + "spec" => Some(OpenQasmMode::Spec), + _ => None, + }); + self.0.set_openqasm_mode_override(uri, mode); + } + pub fn get_code_actions(&self, uri: &str, range: IRange) -> Vec { let range: Range = range.into(); let code_actions = self.0.get_code_actions(uri, range.into()); @@ -453,6 +499,7 @@ serializable_type! { pub languageFeatures: Option>, pub lints: Option>, pub devDiagnostics: Option, + pub openqasmMode: Option, }, r#"export interface IWorkspaceConfiguration { targetProfile?: TargetProfile; @@ -460,6 +507,7 @@ serializable_type! { languageFeatures?: LanguageFeatures[]; lints?: ({ lint: string; level: string } | { group: string; level: string })[]; devDiagnostics?: boolean; + openqasmMode?: "auto" | "qdk" | "spec"; }"#, IWorkspaceConfiguration } @@ -720,3 +768,9 @@ extern "C" { #[wasm_bindgen(typescript_type = "(callables: ITestDescriptor[]) => void")] pub type TestCallableCallback; } + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "(uri: string, mode: \"qdk\" | \"spec\") => void")] + pub type ModeResolvedCallback; +}