From 776691589a0e9ac119760d8c9ad3eba5653e6caf Mon Sep 17 00:00:00 2001 From: Mine Starks <16928427+minestarks@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:10:36 -0800 Subject: [PATCH] Show code lens on entrypoints to invoke commands (#1142) This adds "Run | Histogram | Estimate | Debug" commands above the entrypoint in source files. ![image](https://github.com/microsoft/qsharp/assets/16928427/6c07576f-07ce-4f3a-9134-3212caf06327) --- language_service/src/code_lens.rs | 69 +++++++++ language_service/src/code_lens/tests.rs | 132 ++++++++++++++++++ language_service/src/compilation.rs | 19 ++- language_service/src/lib.rs | 31 ++-- language_service/src/protocol.rs | 14 ++ language_service/src/test_utils.rs | 65 +++++++-- npm/src/language-service/language-service.ts | 12 +- npm/src/language-service/worker-proxy.ts | 1 + vscode/src/codeLens.ts | 64 +++++++++ vscode/src/extension.ts | 37 +++-- .../language-service/language-service.test.ts | 15 ++ wasm/src/language_service.rs | 38 +++++ 12 files changed, 459 insertions(+), 38 deletions(-) create mode 100644 language_service/src/code_lens.rs create mode 100644 language_service/src/code_lens/tests.rs create mode 100644 vscode/src/codeLens.ts diff --git a/language_service/src/code_lens.rs b/language_service/src/code_lens.rs new file mode 100644 index 0000000000..a33d069a4a --- /dev/null +++ b/language_service/src/code_lens.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod tests; + +use crate::{ + compilation::{Compilation, CompilationKind}, + protocol::{CodeLens, CodeLensCommand}, + qsc_utils::{into_range, span_contains}, +}; +use qsc::{ + hir::{Attr, ItemKind}, + line_column::Encoding, +}; + +pub(crate) fn get_code_lenses( + compilation: &Compilation, + source_name: &str, + position_encoding: Encoding, +) -> Vec { + if matches!(compilation.kind, CompilationKind::Notebook) { + // entrypoint actions don't work in notebooks + return vec![]; + } + + let user_unit = compilation.user_unit(); + let source_span = compilation.package_span_of_source(source_name); + + // Get callables in the current source file with the @EntryPoint() attribute. + // If there is more than one entrypoint, not our problem, we'll go ahead + // and return code lenses for all. The duplicate entrypoint diagnostic + // will be reported from elsewhere. + let entry_point_decls = user_unit.package.items.values().filter_map(|item| { + if span_contains(source_span, item.span.lo) { + if let ItemKind::Callable(decl) = &item.kind { + if item.attrs.iter().any(|a| a == &Attr::EntryPoint) { + return Some(decl); + } + } + } + None + }); + + entry_point_decls + .flat_map(|decl| { + let range = into_range(position_encoding, decl.span, &user_unit.sources); + + [ + CodeLens { + range, + command: CodeLensCommand::Run, + }, + CodeLens { + range, + command: CodeLensCommand::Histogram, + }, + CodeLens { + range, + command: CodeLensCommand::Estimate, + }, + CodeLens { + range, + command: CodeLensCommand::Debug, + }, + ] + }) + .collect() +} diff --git a/language_service/src/code_lens/tests.rs b/language_service/src/code_lens/tests.rs new file mode 100644 index 0000000000..faccf19b22 --- /dev/null +++ b/language_service/src/code_lens/tests.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![allow(clippy::needless_raw_string_hashes)] + +use super::get_code_lenses; +use crate::{ + test_utils::{ + compile_notebook_with_fake_stdlib, compile_with_fake_stdlib_and_markers_no_cursor, + }, + Encoding, +}; +use expect_test::{expect, Expect}; + +fn check(source_with_markers: &str, expect: &Expect) { + let (compilation, expected_code_lens_ranges) = + compile_with_fake_stdlib_and_markers_no_cursor(source_with_markers); + let actual_code_lenses = get_code_lenses(&compilation, "", Encoding::Utf8); + + for expected_range in &expected_code_lens_ranges { + assert!( + actual_code_lenses + .iter() + .any(|cl| cl.range == *expected_range), + "expected range not found in actual code lenses: {expected_range:?}" + ); + } + + for actual_range in actual_code_lenses.iter().map(|cl| cl.range) { + assert!( + expected_code_lens_ranges.iter().any(|r| r == &actual_range), + "got code lens for unexpected range: {actual_range:?}" + ); + } + + let actual = expected_code_lens_ranges + .iter() + .enumerate() + .map(|(i, r)| { + ( + i, + actual_code_lenses + .iter() + .filter(|cl| cl.range == *r) + .map(|cl| cl.command) + .collect::>(), + ) + }) + .collect::>(); + expect.assert_debug_eq(&actual); +} + +#[test] +fn one_entrypoint() { + check( + r#" + namespace Test { + @EntryPoint() + ◉operation Main() : Unit{ + }◉ + }"#, + &expect![[r#" + [ + ( + 0, + [ + Run, + Histogram, + Estimate, + Debug, + ], + ), + ] + "#]], + ); +} + +#[test] +fn two_entrypoints() { + check( + r#" + namespace Test { + @EntryPoint() + ◉operation Main() : Unit{ + }◉ + + @EntryPoint() + ◉operation Foo() : Unit{ + }◉ + }"#, + &expect![[r#" + [ + ( + 0, + [ + Run, + Histogram, + Estimate, + Debug, + ], + ), + ( + 1, + [ + Run, + Histogram, + Estimate, + Debug, + ], + ), + ] + "#]], + ); +} + +#[test] +fn no_entrypoint_code_lens_in_notebook() { + let compilation = compile_notebook_with_fake_stdlib( + [( + "cell1", + "@EntryPoint() + operation Main() : Unit {}", + )] + .into_iter(), + ); + + let lenses = get_code_lenses(&compilation, "cell1", Encoding::Utf8); + assert!( + lenses.is_empty(), + "entrypoint code lenses should not be present in notebooks" + ); +} diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs index 60645180cb..7f09375922 100644 --- a/language_service/src/compilation.rs +++ b/language_service/src/compilation.rs @@ -11,7 +11,7 @@ use qsc::{ line_column::{Encoding, Position}, resolve, target::Profile, - CompileUnit, PackageStore, PackageType, SourceMap, + CompileUnit, PackageStore, PackageType, SourceMap, Span, }; use std::sync::Arc; @@ -154,6 +154,23 @@ impl Compilation { source.offset + offset } + /// Gets the span of the whole source file. + pub(crate) fn package_span_of_source(&self, source_name: &str) -> Span { + let unit = self.user_unit(); + + let source = unit + .sources + .find_by_name(source_name) + .expect("source should exist in the user source map"); + + let len = u32::try_from(source.contents.len()).expect("source length should fit into u32"); + + Span { + lo: source.offset, + hi: source.offset + len, + } + } + /// Regenerates the compilation with the same sources but the passed in workspace configuration options. pub fn recompile(&mut self, package_type: PackageType, target_profile: Profile) { let sources = self diff --git a/language_service/src/lib.rs b/language_service/src/lib.rs index 5107167767..201f6cc903 100644 --- a/language_service/src/lib.rs +++ b/language_service/src/lib.rs @@ -4,6 +4,7 @@ #![warn(clippy::mod_module_files, clippy::pedantic, clippy::unwrap_used)] #![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] +pub mod code_lens; mod compilation; pub mod completion; pub mod definition; @@ -26,7 +27,7 @@ use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; use futures_util::StreamExt; use log::{trace, warn}; use protocol::{ - CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, + CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, WorkspaceConfigurationUpdate, }; use qsc::{ @@ -205,12 +206,12 @@ impl LanguageService { include_declaration: bool, ) -> Vec { self.document_op( - |position_encoding, compilation, uri, position| { + |compilation, uri, position, position_encoding| { references::get_references( - position_encoding, compilation, uri, position, + position_encoding, include_declaration, ) }, @@ -249,25 +250,39 @@ impl LanguageService { self.document_op(rename::prepare_rename, "prepare_rename", uri, position) } - /// Executes an operation that takes a document uri and offset, using the current compilation for that document. + /// LSP: textDocument/codeLens + #[must_use] + pub fn get_code_lenses(&self, uri: &str) -> Vec { + self.document_op( + |compilation, uri, (), position_encoding| { + code_lens::get_code_lenses(compilation, uri, position_encoding) + }, + "get_code_lenses", + uri, + (), + ) + } + + /// Executes an operation that takes a document uri, using the current compilation for that document. /// All "read" operations should go through this method. This method will borrow the current /// compilation state to perform the request. /// /// If there are outstanding updates to the compilation in the update message queue, /// this method will still just return the current compilation state. - fn document_op(&self, op: F, op_name: &str, uri: &str, position: Position) -> T + fn document_op(&self, op: F, op_name: &str, uri: &str, arg: A) -> T where - F: Fn(&Compilation, &str, Position, Encoding) -> T, + F: Fn(&Compilation, &str, A, Encoding) -> T, T: Debug + Default, + A: Debug, { - trace!("{op_name}: uri: {uri}, position: {position:?}"); + trace!("{op_name}: uri: {uri}, arg: {arg:?}"); // Borrow must succeed here. If it doesn't succeed, a writer // (i.e. [`state::CompilationStateUpdater`]) must be holding a mutable reference across // an `await` point. Which it shouldn't be doing. let compilation_state = self.state.borrow(); if let Some(compilation) = compilation_state.get_compilation(uri) { - let res = op(compilation, uri, position, self.position_encoding); + let res = op(compilation, uri, arg, self.position_encoding); trace!("{op_name} result: {res:?}"); res } else { diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs index 781432675a..cda8efef22 100644 --- a/language_service/src/protocol.rs +++ b/language_service/src/protocol.rs @@ -119,3 +119,17 @@ pub struct ParameterInformation { pub struct NotebookMetadata { pub target_profile: Option, } + +#[derive(Debug)] +pub struct CodeLens { + pub range: Range, + pub command: CodeLensCommand, +} + +#[derive(Debug, Clone, Copy)] +pub enum CodeLensCommand { + Histogram, + Debug, + Run, + Estimate, +} diff --git a/language_service/src/test_utils.rs b/language_service/src/test_utils.rs index 611c255a62..7c5dcf875c 100644 --- a/language_service/src/test_utils.rs +++ b/language_service/src/test_utils.rs @@ -26,11 +26,46 @@ pub(crate) fn compile_with_fake_stdlib_and_markers( ) } +pub(crate) fn compile_with_fake_stdlib_and_markers_no_cursor( + source_with_markers: &str, +) -> (Compilation, Vec) { + let (compilation, target_spans) = compile_project_with_fake_stdlib_and_markers_no_cursor(&[( + "", + source_with_markers, + )]); + (compilation, target_spans.iter().map(|l| l.range).collect()) +} + pub(crate) fn compile_project_with_fake_stdlib_and_markers( sources_with_markers: &[(&str, &str)], ) -> (Compilation, String, Position, Vec) { - let (sources, cursor_uri, cursor_offset, target_spans) = - get_sources_and_markers(sources_with_markers); + let (compilation, cursor_location, target_spans) = + compile_project_with_fake_stdlib_and_markers_cursor_optional(sources_with_markers); + + let (cursor_uri, cursor_offset) = + cursor_location.expect("input string should have a cursor marker"); + + (compilation, cursor_uri, cursor_offset, target_spans) +} + +pub(crate) fn compile_project_with_fake_stdlib_and_markers_no_cursor( + sources_with_markers: &[(&str, &str)], +) -> (Compilation, Vec) { + let (compilation, cursor_location, target_spans) = + compile_project_with_fake_stdlib_and_markers_cursor_optional(sources_with_markers); + + assert!( + cursor_location.is_none(), + "did not expect cursor marker in input string" + ); + + (compilation, target_spans) +} + +fn compile_project_with_fake_stdlib_and_markers_cursor_optional( + sources_with_markers: &[(&str, &str)], +) -> (Compilation, Option<(String, Position)>, Vec) { + let (sources, cursor_location, target_spans) = get_sources_and_markers(sources_with_markers); let source_map = SourceMap::new(sources, None); let (mut package_store, std_package_id) = compile_fake_stdlib(); @@ -51,8 +86,7 @@ pub(crate) fn compile_project_with_fake_stdlib_and_markers( kind: CompilationKind::OpenProject, errors, }, - cursor_uri, - cursor_offset, + cursor_location, target_spans, ) } @@ -60,14 +94,15 @@ pub(crate) fn compile_project_with_fake_stdlib_and_markers( pub(crate) fn compile_notebook_with_fake_stdlib_and_markers( cells_with_markers: &[(&str, &str)], ) -> (Compilation, String, Position, Vec) { - let (cells, cell_uri, offset, target_spans) = get_sources_and_markers(cells_with_markers); + let (cells, cursor_location, target_spans) = get_sources_and_markers(cells_with_markers); + let (cell_uri, offset) = cursor_location.expect("input string should have a cursor marker"); let compilation = compile_notebook_with_fake_stdlib(cells.iter().map(|c| (c.0.as_ref(), c.1.as_ref()))); (compilation, cell_uri, offset, target_spans) } -fn compile_notebook_with_fake_stdlib<'a, I>(cells: I) -> Compilation +pub(crate) fn compile_notebook_with_fake_stdlib<'a, I>(cells: I) -> Compilation where I: Iterator, { @@ -161,7 +196,11 @@ fn compile_fake_stdlib() -> (PackageStore, PackageId) { #[allow(clippy::type_complexity)] fn get_sources_and_markers( sources: &[(&str, &str)], -) -> (Vec<(Arc, Arc)>, String, Position, Vec) { +) -> ( + Vec<(Arc, Arc)>, + Option<(String, Position)>, + Vec, +) { let (mut cursor_uri, mut cursor_offset, mut target_spans) = (None, None, Vec::new()); let sources = sources .iter() @@ -194,11 +233,13 @@ fn get_sources_and_markers( (Arc::from(s.0), Arc::from(source.as_ref())) }) .collect(); - let cursor_uri = cursor_uri - .expect("input should have a cursor marker") - .to_string(); - let cursor_offset = cursor_offset.expect("input string should have a cursor marker"); - (sources, cursor_uri, cursor_offset, target_spans) + let cursor_location = cursor_uri.map(|cursor_uri| { + ( + cursor_uri.into(), + cursor_offset.expect("cursor offset should be set"), + ) + }); + (sources, cursor_location, target_spans) } fn get_source_and_marker_offsets(source_with_markers: &str) -> (String, Vec, Vec) { diff --git a/npm/src/language-service/language-service.ts b/npm/src/language-service/language-service.ts index 72fe304500..d729eea202 100644 --- a/npm/src/language-service/language-service.ts +++ b/npm/src/language-service/language-service.ts @@ -2,16 +2,17 @@ // Licensed under the MIT License. import type { + ICodeLens, ICompletionList, IHover, ILocation, - ISignatureHelp, INotebookMetadata, + IPosition, + ISignatureHelp, + ITextEdit, IWorkspaceConfiguration, IWorkspaceEdit, - ITextEdit, LanguageService, - IPosition, VSDiagnostic, } from "../../lib/node/qsc_wasm.cjs"; import { log } from "../log.js"; @@ -75,6 +76,7 @@ export interface ILanguageService { documentUri: string, position: IPosition, ): Promise; + getCodeLenses(documentUri: string): Promise; dispose(): Promise; @@ -209,6 +211,10 @@ export class QSharpLanguageService implements ILanguageService { return this.languageService.prepare_rename(documentUri, position); } + async getCodeLenses(documentUri: string): Promise { + return this.languageService.get_code_lenses(documentUri); + } + async dispose() { this.languageService.stop_background_work(); await this.backgroundWork; diff --git a/npm/src/language-service/worker-proxy.ts b/npm/src/language-service/worker-proxy.ts index 5f0b4e93ec..f0395d4207 100644 --- a/npm/src/language-service/worker-proxy.ts +++ b/npm/src/language-service/worker-proxy.ts @@ -24,6 +24,7 @@ const requests: MethodMap = { getSignatureHelp: "request", getRename: "request", prepareRename: "request", + getCodeLenses: "request", dispose: "request", addEventListener: "addEventListener", removeEventListener: "removeEventListener", diff --git a/vscode/src/codeLens.ts b/vscode/src/codeLens.ts new file mode 100644 index 0000000000..36eca59801 --- /dev/null +++ b/vscode/src/codeLens.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILanguageService } from "qsharp-lang"; +import * as vscode from "vscode"; +import { ICodeLens } from "../../npm/lib/web/qsc_wasm"; +import { toVscodeRange } from "./common"; + +export function createCodeLensProvider(languageService: ILanguageService) { + return new QSharpCodeLensProvider(languageService); +} + +class QSharpCodeLensProvider implements vscode.CodeLensProvider { + constructor(public languageService: ILanguageService) {} + // We could raise events when code lenses change, + // but there's no need as the editor seems to query often enough. + // onDidChangeCodeLenses?: vscode.Event | undefined; + async provideCodeLenses( + document: vscode.TextDocument, + ): Promise { + const codeLenses = await this.languageService.getCodeLenses( + document.uri.toString(), + ); + + return codeLenses.map((cl) => mapCodeLens(cl)); + } +} + +function mapCodeLens(cl: ICodeLens): vscode.CodeLens { + let command; + let title; + let tooltip; + switch (cl.command) { + case "histogram": + title = "Histogram"; + command = "qsharp-vscode.showHistogram"; + tooltip = "Run and show histogram"; + break; + case "estimate": + title = "Estimate"; + command = "qsharp-vscode.showRe"; + tooltip = "Calculate resource estimates"; + break; + case "debug": + title = "Debug"; + command = "qsharp-vscode.debugEditorContents"; + tooltip = "Debug program"; + break; + case "run": + title = "Run"; + command = "qsharp-vscode.runEditorContents"; + tooltip = "Run program"; + break; + default: + throw new Error(`Unknown code lens command: ${cl.command}`); + } + + return new vscode.CodeLens(toVscodeRange(cl.range), { + title, + command, + arguments: cl.args, + tooltip, + }); +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index d326f009d0..8c440dd5fb 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -10,42 +10,43 @@ import { qsharpLibraryUriScheme, } from "qsharp-lang"; import * as vscode from "vscode"; +import { initAzureWorkspaces } from "./azure/commands.js"; +import { createCodeLensProvider } from "./codeLens.js"; import { isQsharpDocument, isQsharpNotebookCell, qsharpLanguageId, } from "./common.js"; import { createCompletionItemProvider } from "./completion"; -import { activateDebugger } from "./debugger/activate"; import { getTarget } from "./config"; +import { activateDebugger } from "./debugger/activate"; import { createDefinitionProvider } from "./definition"; import { startCheckingQSharp } from "./diagnostics"; import { createHoverProvider } from "./hover"; +import { + Logging, + initLogForwarder, + initOutputWindowLogger, +} from "./logging.js"; +import { initFileSystem } from "./memfs.js"; import { registerCreateNotebookCommand, registerQSharpNotebookCellUpdateHandlers, registerQSharpNotebookHandlers, } from "./notebook.js"; +import { getManifest, listDir, readFile } from "./projectSystem.js"; +import { initCodegen } from "./qirGeneration.js"; +import { createReferenceProvider } from "./references.js"; +import { createRenameProvider } from "./rename.js"; +import { createSignatureHelpProvider } from "./signature.js"; +import { activateTargetProfileStatusBarItem } from "./statusbar.js"; import { EventType, QsharpDocumentType, initTelemetry, sendTelemetryEvent, } from "./telemetry.js"; -import { initAzureWorkspaces } from "./azure/commands.js"; -import { initCodegen } from "./qirGeneration.js"; -import { createSignatureHelpProvider } from "./signature.js"; -import { createRenameProvider } from "./rename.js"; import { registerWebViewCommands } from "./webviewPanel.js"; -import { createReferenceProvider } from "./references.js"; -import { activateTargetProfileStatusBarItem } from "./statusbar.js"; -import { initFileSystem } from "./memfs.js"; -import { getManifest, readFile, listDir } from "./projectSystem.js"; -import { - Logging, - initLogForwarder, - initOutputWindowLogger, -} from "./logging.js"; export async function activate( context: vscode.ExtensionContext, @@ -219,6 +220,14 @@ async function activateLanguageService(extensionUri: vscode.Uri) { ), ); + // code lens + subscriptions.push( + vscode.languages.registerCodeLensProvider( + qsharpLanguageId, + createCodeLensProvider(languageService), + ), + ); + // add the language service dispose handler as well subscriptions.push(languageService); diff --git a/vscode/test/suites/language-service/language-service.test.ts b/vscode/test/suites/language-service/language-service.test.ts index 38f6252d02..c3d4384e28 100644 --- a/vscode/test/suites/language-service/language-service.test.ts +++ b/vscode/test/suites/language-service/language-service.test.ts @@ -107,4 +107,19 @@ suite("Q# Language Service Tests", function suite() { "function Message(msg : String)", ); }); + + test("Code Lens", async () => { + const doc = await vscode.workspace.openTextDocument(docUri); + + const actualCodeLenses = (await vscode.commands.executeCommand( + "vscode.executeCodeLensProvider", + docUri, + )) as vscode.CodeLens[]; + + assert.lengthOf(actualCodeLenses, 4); + + for (const lens of actualCodeLenses) { + assert.include(doc.getText(lens.range), "operation Test()"); + } + }); }); diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs index 2e4b8e8190..e83f7b8a28 100644 --- a/wasm/src/language_service.rs +++ b/wasm/src/language_service.rs @@ -266,6 +266,28 @@ impl LanguageService { .into() }) } + + pub fn get_code_lenses(&self, uri: &str) -> Vec { + let code_lenses = self.0.get_code_lenses(uri); + code_lenses + .into_iter() + .map(|lens| { + let range = lens.range.into(); + let (command, args) = match lens.command { + qsls::protocol::CodeLensCommand::Histogram => ("histogram", None), + qsls::protocol::CodeLensCommand::Debug => ("debug", None), + qsls::protocol::CodeLensCommand::Run => ("run", None), + qsls::protocol::CodeLensCommand::Estimate => ("estimate", None), + }; + CodeLens { + range, + command: command.to_string(), + args, + } + .into() + }) + .collect() + } } serializable_type! { @@ -377,6 +399,22 @@ serializable_type! { }"# } +serializable_type! { + CodeLens, + { + range: Range, + command: String, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option<(String, String, String)>, + }, + r#"export interface ICodeLens { + range: IRange; + command: "histogram" | "estimate" | "debug" | "run"; + args?: [string, string, string]; + }"#, + ICodeLens +} + serializable_type! { WorkspaceEdit, {