diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs index 5847ab4449..4d0e97dd85 100644 --- a/compiler/qsc/src/lib.rs +++ b/compiler/qsc/src/lib.rs @@ -9,6 +9,37 @@ pub mod interpret; pub mod location; pub mod packages; pub mod target; +pub mod test_callables { + use qsc_data_structures::line_column::{Encoding, Range}; + use qsc_frontend::compile::CompileUnit; + + use crate::location::Location; + + pub struct TestDescriptor { + pub callable_name: String, + pub location: Location, + } + + pub fn get_test_callables(unit: &CompileUnit) -> impl Iterator + '_ { + let test_callables = unit.package.get_test_callables(); + + test_callables.into_iter().map(|(name, span)| { + let source = unit + .sources + .find_by_offset(span.lo) + .expect("source should exist for offset"); + + let location = Location { + source: source.name.clone(), + range: Range::from_span(Encoding::Utf8, &source.contents, &(span - source.offset)), + }; + TestDescriptor { + callable_name: name, + location, + } + }) + } +} pub use qsc_formatter::formatter; diff --git a/compiler/qsc_frontend/src/lower.rs b/compiler/qsc_frontend/src/lower.rs index c349b203bb..67c3cb6309 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 5c22bdd0fe..bcefb8ed9c 100644 --- a/compiler/qsc_hir/src/hir.rs +++ b/compiler/qsc_hir/src/hir.rs @@ -279,6 +279,57 @@ 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 = 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"); + if let ItemKind::Namespace(ns, _) = &parent_item.kind { + format!("{}.{}", ns.name(), callable.name.name) + } else { + callable.name.name.to_string() + } + } + }; + + let span = item.span; + + Some((name, span)) + } else { + None + } + }) + .collect::>(); + + callable_names + } +} + /// An item. #[derive(Clone, Debug, PartialEq)] pub struct Item { @@ -1359,6 +1410,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 { @@ -1376,6 +1429,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.", } } } @@ -1391,6 +1445,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..1c865ac0b5 --- /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.name.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..85cb292346 --- /dev/null +++ b/compiler/qsc_passes/src/test_attribute/tests.rs @@ -0,0 +1,79 @@ +// 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: 43, + hi: 44, + }, + ), + ] + "#]], + ); +} + +#[test] +fn callable_cant_have_type_params() { + check( + indoc! {" + namespace test { + @Test() + operation A<'T>() : Unit { + + } + } + "}, + &expect![[r#" + [ + CallableHasTypeParameters( + Span { + lo: 43, + hi: 44, + }, + ), + ] + "#]], + ); +} diff --git a/language_service/src/completion.rs b/language_service/src/completion.rs index 74ad3500e2..45f92b07d2 100644 --- a/language_service/src/completion.rs +++ b/language_service/src/completion.rs @@ -165,6 +165,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/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 229caa5126..ff9b931893 100644 --- a/library/qtest/src/Tests.qs +++ b/library/qtest/src/Tests.qs @@ -3,24 +3,34 @@ import Std.Diagnostics.Fact; -function Main() : Unit { - let sample_tests = [ + +function SampleTestData() : (String, () -> Int, Int)[] { + [ ("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..617b371b01 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), 11), ("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/compiler/compiler.ts b/npm/qsharp/src/compiler/compiler.ts index 67345326c3..c3d9f93c22 100644 --- a/npm/qsharp/src/compiler/compiler.ts +++ b/npm/qsharp/src/compiler/compiler.ts @@ -9,6 +9,8 @@ import { IProgramConfig as wasmIProgramConfig, TargetProfile, type VSDiagnostic, + IProgramConfig, + ITestDescriptor, } from "../../lib/web/qsc_wasm.js"; import { log } from "../log.js"; import { @@ -77,6 +79,8 @@ export interface ICompiler { exerciseSources: string[], eventHandler: IQscEventTarget, ): Promise; + + getTestCallables(program: ProgramConfig): Promise; } /** @@ -243,6 +247,10 @@ export class Compiler implements ICompiler { return success; } + + async getTestCallables(program: IProgramConfig): Promise { + return this.wasm.get_test_callables(program); + } } /** @@ -326,6 +334,7 @@ export const compilerProtocol: ServiceProtocol = { run: "requestWithProgress", runWithPauliNoise: "requestWithProgress", checkExerciseSolution: "requestWithProgress", + getTestCallables: "request", }, eventNames: ["DumpMachine", "Matrix", "Message", "Result"], }; diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 71de7e08b6..5dd6b3392c 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 { + ILocation, + IRange, + IWorkspaceEdit, + VSDiagnostic, + getCompilerWorker, + ICompilerWorker, +} 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,37 @@ 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; } + +// the below worker is common to multiple consumers in the language extension. +let worker: ICompilerWorker | null = null; +/** + * Returns a singleton instance of the compiler worker. + * @param context The extension context. + * @returns The compiler worker. + * + * This function is used to get a *common* compiler worker. It should only be used for performance-light + * and safe (infallible) operations. For performance-intensive, blocking operations, or for fallible operations, + * use `getCompilerWorker` instead. + **/ +export function getCommonCompilerWorker( + context: vscode.ExtensionContext, +): ICompilerWorker { + if (worker !== null) { + return worker; + } + + const compilerWorkerScriptPath = vscode.Uri.joinPath( + context.extensionUri, + "./out/compilerWorker.js", + ).toString(); + worker = getCompilerWorker(compilerWorkerScriptPath); + + return worker; +} diff --git a/vscode/src/debugger/output.ts b/vscode/src/debugger/output.ts index 00dbb0da74..de888fce1e 100644 --- a/vscode/src/debugger/output.ts +++ b/vscode/src/debugger/output.ts @@ -72,7 +72,11 @@ export function createDebugConsoleEventTarget(out: (message: string) => void) { }); eventTarget.addEventListener("Result", (evt) => { - out(`${evt.detail.value}`); + if (evt.detail.success) { + out(`${evt.detail.value}`); + } else { + out(`${evt.detail.value.message}`); + } }); 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..a22ffe892a 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -28,6 +28,7 @@ import { initCodegen } from "./qirGeneration.js"; import { activateTargetProfileStatusBarItem } from "./statusbar.js"; import { initTelemetry } from "./telemetry.js"; import { registerWebViewCommands } from "./webviewPanel.js"; +import { initTestExplorer } from "./testExplorer.js"; export async function activate( context: vscode.ExtensionContext, @@ -67,14 +68,17 @@ export async function activate( context.subscriptions.push(...activateTargetProfileStatusBarItem()); + const eventEmitter = new vscode.EventEmitter(); + context.subscriptions.push( - ...(await activateLanguageService(context.extensionUri)), + ...(await activateLanguageService(context.extensionUri, eventEmitter)), ); context.subscriptions.push(...startOtherQSharpDiagnostics()); context.subscriptions.push(...registerQSharpNotebookHandlers()); + initTestExplorer(context, eventEmitter.event); initAzureWorkspaces(context); initCodegen(context); activateDebugger(context); diff --git a/vscode/src/language-service/activate.ts b/vscode/src/language-service/activate.ts index d4815d8713..823a55ed6b 100644 --- a/vscode/src/language-service/activate.ts +++ b/vscode/src/language-service/activate.ts @@ -38,7 +38,14 @@ import { createReferenceProvider } from "./references.js"; import { createRenameProvider } from "./rename.js"; import { createSignatureHelpProvider } from "./signature.js"; -export async function activateLanguageService(extensionUri: vscode.Uri) { +/** + * 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. + */ +export async function activateLanguageService( + extensionUri: vscode.Uri, + eventEmitter?: vscode.EventEmitter, +): Promise { const subscriptions: vscode.Disposable[] = []; const languageService = await loadLanguageService(extensionUri); @@ -47,7 +54,9 @@ export async function activateLanguageService(extensionUri: vscode.Uri) { subscriptions.push(...startLanguageServiceDiagnostics(languageService)); // synchronize document contents - subscriptions.push(...registerDocumentUpdateHandlers(languageService)); + subscriptions.push( + ...registerDocumentUpdateHandlers(languageService, eventEmitter), + ); // synchronize notebook cell contents subscriptions.push( @@ -147,7 +156,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,9 +179,17 @@ 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, + eventEmitter?: vscode.EventEmitter, +): vscode.Disposable[] { vscode.workspace.textDocuments.forEach((document) => { - updateIfQsharpDocument(document); + updateIfQsharpDocument(document, eventEmitter); }); // we manually send an OpenDocument telemetry event if this is a Q# document, because the @@ -203,13 +222,13 @@ function registerDocumentUpdateHandlers(languageService: ILanguageService) { { linesOfCode: document.lineCount }, ); } - updateIfQsharpDocument(document); + updateIfQsharpDocument(document, eventEmitter); }), ); subscriptions.push( vscode.workspace.onDidChangeTextDocument((evt) => { - updateIfQsharpDocument(evt.document); + updateIfQsharpDocument(evt.document, eventEmitter); }), ); @@ -252,13 +271,16 @@ function registerDocumentUpdateHandlers(languageService: ILanguageService) { // Check that the document is on the same project as the manifest. document.fileName.startsWith(project_folder) ) { - updateIfQsharpDocument(document); + updateIfQsharpDocument(document, eventEmitter); } }); } } - function updateIfQsharpDocument(document: vscode.TextDocument) { + function updateIfQsharpDocument( + document: vscode.TextDocument, + emitter?: vscode.EventEmitter, + ) { if (isQsharpDocument(document) && !isQsharpNotebookCell(document)) { // Regular (not notebook) Q# document. languageService.updateDocument( @@ -266,6 +288,14 @@ function registerDocumentUpdateHandlers(languageService: ILanguageService) { document.version, document.getText(), ); + + if (emitter) { + // this is used to trigger functionality outside of the language service. + // by firing an event here, we unify the points at which the language service + // recognizes an "update document" and when subscribers to the event react, avoiding + // multiple implementations of the same logic. + emitter.fire(document.uri); + } } } 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/testExplorer.ts b/vscode/src/testExplorer.ts new file mode 100644 index 0000000000..b4b6887950 --- /dev/null +++ b/vscode/src/testExplorer.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file uses the VS Code Test Explorer API (https://code.visualstudio.com/docs/editor/testing) + +import * as vscode from "vscode"; +import { ICompilerWorker, log, ProgramConfig } from "qsharp-lang"; +import { getActiveProgram } from "./programConfig"; +import { + getCommonCompilerWorker, + isQsharpDocument, + toVsCodeLocation, + toVsCodeRange, +} from "./common"; +import { createDebugConsoleEventTarget } from "./debugger/output"; + +/** + * Constructs the handler to pass to the `TestController` that refreshes the discovered tests. + */ +function mkRefreshHandler( + ctrl: vscode.TestController, + context: vscode.ExtensionContext, +) { + /// if `uri` is null, then we are performing a full refresh and scanning the entire program + return async (uri: vscode.Uri | null = null) => { + log.trace("Refreshing tests for uri", uri?.toString()); + // clear out old tests + for (const [id, testItem] of ctrl.items) { + // if the uri is null, delete all test items, as we are going to repopulate + // all tests. + // if the uri is some value, and the test item is from this same URI, + // delete it because we are about to repopulate tests from that document. + if (uri === null || testItem.uri?.toString() == uri.toString()) { + ctrl.items.delete(id); + } + } + + const program = await getActiveProgram(); + if (!program.success) { + throw new Error(program.errorMsg); + } + + const programConfig = program.programConfig; + const worker = getCommonCompilerWorker(context); + const allTestCallables = await worker.getTestCallables(programConfig); + + // only update test callables from this Uri + const scopedTestCallables = + uri === null + ? allTestCallables + : allTestCallables.filter(({ location }) => { + const vscLocation = toVsCodeLocation(location); + return vscLocation.uri.toString() === uri.toString(); + }); + + // break down the test callable into its parts, so we can construct + // the namespace hierarchy in the test explorer + for (const { callableName, location } of scopedTestCallables) { + const vscLocation = toVsCodeLocation(location); + const parts = callableName.split("."); + + // for an individual test case, e.g. foo.bar.baz, create a hierarchy of items + let rover = ctrl.items; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const id = i === parts.length - 1 ? callableName : part; + if (!rover.get(part)) { + const testItem = ctrl.createTestItem(id, part, vscLocation.uri); + testItem.range = vscLocation.range; + rover.add(testItem); + } + rover = rover.get(id)!.children; + } + } + }; +} + +/** + * Initializes the test explorer with the Q# tests in the active document. + **/ +export async function initTestExplorer( + context: vscode.ExtensionContext, + updateDocumentEvent: vscode.Event, +) { + const ctrl: vscode.TestController = vscode.tests.createTestController( + "qsharpTestController", + "Q# Tests", + ); + context.subscriptions.push(ctrl); + + const refreshHandler = mkRefreshHandler(ctrl, context); + // initially populate tests + await refreshHandler(null); + + // when the refresh button is pressed, refresh all tests by passing in a null uri + ctrl.refreshHandler = () => refreshHandler(null); + + // when the language service detects an updateDocument, this event fires. + // we call the test refresher when that happens + updateDocumentEvent(refreshHandler); + + 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 = getCommonCompilerWorker(context); + + const programResult = await getActiveProgram(); + if (!programResult.success) { + throw new Error(programResult.errorMsg); + } + + const program = programResult.programConfig; + + for (const testCase of request.include || []) { + await runTestCase(ctrl, testCase, request, worker, program); + } + }; + + ctrl.createRunProfile( + "Interpreter", + vscode.TestRunProfileKind.Run, + runHandler, + true, + undefined, + false, + ); + + function updateNodeForDocument(e: vscode.TextDocument) { + if (!isQsharpDocument(e)) { + return; + } + } + + for (const document of vscode.workspace.textDocuments) { + updateNodeForDocument(document); + } + + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(updateNodeForDocument), + vscode.workspace.onDidChangeTextDocument((e) => + updateNodeForDocument(e.document), + ), + ); +} + +/** + * 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, + program: ProgramConfig, +): 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, program); + } + 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 message: vscode.TestMessage = { + message: msg.detail.value.message, + location: { + range: toVsCodeRange(msg.detail.value.range), + uri: vscode.Uri.parse(msg.detail.value.uri || ""), + }, + }; + run.failed(testCase, message); + } + run.end(); + }); + + const callableExpr = `${testCase.id}()`; + + 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); +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index ad09a451e2..c6b94c680f 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -36,6 +36,9 @@ mod line_column; mod logging; mod project_system; mod serializable_type; +mod test_discovery; + +pub use test_discovery::get_test_callables; #[cfg(test)] mod tests; diff --git a/wasm/src/test_discovery.rs b/wasm/src/test_discovery.rs new file mode 100644 index 0000000000..1600e672f8 --- /dev/null +++ b/wasm/src/test_discovery.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use qsc::{compile, PackageType}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::{ + project_system::{into_qsc_args, ProgramConfig}, + serializable_type, STORE_CORE_STD, +}; + +serializable_type! { + TestDescriptor, + { + #[serde(rename = "callableName")] + pub callable_name: String, + pub location: crate::line_column::Location, + }, + r#"export interface ITestDescriptor { + callableName: string; + location: ILocation; + }"#, + ITestDescriptor +} + +#[wasm_bindgen] +pub fn get_test_callables(config: ProgramConfig) -> Result, String> { + let (source_map, capabilities, language_features, _store, _deps) = + into_qsc_args(config, None).map_err(super::compile_errors_into_qsharp_errors_json)?; + + let compile_unit = STORE_CORE_STD.with(|(store, std)| { + let (unit, _errs) = compile::compile( + store, + &[(*std, None)], + source_map, + PackageType::Lib, + capabilities, + language_features, + ); + unit + }); + + let test_descriptors = qsc::test_callables::get_test_callables(&compile_unit); + + Ok(test_descriptors + .map( + |qsc::test_callables::TestDescriptor { + callable_name, + location, + }| { + TestDescriptor { + callable_name, + location: location.into(), + } + .into() + }, + ) + .collect()) +}