diff --git a/compiler/qsc_frontend/src/lower.rs b/compiler/qsc_frontend/src/lower.rs index fb56d42d7e..fcb8a421d3 100644 --- a/compiler/qsc_frontend/src/lower.rs +++ b/compiler/qsc_frontend/src/lower.rs @@ -443,6 +443,18 @@ impl With<'_> { None } }, + Ok(hir::Attr::Test) => { + // verify that no args are passed to the attribute + match &*attr.arg.kind { + ast::ExprKind::Tuple(args) if args.is_empty() => {} + _ => { + self.lowerer + .errors + .push(Error::InvalidAttrArgs("()".to_string(), attr.arg.span)); + } + } + Some(hir::Attr::Test) + } Err(()) => { self.lowerer.errors.push(Error::UnknownAttr( attr.name.name.to_string(), diff --git a/compiler/qsc_hir/src/hir.rs b/compiler/qsc_hir/src/hir.rs index ade89e54e3..45d9732abf 100644 --- a/compiler/qsc_hir/src/hir.rs +++ b/compiler/qsc_hir/src/hir.rs @@ -279,6 +279,58 @@ impl Display for Package { } } +/// The name of a test callable, including its parent namespace. +pub type TestCallableName = String; + +impl Package { + /// Returns a collection of the fully qualified names of any callables annotated with `@Test()` + pub fn get_test_callables(&self) -> Vec<(TestCallableName, Span)> { + let items_with_test_attribute = self + .items + .iter() + .filter(|(_, item)| item.attrs.iter().any(|attr| *attr == Attr::Test)); + + let callables = items_with_test_attribute + .filter(|(_, item)| matches!(item.kind, ItemKind::Callable(_))); + + let callable_names = callables + .filter_map(|(_, item)| -> Option<_> { + if let ItemKind::Callable(callable) = &item.kind { + if !callable.generics.is_empty() + || callable.input.kind != PatKind::Tuple(vec![]) + { + return None; + } + + // this is indeed a test callable, so let's grab its parent name + let (name, span) = match item.parent { + None => Default::default(), + Some(parent_id) => { + let parent_item = self + .items + .get(parent_id) + .expect("Parent item did not exist in package"); + let name = if let ItemKind::Namespace(ns, _) = &parent_item.kind { + format!("{}.{}", ns.name(), callable.name.name) + } else { + callable.name.name.to_string() + }; + let span = callable.name.span; + (name, span) + } + }; + + Some((name, span)) + } else { + None + } + }) + .collect::>(); + + callable_names + } +} + /// An item. #[derive(Clone, Debug, PartialEq)] pub struct Item { @@ -1365,6 +1417,8 @@ pub enum Attr { /// Indicates that an intrinsic callable is a reset. This means that the operation will be marked as /// "irreversible" in the generated QIR. Reset, + /// Indicates that a callable is a test case. + Test, } impl Attr { @@ -1382,6 +1436,7 @@ The `not` operator is also supported to negate the attribute, e.g. `not Adaptive Attr::SimulatableIntrinsic => "Indicates that an item should be treated as an intrinsic callable for QIR code generation and any implementation should only be used during simulation.", Attr::Measurement => "Indicates that an intrinsic callable is a measurement. This means that the operation will be marked as \"irreversible\" in the generated QIR, and output Result types will be moved to the arguments.", Attr::Reset => "Indicates that an intrinsic callable is a reset. This means that the operation will be marked as \"irreversible\" in the generated QIR.", + Attr::Test => "Indicates that a callable is a test case.", } } } @@ -1397,6 +1452,7 @@ impl FromStr for Attr { "SimulatableIntrinsic" => Ok(Self::SimulatableIntrinsic), "Measurement" => Ok(Self::Measurement), "Reset" => Ok(Self::Reset), + "Test" => Ok(Self::Test), _ => Err(()), } } diff --git a/compiler/qsc_lowerer/src/lib.rs b/compiler/qsc_lowerer/src/lib.rs index 3fb0084127..34024504ba 100644 --- a/compiler/qsc_lowerer/src/lib.rs +++ b/compiler/qsc_lowerer/src/lib.rs @@ -943,7 +943,10 @@ fn lower_attrs(attrs: &[hir::Attr]) -> Vec { hir::Attr::EntryPoint => Some(fir::Attr::EntryPoint), hir::Attr::Measurement => Some(fir::Attr::Measurement), hir::Attr::Reset => Some(fir::Attr::Reset), - hir::Attr::SimulatableIntrinsic | hir::Attr::Unimplemented | hir::Attr::Config => None, + hir::Attr::SimulatableIntrinsic + | hir::Attr::Unimplemented + | hir::Attr::Config + | hir::Attr::Test => None, }) .collect() } diff --git a/compiler/qsc_parse/src/item/tests.rs b/compiler/qsc_parse/src/item/tests.rs index e94d7168c8..64dd8796e8 100644 --- a/compiler/qsc_parse/src/item/tests.rs +++ b/compiler/qsc_parse/src/item/tests.rs @@ -2396,3 +2396,20 @@ fn top_level_nodes_error_recovery() { ]"#]], ); } + +#[test] +fn test_attribute() { + check( + parse, + "@Test() function Foo() : Unit {}", + &expect![[r#" + Item _id_ [0-32]: + Attr _id_ [0-7] (Ident _id_ [1-5] "Test"): + Expr _id_ [5-7]: Unit + Callable _id_ [8-32] (Function): + name: Ident _id_ [17-20] "Foo" + input: Pat _id_ [20-22]: Unit + output: Type _id_ [25-29]: Path: Path _id_ [25-29] (Ident _id_ [25-29] "Unit") + body: Block: Block _id_ [30-32]: "#]], + ); +} diff --git a/compiler/qsc_passes/src/lib.rs b/compiler/qsc_passes/src/lib.rs index c20a3816bf..7283814099 100644 --- a/compiler/qsc_passes/src/lib.rs +++ b/compiler/qsc_passes/src/lib.rs @@ -15,6 +15,7 @@ mod measurement; mod replace_qubit_allocation; mod reset; mod spec_gen; +mod test_attribute; use callable_limits::CallableLimits; use capabilitiesck::{check_supported_capabilities, lower_store, run_rca_pass}; @@ -52,6 +53,7 @@ pub enum Error { Measurement(measurement::Error), Reset(reset::Error), SpecGen(spec_gen::Error), + TestAttribute(test_attribute::TestAttributeError), } #[derive(Clone, Copy, Debug, PartialEq)] @@ -121,6 +123,9 @@ impl PassContext { ReplaceQubitAllocation::new(core, assigner).visit_package(package); Validator::default().visit_package(package); + let test_attribute_errors = test_attribute::validate_test_attributes(package); + Validator::default().visit_package(package); + callable_errors .into_iter() .map(Error::CallableLimits) @@ -130,6 +135,7 @@ impl PassContext { .chain(entry_point_errors) .chain(measurement_decl_errors.into_iter().map(Error::Measurement)) .chain(reset_decl_errors.into_iter().map(Error::Reset)) + .chain(test_attribute_errors.into_iter().map(Error::TestAttribute)) .collect() } diff --git a/compiler/qsc_passes/src/test_attribute.rs b/compiler/qsc_passes/src/test_attribute.rs new file mode 100644 index 0000000000..ac9d1a678b --- /dev/null +++ b/compiler/qsc_passes/src/test_attribute.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use miette::Diagnostic; +use qsc_data_structures::span::Span; +use qsc_hir::{hir::Attr, visit::Visitor}; +use thiserror::Error; + +#[cfg(test)] +mod tests; + +#[derive(Clone, Debug, Diagnostic, Error)] +pub enum TestAttributeError { + #[error("test callables cannot take arguments")] + CallableHasParameters(#[label] Span), + #[error("test callables cannot have type parameters")] + CallableHasTypeParameters(#[label] Span), +} + +pub(crate) fn validate_test_attributes( + package: &mut qsc_hir::hir::Package, +) -> Vec { + let mut validator = TestAttributeValidator { errors: Vec::new() }; + validator.visit_package(package); + validator.errors +} + +struct TestAttributeValidator { + errors: Vec, +} + +impl<'a> Visitor<'a> for TestAttributeValidator { + fn visit_callable_decl(&mut self, decl: &'a qsc_hir::hir::CallableDecl) { + if decl.attrs.iter().any(|attr| matches!(attr, Attr::Test)) { + if !decl.generics.is_empty() { + self.errors + .push(TestAttributeError::CallableHasTypeParameters( + decl.name.span, + )); + } + if decl.input.ty != qsc_hir::ty::Ty::UNIT { + self.errors + .push(TestAttributeError::CallableHasParameters(decl.span)); + } + } + } +} diff --git a/compiler/qsc_passes/src/test_attribute/tests.rs b/compiler/qsc_passes/src/test_attribute/tests.rs new file mode 100644 index 0000000000..1597a38e5f --- /dev/null +++ b/compiler/qsc_passes/src/test_attribute/tests.rs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use expect_test::{expect, Expect}; +use indoc::indoc; +use qsc_data_structures::{language_features::LanguageFeatures, target::TargetCapabilityFlags}; +use qsc_frontend::compile::{self, compile, PackageStore, SourceMap}; +use qsc_hir::{validate::Validator, visit::Visitor}; + +use crate::test_attribute::validate_test_attributes; + +fn check(file: &str, expect: &Expect) { + let store = PackageStore::new(compile::core()); + let sources = SourceMap::new([("test".into(), file.into())], None); + let mut unit = compile( + &store, + &[], + sources, + TargetCapabilityFlags::all(), + LanguageFeatures::default(), + ); + assert!(unit.errors.is_empty(), "{:?}", unit.errors); + + let errors = validate_test_attributes(&mut unit.package); + Validator::default().visit_package(&unit.package); + if errors.is_empty() { + expect.assert_eq(&unit.package.to_string()); + } else { + expect.assert_debug_eq(&errors); + } +} + +#[test] +fn callable_cant_have_params() { + check( + indoc! {" + namespace test { + @Test() + operation A(q : Qubit) : Unit { + + } + } + "}, + &expect![[r#" + [ + CallableHasParameters( + Span { + lo: 33, + hi: 71, + }, + ), + ] + "#]], + ); +} + +#[test] +fn callable_cant_have_type_params() { + check( + indoc! {" + namespace test { + @Test() + operation A<'T>() : Unit { + + } + } + "}, + &expect![[r#" + [ + CallableHasTypeParameters( + Span { + lo: 43, + hi: 44, + }, + ), + ] + "#]], + ); +} + +#[test] +fn conditionally_compile_out_test() { + check( + indoc! {" + namespace test { + @Test() + @Config(Base) + operation A<'T>() : Unit { + + } + } + "}, + &expect![[r#" + Package: + Item 0 [0-86] (Public): + Namespace (Ident 0 [10-14] "test"): "#]], + ); +} + +#[test] +fn callable_is_valid_test_callable() { + check( + indoc! {" + namespace test { + @Test() + operation A() : Unit { + + } + } + "}, + &expect![[r#" + Package: + Item 0 [0-64] (Public): + Namespace (Ident 5 [10-14] "test"): Item 1 + Item 1 [21-62] (Internal): + Parent: 0 + Test + Callable 0 [33-62] (operation): + name: Ident 1 [43-44] "A" + input: Pat 2 [44-46] [Type Unit]: Unit + output: Unit + functors: empty set + body: SpecDecl 3 [33-62]: Impl: + Block 4 [54-62]: + adj: + ctl: + ctl-adj: "#]], + ); +} diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs index 253eab560e..3521fad23d 100644 --- a/language_service/src/compilation.rs +++ b/language_service/src/compilation.rs @@ -37,6 +37,7 @@ pub(crate) struct Compilation { pub compile_errors: Vec, pub kind: CompilationKind, pub dependencies: FxHashMap>, + pub test_cases: Vec<(String, Span)>, } #[derive(Debug)] @@ -46,6 +47,8 @@ pub(crate) enum CompilationKind { /// one or more sources, and a target profile. OpenProject { package_graph_sources: PackageGraphSources, + /// a human-readable name for the package (not a unique URI -- meant to be read by humans) + friendly_name: Arc, }, /// A Q# notebook. In a notebook compilation, the user package /// contains multiple `Source`s, with each source corresponding @@ -62,6 +65,7 @@ impl Compilation { lints_config: &[LintConfig], package_graph_sources: PackageGraphSources, project_errors: Vec, + friendly_name: &Arc, ) -> Self { let mut buildable_program = prepare_package_store(target_profile.into(), package_graph_sources.clone()); @@ -102,15 +106,19 @@ impl Compilation { run_linter_passes(&mut compile_errors, &package_store, unit, lints_config); + let test_cases = unit.package.get_test_callables(); + Self { package_store, user_package_id: package_id, kind: CompilationKind::OpenProject { package_graph_sources, + friendly_name: friendly_name.clone(), }, compile_errors, project_errors, dependencies: user_code_dependencies.into_iter().collect(), + test_cases, } } @@ -218,16 +226,28 @@ impl Compilation { .chain(once((source_package_id, None))) .collect(); + let test_cases = unit.package.get_test_callables(); + Self { package_store, user_package_id: package_id, compile_errors: errors, project_errors: project.as_ref().map_or_else(Vec::new, |p| p.errors.clone()), kind: CompilationKind::Notebook { project }, + test_cases, dependencies, } } + /// Returns a human-readable compilation name if one exists. + /// Notebooks don't have human-readable compilation names. + pub fn friendly_project_name(&self) -> Option> { + match &self.kind { + CompilationKind::OpenProject { friendly_name, .. } => Some(friendly_name.clone()), + CompilationKind::Notebook { .. } => None, + } + } + /// Gets the `CompileUnit` associated with user (non-library) code. pub fn user_unit(&self) -> &CompileUnit { self.package_store @@ -322,6 +342,7 @@ impl Compilation { let new = match self.kind { CompilationKind::OpenProject { ref package_graph_sources, + ref friendly_name, } => Self::new( package_type, target_profile, @@ -329,6 +350,7 @@ impl Compilation { lints_config, package_graph_sources.clone(), Vec::new(), // project errors will stay the same + friendly_name, ), CompilationKind::Notebook { ref project } => Self::new_notebook( sources.into_iter(), diff --git a/language_service/src/completion.rs b/language_service/src/completion.rs index 74ad3500e2..10884e7198 100644 --- a/language_service/src/completion.rs +++ b/language_service/src/completion.rs @@ -115,6 +115,7 @@ fn expected_word_kinds( match &compilation.kind { CompilationKind::OpenProject { package_graph_sources, + .. } => possible_words_at_offset_in_source( source_contents, Some(source_name_relative), @@ -165,6 +166,7 @@ fn collect_hardcoded_words(expected: WordKinds) -> Vec { ), Completion::new("Measurement".to_string(), CompletionItemKind::Interface), Completion::new("Reset".to_string(), CompletionItemKind::Interface), + Completion::new("Test".to_string(), CompletionItemKind::Interface), ]); } HardcodedIdentKind::Size => { diff --git a/language_service/src/lib.rs b/language_service/src/lib.rs index 983a58a8bb..cf9f9ceb85 100644 --- a/language_service/src/lib.rs +++ b/language_service/src/lib.rs @@ -26,7 +26,7 @@ use futures_util::StreamExt; use log::{trace, warn}; use protocol::{ CodeAction, CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, - TextEdit, WorkspaceConfigurationUpdate, + TestCallables, TextEdit, WorkspaceConfigurationUpdate, }; use qsc::{ line_column::{Encoding, Position, Range}, @@ -67,6 +67,9 @@ impl LanguageService { pub fn create_update_worker<'a>( &mut self, diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a, + // 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, project_host: impl JSProjectHost + 'static, ) -> UpdateWorker<'a> { assert!(self.state_updater.is_none()); @@ -75,7 +78,9 @@ impl LanguageService { updater: CompilationStateUpdater::new( self.state.clone(), diagnostics_receiver, + test_callable_receiver, project_host, + self.position_encoding, ), recv, }; diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs index 4a75b701ca..8a6c2f340d 100644 --- a/language_service/src/protocol.rs +++ b/language_service/src/protocol.rs @@ -3,6 +3,7 @@ use miette::Diagnostic; use qsc::line_column::Range; +use qsc::location::Location; use qsc::{compile, project}; use qsc::{linter::LintConfig, project::Manifest, target::Profile, LanguageFeatures, PackageType}; use thiserror::Error; @@ -33,6 +34,23 @@ pub struct DiagnosticUpdate { pub errors: Vec, } +#[derive(Debug)] +pub struct TestCallable { + /// This is a string that represents the interpreter-ready name of the test callable. + /// i.e. "Main.TestCase". Call it by adding parens to the end, e.g. `Main.TestCase()` + pub callable_name: Arc, + /// A string that represents the originating compilation URI of this callable + pub compilation_uri: Arc, + pub location: Location, + /// A human readable name that represents the compilation. + pub friendly_name: Arc, +} + +#[derive(Debug)] +pub struct TestCallables { + pub callables: Vec, +} + #[derive(Debug)] pub enum CodeActionKind { Empty, @@ -112,6 +130,7 @@ impl PartialEq for CompletionItem { impl Eq for CompletionItem {} use std::hash::{Hash, Hasher}; +use std::sync::Arc; impl Hash for CompletionItem { fn hash(&self, state: &mut H) { diff --git a/language_service/src/state.rs b/language_service/src/state.rs index 3eaed8c13c..00a726fe53 100644 --- a/language_service/src/state.rs +++ b/language_service/src/state.rs @@ -4,13 +4,16 @@ #[cfg(test)] mod tests; +use crate::protocol::TestCallable; + use super::compilation::Compilation; -use super::protocol::{DiagnosticUpdate, NotebookMetadata}; -use crate::protocol::{ErrorKind, WorkspaceConfigurationUpdate}; +use super::protocol::{ + DiagnosticUpdate, ErrorKind, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate, +}; use log::{debug, trace}; use miette::Diagnostic; -use qsc::{compile, project}; -use qsc::{target::Profile, LanguageFeatures, PackageType}; +use qsc::line_column::Encoding; +use qsc::{compile, project, target::Profile, LanguageFeatures, PackageType}; use qsc_linter::LintConfig; use qsc_project::{FileSystemAsync, JSProjectHost, PackageCache, Project}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -102,24 +105,32 @@ pub(super) struct CompilationStateUpdater<'a> { /// Callback which will receive diagnostics (compilation errors) /// whenever a (re-)compilation occurs. diagnostics_receiver: Box, + /// Callback which will receive test callables whenever a (re-)compilation occurs. + test_callable_receiver: Box, cache: RefCell, /// Functions to interact with the host filesystem for project system operations. project_host: Box, + /// Encoding for converting between line/column and byte offsets. + position_encoding: Encoding, } impl<'a> CompilationStateUpdater<'a> { pub fn new( state: Rc>, diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a, + test_callable_receiver: impl Fn(TestCallables) + 'a, project_host: impl JSProjectHost + 'static, + position_encoding: Encoding, ) -> Self { Self { state, configuration: Configuration::default(), documents_with_errors: FxHashSet::default(), diagnostics_receiver: Box::new(diagnostics_receiver), + test_callable_receiver: Box::new(test_callable_receiver), cache: RefCell::default(), project_host: Box::new(project_host), + position_encoding, } } @@ -174,7 +185,7 @@ impl<'a> CompilationStateUpdater<'a> { self.insert_buffer_aware_compilation(project); - self.publish_diagnostics(); + self.publish_diagnostics_and_test_callables(); } /// Attempts to resolve a manifest for the given document uri. @@ -253,6 +264,7 @@ impl<'a> CompilationStateUpdater<'a> { &configuration.lints_config, loaded_project.package_graph_sources, loaded_project.errors, + &loaded_project.name, ); state @@ -275,7 +287,7 @@ impl<'a> CompilationStateUpdater<'a> { } } - self.publish_diagnostics(); + self.publish_diagnostics_and_test_callables(); } /// Removes a document from the open documents map. If the @@ -372,7 +384,7 @@ impl<'a> CompilationStateUpdater<'a> { (compilation, notebook_configuration), ); }); - self.publish_diagnostics(); + self.publish_diagnostics_and_test_callables(); } pub(super) fn close_notebook_document(&mut self, notebook_uri: &str) { @@ -390,13 +402,14 @@ impl<'a> CompilationStateUpdater<'a> { state.compilations.remove(notebook_uri); }); - self.publish_diagnostics(); + self.publish_diagnostics_and_test_callables(); } // It gets really messy knowing when to clear diagnostics // when the document changes ownership between compilations, etc. // So let's do it the simplest way possible. Republish all the diagnostics every time. - fn publish_diagnostics(&mut self) { + fn publish_diagnostics_and_test_callables(&mut self) { + self.publish_test_callables(); let last_docs_with_errors = take(&mut self.documents_with_errors); let mut docs_with_errors = FxHashSet::default(); @@ -503,7 +516,7 @@ impl<'a> CompilationStateUpdater<'a> { } }); - self.publish_diagnostics(); + self.publish_diagnostics_and_test_callables(); } /// Borrows the compilation state immutably and invokes `f`. @@ -533,6 +546,36 @@ impl<'a> CompilationStateUpdater<'a> { let mut state = self.state.borrow_mut(); f(&mut state) } + + fn publish_test_callables(&self) { + self.with_state(|state| { + // get test callables from each compilation + let callables: Vec<_> = state + .compilations + .iter() + .flat_map(|(compilation_uri, (compilation, _))| { + compilation.test_cases.iter().map(move |(name, span)| { + Some(TestCallable { + compilation_uri: Arc::from(compilation_uri.as_ref()), + callable_name: Arc::from(name.as_ref()), + location: crate::qsc_utils::into_location( + self.position_encoding, + compilation, + *span, + compilation.user_package_id, + ), + // notebooks don't have human readable names -- we use this + // to filter them out in the test explorer + friendly_name: compilation.friendly_project_name()?, + }) + }) + }) + .flatten() + .collect(); + + (self.test_callable_receiver)(TestCallables { callables }); + }); + } } impl CompilationState { diff --git a/language_service/src/state/tests.rs b/language_service/src/state/tests.rs index f115accbee..9e5d7a4a45 100644 --- a/language_service/src/state/tests.rs +++ b/language_service/src/state/tests.rs @@ -6,12 +6,12 @@ use super::{CompilationState, CompilationStateUpdater}; use crate::{ - protocol::{DiagnosticUpdate, NotebookMetadata, WorkspaceConfigurationUpdate}, + protocol::{DiagnosticUpdate, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate}, tests::test_fs::{dir, file, FsNode, TestProjectHost}, }; use expect_test::{expect, Expect}; use miette::Diagnostic; -use qsc::{target::Profile, LanguageFeatures, PackageType}; +use qsc::{line_column::Encoding, target::Profile, LanguageFeatures, PackageType}; use qsc_linter::{AstLint, LintConfig, LintKind, LintLevel}; use std::{ cell::RefCell, @@ -23,7 +23,8 @@ use std::{ #[tokio::test] async fn no_error() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_document( @@ -39,7 +40,8 @@ async fn no_error() { #[tokio::test] async fn clear_error() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_document("single/foo.qs", 1, "namespace {") @@ -76,7 +78,8 @@ async fn clear_error() { #[tokio::test] async fn close_last_doc_in_project() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document( @@ -141,7 +144,9 @@ async fn close_last_doc_in_project() { #[tokio::test] async fn clear_on_document_close() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + + let mut updater = new_updater(&errors, &test_cases); updater .update_document("single/foo.qs", 1, "namespace {") @@ -172,7 +177,8 @@ async fn clear_on_document_close() { #[tokio::test] async fn compile_error() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_document("single/foo.qs", 1, "badsyntax") @@ -193,7 +199,8 @@ async fn compile_error() { #[tokio::test] async fn rca_errors_are_reported_when_compilation_succeeds() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater.update_configuration(WorkspaceConfigurationUpdate { target_profile: Some(Profile::AdaptiveRI), @@ -223,7 +230,8 @@ async fn rca_errors_are_reported_when_compilation_succeeds() { #[tokio::test] async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater.update_configuration(WorkspaceConfigurationUpdate { target_profile: Some(Profile::Base), @@ -255,7 +263,8 @@ async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() { #[tokio::test] async fn package_type_update_causes_error() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater.update_configuration(WorkspaceConfigurationUpdate { package_type: Some(PackageType::Lib), @@ -291,7 +300,8 @@ async fn package_type_update_causes_error() { #[tokio::test] async fn target_profile_update_fixes_error() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater.update_configuration(WorkspaceConfigurationUpdate { target_profile: Some(Profile::Base), @@ -335,7 +345,8 @@ async fn target_profile_update_fixes_error() { #[tokio::test] async fn target_profile_update_causes_error_in_stdlib() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater.update_document( "single/foo.qs", @@ -365,7 +376,8 @@ async fn target_profile_update_causes_error_in_stdlib() { #[tokio::test] async fn notebook_document_no_errors() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_notebook_document( @@ -385,7 +397,8 @@ async fn notebook_document_no_errors() { #[tokio::test] async fn notebook_document_errors() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_notebook_document( @@ -416,7 +429,8 @@ async fn notebook_document_errors() { #[tokio::test] async fn notebook_document_lints() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_notebook_document( @@ -450,7 +464,8 @@ async fn notebook_document_lints() { #[tokio::test] async fn notebook_update_remove_cell_clears_errors() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_notebook_document( @@ -497,7 +512,8 @@ async fn notebook_update_remove_cell_clears_errors() { #[tokio::test] async fn close_notebook_clears_errors() { let errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&errors, &test_cases); updater .update_notebook_document( @@ -557,7 +573,9 @@ async fn update_notebook_with_valid_dependencies() { let fs = Rc::new(RefCell::new(fs)); let errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&errors, &fs); + let test_cases = RefCell::new(Vec::new()); + + let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs); updater .update_notebook_document( @@ -597,7 +615,9 @@ async fn update_notebook_reports_errors_from_dependencies() { let fs = Rc::new(RefCell::new(fs)); let errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&errors, &fs); + let test_cases = RefCell::new(Vec::new()); + + let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs); updater .update_notebook_document( @@ -675,7 +695,9 @@ async fn update_notebook_reports_errors_from_dependency_of_dependencies() { let fs = Rc::new(RefCell::new(fs)); let errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&errors, &fs); + let test_cases = RefCell::new(Vec::new()); + + let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs); updater .update_notebook_document( @@ -705,7 +727,8 @@ async fn update_notebook_reports_errors_from_dependency_of_dependencies() { #[tokio::test] async fn update_doc_updates_project() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document( @@ -758,6 +781,7 @@ async fn update_doc_updates_project() { #[tokio::test] async fn file_not_in_files_list() { let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); // Manifest has a "files" field. // One file is listed in it, the other is not. @@ -788,7 +812,7 @@ async fn file_not_in_files_list() { ); let fs = Rc::new(RefCell::new(fs)); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); // Open the file that is listed in the files list updater @@ -866,6 +890,7 @@ async fn file_not_in_files_list() { #[tokio::test] async fn file_not_under_src() { let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); // One file lives under the 'src' directory, the other does not. // The one that isn't under 'src' should not be associated with the project. @@ -890,7 +915,7 @@ async fn file_not_under_src() { ); let fs = Rc::new(RefCell::new(fs)); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); // Open the file that is not under src. updater @@ -964,7 +989,8 @@ async fn file_not_under_src() { #[tokio::test] async fn close_doc_prioritizes_fs() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document( @@ -1016,7 +1042,8 @@ async fn close_doc_prioritizes_fs() { #[tokio::test] async fn delete_manifest() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document( @@ -1077,7 +1104,8 @@ async fn delete_manifest() { #[tokio::test] async fn delete_manifest_then_close() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document( @@ -1122,7 +1150,8 @@ async fn delete_manifest_then_close() { #[tokio::test] async fn doc_switches_project() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document("nested_projects/src/subdir/src/a.qs", 1, "namespace A {}") @@ -1202,7 +1231,8 @@ async fn doc_switches_project() { #[tokio::test] async fn doc_switches_project_on_close() { let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater .update_document("nested_projects/src/subdir/src/a.qs", 1, "namespace A {}") @@ -1298,7 +1328,9 @@ async fn loading_lints_config_from_manifest() { let fs = Rc::new(RefCell::new(fs)); let received_errors = RefCell::new(Vec::new()); - let updater = new_updater_with_file_system(&received_errors, &fs); + let test_cases = RefCell::new(Vec::new()); + + let updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); // Check the LintConfig. check_lints_config( @@ -1350,7 +1382,9 @@ async fn lints_update_after_manifest_change() { let fs = Rc::new(RefCell::new(fs)); let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let test_cases = RefCell::new(Vec::new()); + + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); // Trigger a document update. updater @@ -1403,7 +1437,8 @@ async fn lints_prefer_workspace_over_defaults() { "namespace Foo { @EntryPoint() function Main() : Unit { let x = 5 / 0 + (2 ^ 4); } }"; let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater(&received_errors); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater(&received_errors, &test_cases); updater.update_configuration(WorkspaceConfigurationUpdate { lints_config: Some(vec![LintConfig { kind: LintKind::Ast(AstLint::DivisionByZero), @@ -1451,7 +1486,8 @@ async fn lints_prefer_manifest_over_workspace() { let fs = Rc::new(RefCell::new(fs)); let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); updater.update_configuration(WorkspaceConfigurationUpdate { lints_config: Some(vec![LintConfig { kind: LintKind::Ast(AstLint::DivisionByZero), @@ -1488,9 +1524,10 @@ async fn missing_dependency_reported() { let fs = Rc::new(RefCell::new(fs)); let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); - // Triger a document update. + // Trigger a document update. updater .update_document("parent/src/main.qs", 1, "function Main() : Unit {}") .await; @@ -1534,9 +1571,10 @@ async fn error_from_dependency_reported() { let fs = Rc::new(RefCell::new(fs)); let received_errors = RefCell::new(Vec::new()); - let mut updater = new_updater_with_file_system(&received_errors, &fs); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); - // Triger a document update. + // Trigger a document update. updater .update_document("parent/src/main.qs", 1, "function Main() : Unit {}") .await; @@ -1553,6 +1591,419 @@ async fn error_from_dependency_reported() { ); } +#[tokio::test] +async fn test_case_detected() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir("src", [file("main.qs", "function MyTestCase() : Unit {}")]), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update. + updater + .update_document( + "parent/src/main.qs", + 1, + "@Test() function MyTestCase() : Unit {}", + ) + .await; + + expect![[r#" + [ + TestCallables { + callables: [ + TestCallable { + callable_name: "main.MyTestCase", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 27, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + +#[tokio::test] +async fn test_case_removed() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir( + "src", + [file("main.qs", "@Test() function MyTestCase() : Unit {}")], + ), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update. + updater + .update_document("parent/src/main.qs", 1, "function MyTestCase() : Unit {}") + .await; + + expect![[r#" + [ + TestCallables { + callables: [], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + +#[tokio::test] +async fn test_case_modified() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir( + "src", + [file("main.qs", "@Test() function MyTestCase() : Unit {}")], + ), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update. + updater + .update_document( + "parent/src/main.qs", + 1, + "@Test() function MyTestCase() : Unit {}", + ) + .await; + + updater + .update_document( + "parent/src/main.qs", + 2, + "@Test() function MyTestCase2() : Unit { }", + ) + .await; + + expect![[r#" + [ + TestCallables { + callables: [ + TestCallable { + callable_name: "main.MyTestCase", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 27, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + TestCallables { + callables: [ + TestCallable { + callable_name: "main.MyTestCase2", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 28, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + +#[tokio::test] +async fn test_annotation_removed() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir( + "src", + [file("main.qs", "@Test() function MyTestCase() : Unit {}")], + ), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update. + updater + .update_document( + "parent/src/main.qs", + 1, + "@Test() function MyTestCase() : Unit {}", + ) + .await; + + updater + .update_document("parent/src/main.qs", 2, "function MyTestCase() : Unit {}") + .await; + + expect![[r#" + [ + TestCallables { + callables: [ + TestCallable { + callable_name: "main.MyTestCase", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 27, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + TestCallables { + callables: [], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + +#[tokio::test] +async fn multiple_tests() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir( + "src", + [file( + "main.qs", + "@Test() function Test1() : Unit {} @Test() function Test2() : Unit {}", + )], + ), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update. + updater + .update_document( + "parent/src/main.qs", + 1, + "@Test() function Test1() : Unit {} @Test() function Test2() : Unit {}", + ) + .await; + + expect![[r#" + [ + TestCallables { + callables: [ + TestCallable { + callable_name: "main.Test1", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 22, + }, + }, + }, + friendly_name: "parent", + }, + TestCallable { + callable_name: "main.Test2", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/main.qs", + range: Range { + start: Position { + line: 0, + column: 52, + }, + end: Position { + line: 0, + column: 57, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + +#[tokio::test] +async fn test_case_in_different_files() { + let fs = FsNode::Dir( + [dir( + "parent", + [ + file("qsharp.json", r#"{}"#), + dir( + "src", + [ + file("test1.qs", "@Test() function Test1() : Unit {}"), + file("test2.qs", "@Test() function Test2() : Unit {}"), + ], + ), + ], + )] + .into_iter() + .collect(), + ); + + let fs = Rc::new(RefCell::new(fs)); + let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); + let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs); + + // Trigger a document update for the first test file. + updater + .update_document( + "parent/src/test1.qs", + 1, + "@Test() function Test1() : Unit {}", + ) + .await; + + expect![[r#" + [ + TestCallables { + callables: [ + TestCallable { + callable_name: "test1.Test1", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/test1.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 22, + }, + }, + }, + friendly_name: "parent", + }, + TestCallable { + callable_name: "test2.Test2", + compilation_uri: "parent/qsharp.json", + location: Location { + source: "parent/src/test2.qs", + range: Range { + start: Position { + line: 0, + column: 17, + }, + end: Position { + line: 0, + column: 22, + }, + }, + }, + friendly_name: "parent", + }, + ], + }, + ] + "#]] + .assert_debug_eq(&test_cases.borrow()); +} + impl Display for DiagnosticUpdate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let DiagnosticUpdate { @@ -1590,23 +2041,34 @@ impl Display for DiagnosticUpdate { } } -fn new_updater(received_errors: &RefCell>) -> CompilationStateUpdater<'_> { +fn new_updater<'a>( + received_errors: &'a RefCell>, + received_test_cases: &'a RefCell>, +) -> CompilationStateUpdater<'a> { let diagnostic_receiver = move |update: DiagnosticUpdate| { let mut v = received_errors.borrow_mut(); v.push(update); }; + let test_callable_receiver = move |update: TestCallables| { + let mut v = received_test_cases.borrow_mut(); + v.push(update); + }; + CompilationStateUpdater::new( Rc::new(RefCell::new(CompilationState::default())), diagnostic_receiver, + test_callable_receiver, TestProjectHost { fs: TEST_FS.with(Clone::clone), }, + Encoding::Utf8, ) } fn new_updater_with_file_system<'a>( received_errors: &'a RefCell>, + received_test_cases: &'a RefCell>, fs: &Rc>, ) -> CompilationStateUpdater<'a> { let diagnostic_receiver = move |update: DiagnosticUpdate| { @@ -1614,10 +2076,17 @@ fn new_updater_with_file_system<'a>( v.push(update); }; + let test_callable_receiver = move |update: TestCallables| { + let mut v = received_test_cases.borrow_mut(); + v.push(update); + }; + CompilationStateUpdater::new( Rc::new(RefCell::new(CompilationState::default())), diagnostic_receiver, + test_callable_receiver, TestProjectHost { fs: fs.clone() }, + Encoding::Utf8, ) } diff --git a/language_service/src/test_utils.rs b/language_service/src/test_utils.rs index 5667e455d1..01c9fbf710 100644 --- a/language_service/src/test_utils.rs +++ b/language_service/src/test_utils.rs @@ -223,6 +223,7 @@ fn compile_project_with_markers_cursor_optional( LanguageFeatures::default(), ); + let test_cases = unit.package.get_test_callables(); let package_id = package_store.insert(unit); ( @@ -231,10 +232,12 @@ fn compile_project_with_markers_cursor_optional( user_package_id: package_id, kind: CompilationKind::OpenProject { package_graph_sources, + friendly_name: Arc::from("test project"), }, compile_errors: errors, project_errors: Vec::new(), dependencies: dependencies.into_iter().collect(), + test_cases, }, cursor_location, target_spans, @@ -294,6 +297,7 @@ where kind: CompilationKind::Notebook { project: None }, project_errors: Vec::new(), dependencies: [(source_package_id, None)].into_iter().collect(), + test_cases: Default::default(), } } diff --git a/language_service/src/tests.rs b/language_service/src/tests.rs index 645c1467f9..66808bd36a 100644 --- a/language_service/src/tests.rs +++ b/language_service/src/tests.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. use crate::{ - protocol::{DiagnosticUpdate, ErrorKind}, + protocol::{DiagnosticUpdate, ErrorKind, TestCallables}, Encoding, LanguageService, UpdateWorker, }; use expect_test::{expect, Expect}; @@ -15,8 +15,9 @@ pub(crate) mod test_fs; #[tokio::test] async fn single_document() { let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); let mut ls = LanguageService::new(Encoding::Utf8); - let mut worker = create_update_worker(&mut ls, &received_errors); + let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases); ls.update_document("foo.qs", 1, "namespace Foo { }"); @@ -49,8 +50,9 @@ async fn single_document() { #[allow(clippy::too_many_lines)] async fn single_document_update() { let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); let mut ls = LanguageService::new(Encoding::Utf8); - let mut worker = create_update_worker(&mut ls, &received_errors); + let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases); ls.update_document("foo.qs", 1, "namespace Foo { }"); @@ -114,8 +116,9 @@ async fn single_document_update() { #[allow(clippy::too_many_lines)] async fn document_in_project() { let received_errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); let mut ls = LanguageService::new(Encoding::Utf8); - let mut worker = create_update_worker(&mut ls, &received_errors); + let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases); ls.update_document("project/src/this_file.qs", 1, "namespace Foo { }"); @@ -167,8 +170,9 @@ async fn document_in_project() { #[tokio::test] async fn completions_requested_before_document_load() { let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); let mut ls = LanguageService::new(Encoding::Utf8); - let _worker = create_update_worker(&mut ls, &errors); + let _worker = create_update_worker(&mut ls, &errors, &test_cases); ls.update_document( "foo.qs", @@ -195,8 +199,9 @@ async fn completions_requested_before_document_load() { #[tokio::test] async fn completions_requested_after_document_load() { let errors = RefCell::new(Vec::new()); + let test_cases = RefCell::new(Vec::new()); let mut ls = LanguageService::new(Encoding::Utf8); - let mut worker = create_update_worker(&mut ls, &errors); + let mut worker = create_update_worker(&mut ls, &errors, &test_cases); // this test is a contrast to `completions_requested_before_document_load` // we want to ensure that completions load when the update_document call has been awaited @@ -264,6 +269,7 @@ type ErrorInfo = ( fn create_update_worker<'a>( ls: &mut LanguageService, received_errors: &'a RefCell>, + received_test_cases: &'a RefCell>, ) -> UpdateWorker<'a> { let worker = ls.create_update_worker( |update: DiagnosticUpdate| { @@ -285,6 +291,10 @@ fn create_update_worker<'a>( project_errors.collect(), )); }, + move |update: TestCallables| { + let mut v = received_test_cases.borrow_mut(); + v.push(update); + }, TestProjectHost { fs: TEST_FS.with(Clone::clone), }, diff --git a/library/fixed_point/src/Tests.qs b/library/fixed_point/src/Tests.qs index 24826d0765..29568bea78 100644 --- a/library/fixed_point/src/Tests.qs +++ b/library/fixed_point/src/Tests.qs @@ -9,11 +9,7 @@ import Std.Convert.IntAsDouble; import Std.Math.AbsD; import Operations.*; -operation Main() : Unit { - FxpMeasurementTest(); - FxpOperationTests(); -} - +@Test() operation FxpMeasurementTest() : Unit { for numQubits in 3..12 { for numIntBits in 2..numQubits { @@ -43,6 +39,7 @@ operation TestConstantMeasurement(constant : Double, registerWidth : Int, intege ResetAll(register); } +@Test() operation FxpOperationTests() : Unit { for i in 0..10 { let constant1 = 0.2 * IntAsDouble(i); @@ -54,6 +51,7 @@ operation FxpOperationTests() : Unit { TestSquare(constant1); } } + operation TestSquare(a : Double) : Unit { Message($"Testing Square({a})"); use resultRegister = Qubit[30]; diff --git a/library/qtest/src/Tests.qs b/library/qtest/src/Tests.qs index ab8b9e147d..1f89e33048 100644 --- a/library/qtest/src/Tests.qs +++ b/library/qtest/src/Tests.qs @@ -51,19 +51,28 @@ function BasicTests() : Unit { ("Should return 42", TestCaseOne, 43), ("Should add one", () -> AddOne(5), 42), ("Should add one", () -> AddOne(5), 6) - ]; + ] +} +@Test() +function ReturnsFalseForFailingTest() : Unit { Fact( - not Functions.CheckAllTestCases(sample_tests), + not Functions.CheckAllTestCases(SampleTestData()), "Test harness failed to return false for a failing tests." ); +} +@Test() +function ReturnsTrueForPassingTest() : Unit { Fact( Functions.CheckAllTestCases([("always returns true", () -> true, true)]), "Test harness failed to return true for a passing test" ); +} - let run_all_result = Functions.RunAllTestCases(sample_tests); +@Test() +function RunAllTests() : Unit { + let run_all_result = Functions.RunAllTestCases(SampleTestData()); Fact( Length(run_all_result) == 3, diff --git a/library/rotations/src/Tests.qs b/library/rotations/src/Tests.qs index 0d9267e52c..8bb9a2f7fc 100644 --- a/library/rotations/src/Tests.qs +++ b/library/rotations/src/Tests.qs @@ -6,11 +6,7 @@ import Std.Math.HammingWeightI, Std.Math.PI; import HammingWeightPhasing.HammingWeightPhasing, HammingWeightPhasing.WithHammingWeight; -operation Main() : Unit { - TestHammingWeight(); - TestPhasing(); -} - +@Test() operation TestHammingWeight() : Unit { // exhaustive use qs = Qubit[4]; @@ -41,6 +37,7 @@ operation TestHammingWeight() : Unit { } } +@Test() operation TestPhasing() : Unit { for theta in [1.0, 2.0, 0.0, -0.5, 5.0 * PI()] { for numQubits in 1..6 { diff --git a/library/signed/src/Tests.qs b/library/signed/src/Tests.qs index b8afe37441..3fbe29d3b9 100644 --- a/library/signed/src/Tests.qs +++ b/library/signed/src/Tests.qs @@ -5,17 +5,10 @@ import Std.Diagnostics.Fact; import Operations.Invert2sSI; import Measurement.MeasureSignedInteger; -/// This entrypoint runs tests for the signed integer library. -operation Main() : Unit { - UnsignedOpTests(); - Fact(Qtest.Operations.CheckAllTestCases(MeasureSignedIntTests()), "SignedInt tests failed"); - SignedOpTests(); - -} - -function MeasureSignedIntTests() : (String, Int, (Qubit[]) => (), (Qubit[]) => Int, Int)[] { - [ - ("0b0001 == 1", 4, (qs) => X(qs[0]), (qs) => MeasureSignedInteger(qs, 4), 1), +@Test() +operation MeasureSignedIntTests() : Unit { + let testCases = [ + ("0b0001 == 1", 4, (qs) => X(qs[0]), (qs) => MeasureSignedInteger(qs, 6), 1), ("0b1111 == -1", 4, (qs) => { X(qs[0]); X(qs[1]); X(qs[2]); X(qs[3]); }, (qs) => MeasureSignedInteger(qs, 4), -1), ("0b01000 == 8", 5, (qs) => X(qs[3]), (qs) => MeasureSignedInteger(qs, 5), 8), ("0b11110 == -2", 5, (qs) => { @@ -25,9 +18,11 @@ function MeasureSignedIntTests() : (String, Int, (Qubit[]) => (), (Qubit[]) => I X(qs[4]); }, (qs) => MeasureSignedInteger(qs, 5), -2), ("0b11000 == -8", 5, (qs) => { X(qs[3]); X(qs[4]); }, (qs) => MeasureSignedInteger(qs, 5), -8) - ] + ]; + Fact(Qtest.Operations.CheckAllTestCases(testCases), "SignedInt tests failed"); } +@Test() operation SignedOpTests() : Unit { use a = Qubit[32]; use b = Qubit[32]; @@ -54,6 +49,7 @@ operation SignedOpTests() : Unit { } +@Test() operation UnsignedOpTests() : Unit { use a = Qubit[2]; use b = Qubit[2]; diff --git a/npm/qsharp/src/browser.ts b/npm/qsharp/src/browser.ts index 258d80176e..607b0116df 100644 --- a/npm/qsharp/src/browser.ts +++ b/npm/qsharp/src/browser.ts @@ -168,12 +168,17 @@ export type { IStructStepResult, IWorkspaceEdit, ProjectLoader, + ITestDescriptor, VSDiagnostic, } from "../lib/web/qsc_wasm.js"; export { type Dump, type ShotResult } from "./compiler/common.js"; export { type CompilerState, type ProgramConfig } from "./compiler/compiler.js"; export { QscEventTarget } from "./compiler/events.js"; -export type { LanguageServiceEvent } from "./language-service/language-service.js"; +export type { + LanguageServiceDiagnosticEvent, + LanguageServiceEvent, + LanguageServiceTestCallablesEvent, +} from "./language-service/language-service.js"; export { default as samples } from "./samples.generated.js"; export { log, type LogLevel, type TargetProfile }; export type { diff --git a/npm/qsharp/src/compiler/compiler.ts b/npm/qsharp/src/compiler/compiler.ts index 50f73cc387..453984c7b1 100644 --- a/npm/qsharp/src/compiler/compiler.ts +++ b/npm/qsharp/src/compiler/compiler.ts @@ -123,6 +123,9 @@ export class Compiler implements ICompiler { (uri: string, version: number | undefined, errors: VSDiagnostic[]) => { diags = errors; }, + () => { + // do nothing; test callables are not reported in checkCode + }, { readFile: async () => null, listDirectory: async () => [], diff --git a/npm/qsharp/src/language-service/language-service.ts b/npm/qsharp/src/language-service/language-service.ts index c27db6de25..268b742bd9 100644 --- a/npm/qsharp/src/language-service/language-service.ts +++ b/npm/qsharp/src/language-service/language-service.ts @@ -16,6 +16,7 @@ import type { IWorkspaceEdit, LanguageService, VSDiagnostic, + ITestDescriptor, } from "../../lib/web/qsc_wasm.js"; import { IProjectHost } from "../browser.js"; import { log } from "../log.js"; @@ -26,8 +27,7 @@ import { } from "../workers/common.js"; type QscWasm = typeof import("../../lib/web/qsc_wasm.js"); -// Only one event type for now -export type LanguageServiceEvent = { +export type LanguageServiceDiagnosticEvent = { type: "diagnostics"; detail: { uri: string; @@ -36,6 +36,17 @@ export type LanguageServiceEvent = { }; }; +export type LanguageServiceTestCallablesEvent = { + type: "testCallables"; + detail: { + callables: ITestDescriptor[]; + }; +}; + +export type LanguageServiceEvent = + | LanguageServiceDiagnosticEvent + | LanguageServiceTestCallablesEvent; + // These need to be async/promise results for when communicating across a WebWorker, however // for running the compiler in the same thread the result will be synchronous (a resolved promise). export interface ILanguageService { @@ -127,6 +138,7 @@ export class QSharpLanguageService implements ILanguageService { this.backgroundWork = this.languageService.start_background_work( this.onDiagnostics.bind(this), + this.onTestCallables.bind(this), host, ); } @@ -263,7 +275,8 @@ export class QSharpLanguageService implements ILanguageService { diagnostics: VSDiagnostic[], ) { try { - const event = new Event("diagnostics") as LanguageServiceEvent & Event; + const event = new Event("diagnostics") as LanguageServiceDiagnosticEvent & + Event; event.detail = { uri, version: version ?? 0, @@ -274,6 +287,20 @@ export class QSharpLanguageService implements ILanguageService { log.error("Error in onDiagnostics", e); } } + + async onTestCallables(callables: ITestDescriptor[]) { + try { + const event = new Event( + "testCallables", + ) as LanguageServiceTestCallablesEvent & Event; + event.detail = { + callables, + }; + this.eventHandler.dispatchEvent(event); + } catch (e) { + log.error("Error in onTestCallables", e); + } + } } /** @@ -283,7 +310,7 @@ export class QSharpLanguageService implements ILanguageService { */ export const languageServiceProtocol: ServiceProtocol< ILanguageService, - LanguageServiceEvent + LanguageServiceDiagnosticEvent > = { class: QSharpLanguageService, methods: { diff --git a/npm/qsharp/test/basics.js b/npm/qsharp/test/basics.js index eae272142e..abb787d993 100644 --- a/npm/qsharp/test/basics.js +++ b/npm/qsharp/test/basics.js @@ -504,6 +504,80 @@ test("language service diagnostics", async () => { assert(gotDiagnostics); }); +test("test callable discovery", async () => { + const languageService = getLanguageService(); + let gotTests = false; + languageService.addEventListener("testCallables", (event) => { + gotTests = true; + assert.equal(event.type, "testCallables"); + assert.equal(event.detail.callables.length, 1); + assert.equal(event.detail.callables[0].callableName, "Sample.main"); + assert.deepStrictEqual(event.detail.callables[0].location, { + source: "test.qs", + span: { + end: { + character: 18, + line: 2, + }, + start: { + character: 14, + line: 2, + }, + }, + }); + }); + await languageService.updateDocument( + "test.qs", + 1, + `namespace Sample { + @Test() + operation main() : Unit {} +}`, + ); + + // dispose() will complete when the language service has processed all the updates. + await languageService.dispose(); + assert(gotTests); +}); + +test("multiple test callable discovery", async () => { + const languageService = getLanguageService(); + let gotTests = false; + languageService.addEventListener("testCallables", (event) => { + gotTests = true; + assert.equal(event.type, "testCallables"); + assert.equal(event.detail.callables.length, 4); + assert.equal(event.detail.callables[0].callableName, "Sample.test1"); + assert.equal(event.detail.callables[1].callableName, "Sample.test2"); + assert.equal(event.detail.callables[2].callableName, "Sample2.test1"); + assert.equal(event.detail.callables[3].callableName, "Sample2.test2"); + }); + await languageService.updateDocument( + "test.qs", + 1, + `namespace Sample { + @Test() + operation test1() : Unit {} + + @Test() + function test2() : Unit {} +} +namespace Sample2 { + @Test() + operation test1() : Unit {} + + @Test() + function test2() : Unit {} + } +} +`, + ); + + // dispose() will complete when the language service has processed all the updates. + await languageService.dispose(); + assert(gotTests); +}); + test("diagnostics with related spans", async () => { const languageService = getLanguageService(); let gotDiagnostics = false; diff --git a/playground/src/editor.tsx b/playground/src/editor.tsx index c624d78b27..d3dcad9c06 100644 --- a/playground/src/editor.tsx +++ b/playground/src/editor.tsx @@ -8,12 +8,12 @@ import { CompilerState, ICompilerWorker, ILanguageServiceWorker, - LanguageServiceEvent, QscEventTarget, VSDiagnostic, log, ProgramConfig, TargetProfile, + LanguageServiceDiagnosticEvent, } from "qsharp-lang"; import { Exercise, getExerciseSources } from "qsharp-lang/katas-md"; import { codeToCompressedBase64, lsRangeToMonacoRange } from "./utils.js"; @@ -311,7 +311,7 @@ export function Editor(props: { : [{ lint: "needlessOperation", level: "warn" }], }); - function onDiagnostics(evt: LanguageServiceEvent) { + function onDiagnostics(evt: LanguageServiceDiagnosticEvent) { const diagnostics = evt.detail.diagnostics; errMarks.current.checkDiags = diagnostics; markErrors(); diff --git a/samples/language/TestAttribute.qs b/samples/language/TestAttribute.qs new file mode 100644 index 0000000000..95f4e7371b --- /dev/null +++ b/samples/language/TestAttribute.qs @@ -0,0 +1,21 @@ +// # Sample +// Test Attribute +// +// # Description +// A Q# function or operation (callable) can be designated as a test case via the @Test() attribute. +// In VS Code, these tests will show up in the "test explorer" in the Activity Bar. +// If the test crashes, it is a failure. If it runs to completion, it is a success. + +// Tests must take zero parameters, and contain no generic types (type parameters). +@Test() +function TestPass() : Unit { + Std.Diagnostics.Fact(true, "This test should pass."); +} + +// Because this function asserts `false`, it will crash and the test will fail. +@Test() +function TestFail() : Unit { + Std.Diagnostics.Fact(false, "This test should fail."); +} + +function Main() : Unit {} \ No newline at end of file diff --git a/samples_test/src/tests/language.rs b/samples_test/src/tests/language.rs index f2362a4736..4cd55b69fe 100644 --- a/samples_test/src/tests/language.rs +++ b/samples_test/src/tests/language.rs @@ -362,3 +362,6 @@ pub const CLASSCONSTRAINTS_EXPECT_DEBUG: Expect = expect![[r#" false true ()"#]]; + +pub const TESTATTRIBUTE_EXPECT: Expect = expect!["()"]; +pub const TESTATTRIBUTE_EXPECT_DEBUG: Expect = expect!["()"]; diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 71de7e08b6..c695bf7b1e 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -3,7 +3,14 @@ import { TextDocument, Uri, Range, Location } from "vscode"; import { Utils } from "vscode-uri"; -import { ILocation, IRange, IWorkspaceEdit, VSDiagnostic } from "qsharp-lang"; +import { + getCompilerWorker, + ICompilerWorker, + ILocation, + IRange, + IWorkspaceEdit, + VSDiagnostic, +} from "qsharp-lang"; import * as vscode from "vscode"; export const qsharpLanguageId = "qsharp"; @@ -32,7 +39,7 @@ export function basename(path: string): string | undefined { return path.replace(/\/+$/, "").split("/").pop(); } -export function toVscodeRange(range: IRange): Range { +export function toVsCodeRange(range: IRange): Range { return new Range( range.start.line, range.start.character, @@ -41,18 +48,18 @@ export function toVscodeRange(range: IRange): Range { ); } -export function toVscodeLocation(location: ILocation): any { - return new Location(Uri.parse(location.source), toVscodeRange(location.span)); +export function toVsCodeLocation(location: ILocation): Location { + return new Location(Uri.parse(location.source), toVsCodeRange(location.span)); } -export function toVscodeWorkspaceEdit( +export function toVsCodeWorkspaceEdit( iWorkspaceEdit: IWorkspaceEdit, ): vscode.WorkspaceEdit { const workspaceEdit = new vscode.WorkspaceEdit(); for (const [source, edits] of iWorkspaceEdit.changes) { const uri = vscode.Uri.parse(source, true); const vsEdits = edits.map((edit) => { - return new vscode.TextEdit(toVscodeRange(edit.range), edit.newText); + return new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText); }); workspaceEdit.set(uri, vsEdits); } @@ -73,7 +80,7 @@ export function toVsCodeDiagnostic(d: VSDiagnostic): vscode.Diagnostic { break; } const vscodeDiagnostic = new vscode.Diagnostic( - toVscodeRange(d.range), + toVsCodeRange(d.range), d.message, severity, ); @@ -88,10 +95,18 @@ export function toVsCodeDiagnostic(d: VSDiagnostic): vscode.Diagnostic { if (d.related) { vscodeDiagnostic.relatedInformation = d.related.map((r) => { return new vscode.DiagnosticRelatedInformation( - toVscodeLocation(r.location), + toVsCodeLocation(r.location), r.message, ); }); } return vscodeDiagnostic; } + +export function loadCompilerWorker(extensionUri: vscode.Uri): ICompilerWorker { + const compilerWorkerScriptPath = vscode.Uri.joinPath( + extensionUri, + "./out/compilerWorker.js", + ).toString(); + return getCompilerWorker(compilerWorkerScriptPath); +} diff --git a/vscode/src/debugger/output.ts b/vscode/src/debugger/output.ts index 00dbb0da74..bf180c247e 100644 --- a/vscode/src/debugger/output.ts +++ b/vscode/src/debugger/output.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { QscEventTarget } from "qsharp-lang"; +import { QscEventTarget, VSDiagnostic } from "qsharp-lang"; function formatComplex(real: number, imag: number) { // Format -0 as 0 @@ -72,7 +72,12 @@ export function createDebugConsoleEventTarget(out: (message: string) => void) { }); eventTarget.addEventListener("Result", (evt) => { - out(`${evt.detail.value}`); + // sometimes these are VS Diagnostics + if ((evt.detail.value as VSDiagnostic).message !== undefined) { + out(`${(evt.detail.value as VSDiagnostic).message}`); + } else { + out(`${evt.detail.value}`); + } }); return eventTarget; diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts index 0db074e737..9108ca43fc 100644 --- a/vscode/src/debugger/session.ts +++ b/vscode/src/debugger/session.ts @@ -30,7 +30,7 @@ import { log, } from "qsharp-lang"; import { updateCircuitPanel } from "../circuit"; -import { basename, isQsharpDocument, toVscodeRange } from "../common"; +import { basename, isQsharpDocument, toVsCodeRange } from "../common"; import { DebugEvent, EventType, @@ -134,7 +134,7 @@ export class QscDebugSession extends LoggingDebugSession { ), }; return { - range: toVscodeRange(location.range), + range: toVsCodeRange(location.range), uiLocation, breakpoint: this.createBreakpoint(location.id, uiLocation), } as IBreakpointLocationData; diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 750700f373..d5b54f6eb9 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -67,9 +67,7 @@ export async function activate( context.subscriptions.push(...activateTargetProfileStatusBarItem()); - context.subscriptions.push( - ...(await activateLanguageService(context.extensionUri)), - ); + context.subscriptions.push(...(await activateLanguageService(context))); context.subscriptions.push(...startOtherQSharpDiagnostics()); diff --git a/vscode/src/language-service/activate.ts b/vscode/src/language-service/activate.ts index 89d407af7e..09a6f739cd 100644 --- a/vscode/src/language-service/activate.ts +++ b/vscode/src/language-service/activate.ts @@ -37,8 +37,15 @@ import { registerQSharpNotebookCellUpdateHandlers } from "./notebook.js"; import { createReferenceProvider } from "./references.js"; import { createRenameProvider } from "./rename.js"; import { createSignatureHelpProvider } from "./signature.js"; - -export async function activateLanguageService(extensionUri: vscode.Uri) { +import { startTestDiscovery } from "./testExplorer.js"; + +/** + * Returns all of the subscriptions that should be registered for the language service. + */ +export async function activateLanguageService( + context: vscode.ExtensionContext, +): Promise { + const extensionUri = context.extensionUri; const subscriptions: vscode.Disposable[] = []; const languageService = await loadLanguageService(extensionUri); @@ -46,6 +53,9 @@ export async function activateLanguageService(extensionUri: vscode.Uri) { // diagnostics subscriptions.push(...startLanguageServiceDiagnostics(languageService)); + // test explorer + subscriptions.push(...startTestDiscovery(languageService, context)); + // synchronize document contents subscriptions.push(...registerDocumentUpdateHandlers(languageService)); @@ -147,7 +157,9 @@ export async function activateLanguageService(extensionUri: vscode.Uri) { return subscriptions; } -async function loadLanguageService(baseUri: vscode.Uri) { +async function loadLanguageService( + baseUri: vscode.Uri, +): Promise { const start = performance.now(); const wasmUri = vscode.Uri.joinPath(baseUri, "./wasm/qsc_wasm_bg.wasm"); const wasmBytes = await vscode.workspace.fs.readFile(wasmUri); @@ -168,7 +180,14 @@ async function loadLanguageService(baseUri: vscode.Uri) { ); return languageService; } -function registerDocumentUpdateHandlers(languageService: ILanguageService) { + +/** + * This function returns all of the subscriptions that should be registered for the language service. + * Additionally, if an `eventEmitter` is passed in, will fire an event when a document is updated. + */ +function registerDocumentUpdateHandlers( + languageService: ILanguageService, +): vscode.Disposable[] { vscode.workspace.textDocuments.forEach((document) => { updateIfQsharpDocument(document); }); diff --git a/vscode/src/language-service/codeActions.ts b/vscode/src/language-service/codeActions.ts index 513f28fe88..c3c29b73bc 100644 --- a/vscode/src/language-service/codeActions.ts +++ b/vscode/src/language-service/codeActions.ts @@ -3,7 +3,7 @@ import { ILanguageService, ICodeAction } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeWorkspaceEdit } from "../common"; +import { toVsCodeWorkspaceEdit } from "../common"; export function createCodeActionsProvider(languageService: ILanguageService) { return new QSharpCodeActionProvider(languageService); @@ -31,7 +31,7 @@ function toCodeAction(iCodeAction: ICodeAction): vscode.CodeAction { toCodeActionKind(iCodeAction.kind), ); if (iCodeAction.edit) { - codeAction.edit = toVscodeWorkspaceEdit(iCodeAction.edit); + codeAction.edit = toVsCodeWorkspaceEdit(iCodeAction.edit); } codeAction.isPreferred = iCodeAction.isPreferred; return codeAction; diff --git a/vscode/src/language-service/codeLens.ts b/vscode/src/language-service/codeLens.ts index 98672811cb..f5d952237b 100644 --- a/vscode/src/language-service/codeLens.ts +++ b/vscode/src/language-service/codeLens.ts @@ -7,7 +7,7 @@ import { qsharpLibraryUriScheme, } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeRange } from "../common"; +import { toVsCodeRange } from "../common"; export function createCodeLensProvider(languageService: ILanguageService) { return new QSharpCodeLensProvider(languageService); @@ -71,7 +71,7 @@ function mapCodeLens(cl: ICodeLens): vscode.CodeLens { break; } - return new vscode.CodeLens(toVscodeRange(cl.range), { + return new vscode.CodeLens(toVsCodeRange(cl.range), { title, command, arguments: args, diff --git a/vscode/src/language-service/completion.ts b/vscode/src/language-service/completion.ts index 92f2fc8bc8..444a46cdfd 100644 --- a/vscode/src/language-service/completion.ts +++ b/vscode/src/language-service/completion.ts @@ -4,7 +4,7 @@ import { ILanguageService, samples } from "qsharp-lang"; import * as vscode from "vscode"; import { CompletionItem } from "vscode"; -import { toVscodeRange } from "../common"; +import { toVsCodeRange } from "../common"; import { EventType, sendTelemetryEvent } from "../telemetry"; export function createCompletionItemProvider( @@ -84,7 +84,7 @@ class QSharpCompletionItemProvider implements vscode.CompletionItemProvider { item.sortText = c.sortText; item.detail = c.detail; item.additionalTextEdits = c.additionalTextEdits?.map((edit) => { - return new vscode.TextEdit(toVscodeRange(edit.range), edit.newText); + return new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText); }); return item; }); diff --git a/vscode/src/language-service/definition.ts b/vscode/src/language-service/definition.ts index fb2f6a6a23..3b2f8e1607 100644 --- a/vscode/src/language-service/definition.ts +++ b/vscode/src/language-service/definition.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeLocation } from "../common"; +import { toVsCodeLocation } from "../common"; export function createDefinitionProvider(languageService: ILanguageService) { return new QSharpDefinitionProvider(languageService); @@ -21,6 +21,6 @@ class QSharpDefinitionProvider implements vscode.DefinitionProvider { position, ); if (!definition) return null; - return toVscodeLocation(definition); + return toVsCodeLocation(definition); } } diff --git a/vscode/src/language-service/format.ts b/vscode/src/language-service/format.ts index fb9275dfd5..a3a2b7f71e 100644 --- a/vscode/src/language-service/format.ts +++ b/vscode/src/language-service/format.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeRange } from "../common"; +import { toVsCodeRange } from "../common"; import { EventType, FormatEvent, sendTelemetryEvent } from "../telemetry"; import { getRandomGuid } from "../utils"; @@ -50,7 +50,7 @@ class QSharpFormattingProvider } let edits = lsEdits.map( - (edit) => new vscode.TextEdit(toVscodeRange(edit.range), edit.newText), + (edit) => new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText), ); if (range) { diff --git a/vscode/src/language-service/hover.ts b/vscode/src/language-service/hover.ts index 4307174099..be17cf20b8 100644 --- a/vscode/src/language-service/hover.ts +++ b/vscode/src/language-service/hover.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeRange } from "../common"; +import { toVsCodeRange } from "../common"; export function createHoverProvider(languageService: ILanguageService) { return new QSharpHoverProvider(languageService); @@ -21,7 +21,7 @@ class QSharpHoverProvider implements vscode.HoverProvider { hover && new vscode.Hover( new vscode.MarkdownString(hover.contents), - toVscodeRange(hover.span), + toVsCodeRange(hover.span), ) ); } diff --git a/vscode/src/language-service/references.ts b/vscode/src/language-service/references.ts index 528038c189..84ada029ac 100644 --- a/vscode/src/language-service/references.ts +++ b/vscode/src/language-service/references.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeLocation } from "../common"; +import { toVsCodeLocation } from "../common"; export function createReferenceProvider(languageService: ILanguageService) { return new QSharpReferenceProvider(languageService); @@ -24,6 +24,6 @@ class QSharpReferenceProvider implements vscode.ReferenceProvider { context.includeDeclaration, ); if (!lsReferences) return []; - return lsReferences.map(toVscodeLocation); + return lsReferences.map(toVsCodeLocation); } } diff --git a/vscode/src/language-service/rename.ts b/vscode/src/language-service/rename.ts index 02060ab4f5..7ae45ce218 100644 --- a/vscode/src/language-service/rename.ts +++ b/vscode/src/language-service/rename.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeRange, toVscodeWorkspaceEdit } from "../common"; +import { toVsCodeRange, toVsCodeWorkspaceEdit } from "../common"; export function createRenameProvider(languageService: ILanguageService) { return new QSharpRenameProvider(languageService); @@ -25,7 +25,7 @@ class QSharpRenameProvider implements vscode.RenameProvider { newName, ); if (!rename) return null; - return toVscodeWorkspaceEdit(rename); + return toVsCodeWorkspaceEdit(rename); } async prepareRename( @@ -40,7 +40,7 @@ class QSharpRenameProvider implements vscode.RenameProvider { ); if (prepareRename) { return { - range: toVscodeRange(prepareRename.range), + range: toVsCodeRange(prepareRename.range), placeholder: prepareRename.newText, }; } else { diff --git a/vscode/src/language-service/testExplorer.ts b/vscode/src/language-service/testExplorer.ts new file mode 100644 index 0000000000..b6204d0a1e --- /dev/null +++ b/vscode/src/language-service/testExplorer.ts @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ICompilerWorker, + ILanguageService, + ITestDescriptor, + log, +} from "qsharp-lang"; +import * as vscode from "vscode"; +import { loadCompilerWorker, toVsCodeLocation, toVsCodeRange } from "../common"; +import { getProgramForDocument } from "../programConfig"; +import { createDebugConsoleEventTarget } from "../debugger/output"; + +let worker: ICompilerWorker | null = null; +/** + * Returns a singleton instance of the compiler worker. + * @param context The extension context. + * @returns The compiler worker. + **/ +function getLocalCompilerWorker(extensionUri: vscode.Uri): ICompilerWorker { + if (worker !== null) { + return worker; + } + + worker = loadCompilerWorker(extensionUri); + + return worker; +} + +export function startTestDiscovery( + languageService: ILanguageService, + context: vscode.ExtensionContext, +): vscode.Disposable[] { + // test explorer features + const testController: vscode.TestController = + vscode.tests.createTestController("qsharpTestController", "Q# Tests"); + const runHandler = (request: vscode.TestRunRequest) => { + if (!request.continuous) { + return startTestRun(request); + } + }; + + // runs an individual test run + // or test group (a test run where there are child tests) + const startTestRun = async (request: vscode.TestRunRequest) => { + // use the compiler worker to run the test in the interpreter + + log.trace("Starting test run, request was", JSON.stringify(request)); + + const worker = getLocalCompilerWorker(context.extensionUri); + + // request.include is an array of test cases to run, and it is only provided if a specific set of tests were selected. + if (request.include !== undefined) { + for (const testCase of request.include || []) { + await runTestCase(testController, testCase, request, worker); + } + } else { + // alternatively, if there is no include specified, we run all tests that are not in the exclude list + for (const [, testCase] of testController.items) { + if (request.exclude && request.exclude.includes(testCase)) { + continue; + } + await runTestCase(testController, testCase, request, worker); + } + } + }; + + /** + * Given a single test case, run it in the worker (which runs the interpreter) and report results back to the + * `TestController` as a side effect. + * + * This function manages its own event target for the results of the test run and uses the controller to render the output in the VS Code UI. + **/ + async function runTestCase( + ctrl: vscode.TestController, + testCase: vscode.TestItem, + request: vscode.TestRunRequest, + worker: ICompilerWorker, + ): Promise { + log.trace("Running Q# test: ", testCase.id); + if (testCase.children.size > 0) { + for (const childTestCase of testCase.children) { + await runTestCase(ctrl, childTestCase[1], request, worker); + } + return; + } + const run = ctrl.createTestRun(request); + const evtTarget = createDebugConsoleEventTarget((msg) => { + run.appendOutput(`${msg}\n`); + }); + evtTarget.addEventListener("Result", (msg) => { + if (msg.detail.success) { + run.passed(testCase); + } else { + const failureLocation = + msg.detail?.value?.uri || + (msg.detail?.value?.related && + msg.detail.value.related[0].location?.source) || + null; + + const message: vscode.TestMessage = { + message: msg.detail.value.message, + location: + failureLocation === null + ? undefined + : { + range: toVsCodeRange(msg.detail.value.range), + uri: vscode.Uri.parse(failureLocation), + }, + }; + run.failed(testCase, message); + } + run.end(); + }); + + const callableExpr = `${testCase.id}()`; + const uri = testCase.uri; + if (!uri) { + log.error(`No compilation URI for test ${testCase.id}`); + run.appendOutput(`No compilation URI for test ${testCase.id}\r\n`); + return; + } + const programResult = await getProgramForDocument(uri); + + if (!programResult.success) { + throw new Error(programResult.errorMsg); + } + + const program = programResult.programConfig; + + try { + await worker.run(program, callableExpr, 1, evtTarget); + } catch (error) { + log.error(`Error running test ${testCase.id}:`, error); + run.appendOutput(`Error running test ${testCase.id}: ${error}\r\n`); + } + log.trace("ran test:", testCase.id); + } + + testController.createRunProfile( + "Interpreter", + vscode.TestRunProfileKind.Run, + runHandler, + true, + undefined, + false, + ); + + const testVersions = new WeakMap(); + async function onTestCallables(evt: { + detail: { + callables: ITestDescriptor[]; + }; + }) { + let currentVersion = 0; + for (const [, testItem] of testController.items) { + currentVersion = (testVersions.get(testItem) || 0) + 1; + break; + } + + for (const { callableName, location, friendlyName } of evt.detail + .callables) { + const vscLocation = toVsCodeLocation(location); + // below, we transform `parts` into a tree structure for the test explorer + // e.g. if we have the following callables: + // - "TestSuite.Test1" + // - "TestSuite.Test2" + // they will be turned into the parts: + // - ["FriendlyName", "TestSuite", "Test1"] + // - ["FriendlyName", "TestSuite", "Test2"] + // and then into a tree structure: + // - FriendlyName + // - TestSuite + // - Test1 + // - Test2 + const parts = [friendlyName, ...callableName.split(".")]; + + let rover = testController.items; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + // the `id` is used to actually call the test item + // it is constructed in the test runner via: callableExpr = `${testCase.id}()`; + // so it should be the full path to the test item, not including the "friendly name" (since that isn't in the callable expr), + // if and only if it is a "leaf" (an actual test case) + // note that leaves are test cases and internal nodes are not test cases + // in teh above example, TestSuite would have the id `FriendlyName.TestSuite`, and Test1 would have the id `TestSuite.Test1` + const id = + i === parts.length - 1 + ? callableName + : parts.slice(0, i + 1).join("."); + // this test item may have already existed from a previous scan, so fetch it + let testItem = rover.get(id); + + // if it doesn't exist, create it + if (!testItem) { + testItem = testController.createTestItem(id, part, vscLocation.uri); + // if this is the actual test item, give it a range and a compilation uri + // this triggers the little "green play button" in the test explorer and in the left + // gutter of the editor + if (i === parts.length - 1) { + testItem.range = vscLocation.range; + } + rover.add(testItem); + } + testVersions.set(testItem, currentVersion); + rover = testItem.children; + } + } + + // delete old items from previous versions that were not updated + deleteItemsNotOfVersion( + currentVersion, + testController.items, + testController, + ); + } + + function deleteItemsNotOfVersion( + version: number, + items: vscode.TestItemCollection, + testController: vscode.TestController, + ) { + for (const [id, testItem] of items) { + deleteItemsNotOfVersion(version, testItem.children, testController); + if (testVersions.get(testItem) !== version) { + items.delete(id); + } + } + } + + languageService.addEventListener("testCallables", onTestCallables); + + return [ + { + dispose: () => { + languageService.removeEventListener("testCallables", onTestCallables); + }, + }, + testController, + ]; +} diff --git a/vscode/src/projectSystem.ts b/vscode/src/projectSystem.ts index cff724769f..935231f43c 100644 --- a/vscode/src/projectSystem.ts +++ b/vscode/src/projectSystem.ts @@ -24,6 +24,14 @@ async function findManifestDocument( // vscode-vfs://github%2B7b2276223a312c22726566223a7b2274797065223a332c226964223a22383439227d7d/microsoft/qsharp/samples/shor.qs const currentDocumentUri = URI.parse(currentDocumentUriString); + // if this document is itself a manifest file, then we've found it + if (currentDocumentUri.path.endsWith("qsharp.json")) { + return { + directory: Utils.dirname(currentDocumentUri), + manifest: currentDocumentUri, + }; + } + // Untitled documents don't have a file location, thus can't have a manifest if (currentDocumentUri.scheme === "untitled") return null; diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs index 6f2f807920..76ef3a9662 100644 --- a/wasm/src/language_service.rs +++ b/wasm/src/language_service.rs @@ -6,12 +6,13 @@ use crate::{ line_column::{ILocation, IPosition, IRange, Location, Position, Range}, project_system::ProjectHost, serializable_type, + test_discovery::TestDescriptor, }; use qsc::{ self, line_column::Encoding, linter::LintConfig, target::Profile, LanguageFeatures, PackageType, }; use qsc_project::Manifest; -use qsls::protocol::DiagnosticUpdate; +use qsls::protocol::{DiagnosticUpdate, TestCallable, TestCallables}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -31,12 +32,10 @@ impl LanguageService { pub fn start_background_work( &mut self, - diagnostics_callback: DiagnosticsCallback, + diagnostics_callback: &DiagnosticsCallback, + test_callables_callback: &TestCallableCallback, host: ProjectHost, ) -> js_sys::Promise { - let diagnostics_callback = - crate::project_system::to_js_function(diagnostics_callback.obj, "diagnostics_callback"); - let diagnostics_callback = diagnostics_callback .dyn_ref::() .expect("expected a valid JS function") @@ -58,7 +57,45 @@ impl LanguageService { ) .expect("callback should succeed"); }; - let mut worker = self.0.create_update_worker(diagnostics_callback, host); + + let test_callables_callback = test_callables_callback + .dyn_ref::() + .expect("expected a valid JS function") + .clone(); + + let test_callables_callback = move |update: TestCallables| { + let callables = update + .callables + .iter() + .map( + |TestCallable { + compilation_uri, + callable_name, + location, + friendly_name, + }| + -> TestDescriptor { + TestDescriptor { + compilation_uri: compilation_uri.to_string(), + callable_name: callable_name.to_string(), + location: location.clone().into(), + friendly_name: friendly_name.to_string(), + } + }, + ) + .collect::>(); + + let _ = test_callables_callback + .call1( + &JsValue::NULL, + &serde_wasm_bindgen::to_value(&callables) + .expect("conversion to TestCallables should succeed"), + ) + .expect("callback should succeed"); + }; + let mut worker = + self.0 + .create_update_worker(diagnostics_callback, test_callables_callback, host); future_to_promise(async move { worker.run().await; @@ -587,3 +624,9 @@ extern "C" { )] pub type DiagnosticsCallback; } + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "(callables: ITestDescriptor[]) => void")] + pub type TestCallableCallback; +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index edc8fb0a5d..a38a08ca47 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -36,6 +36,7 @@ mod line_column; mod logging; mod project_system; mod serializable_type; +mod test_discovery; #[cfg(test)] mod tests; diff --git a/wasm/src/project_system.rs b/wasm/src/project_system.rs index f1b7b62613..a15699b1cc 100644 --- a/wasm/src/project_system.rs +++ b/wasm/src/project_system.rs @@ -73,15 +73,6 @@ extern "C" { fn profile(this: &ProgramConfig) -> String; } -pub(crate) fn to_js_function(val: JsValue, help_text_panic: &'static str) -> js_sys::Function { - let js_ty = val.js_typeof(); - assert!( - val.is_function(), - "expected a valid JS function ({help_text_panic}), received {js_ty:?}" - ); - Into::::into(val) -} - thread_local! { static PACKAGE_CACHE: Rc> = Rc::default(); } /// a minimal implementation for interacting with async JS filesystem callbacks to diff --git a/wasm/src/test_discovery.rs b/wasm/src/test_discovery.rs new file mode 100644 index 0000000000..6314960515 --- /dev/null +++ b/wasm/src/test_discovery.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::serializable_type; + +serializable_type! { + TestDescriptor, + { + pub callable_name: String, + pub location: crate::line_column::Location, + pub compilation_uri: String, + pub friendly_name: String, + }, + r#"export interface ITestDescriptor { + callableName: string; + location: ILocation; + compilationUri: string; + friendlyName: string; + }"#, + ITestDescriptor +}