From f20c06a336b2b4bf3f3a1a76a056887bff42afd1 Mon Sep 17 00:00:00 2001 From: orpuente-MS <156957451+orpuente-MS@users.noreply.github.com> Date: Wed, 29 May 2024 13:38:40 -0700 Subject: [PATCH] Add support for CodeActions in the Language Service (#1495) This PR adds support for CodeActions in the Language Service. QuickFixes are one kind of CodeActions available in VS Code. Below is a demo of QuickFixes for lints: https://github.com/microsoft/qsharp/assets/156957451/e0c6ba8b-3a67-42af-b9f5-87a755efa2a0 --- compiler/qsc_data_structures/src/span.rs | 22 +++ compiler/qsc_linter/src/lib.rs | 1 + compiler/qsc_linter/src/linter.rs | 2 + compiler/qsc_linter/src/linter/ast.rs | 13 +- compiler/qsc_linter/src/linter/hir.rs | 13 +- compiler/qsc_linter/src/lints.rs | 1 + language_service/src/code_action.rs | 163 ++++++++++++++++++ language_service/src/compilation.rs | 21 ++- language_service/src/lib.rs | 15 +- language_service/src/protocol.rs | 28 +++ language_service/src/state/tests.rs | 107 +++++++----- npm/qsharp/src/browser.ts | 2 + .../src/language-service/language-service.ts | 11 ++ playground/src/main.tsx | 48 ++++-- playground/src/utils.ts | 30 +++- vscode/src/codeActions.ts | 67 +++++++ vscode/src/common.ts | 17 +- vscode/src/extension.ts | 8 + vscode/src/rename.ts | 16 +- wasm/src/language_service.rs | 78 ++++++++- wasm/src/line_column.rs | 12 +- 21 files changed, 588 insertions(+), 87 deletions(-) create mode 100644 language_service/src/code_action.rs create mode 100644 vscode/src/codeActions.ts diff --git a/compiler/qsc_data_structures/src/span.rs b/compiler/qsc_data_structures/src/span.rs index ed0ea85683..2b028eda4d 100644 --- a/compiler/qsc_data_structures/src/span.rs +++ b/compiler/qsc_data_structures/src/span.rs @@ -16,6 +16,28 @@ pub struct Span { pub hi: u32, } +impl Span { + /// Returns true if the position is within the range. + #[must_use] + pub fn contains(&self, offset: u32) -> bool { + (self.lo..self.hi).contains(&offset) + } + + /// Intersect `range` with this range and returns a new range or `None` + /// if the ranges have no overlap. + #[must_use] + pub fn intersection(&self, other: &Self) -> Option { + let lo = self.lo.max(other.lo); + let hi = self.hi.min(other.hi); + + if lo <= hi { + Some(Self { lo, hi }) + } else { + None + } + } +} + impl Add for Span { type Output = Self; diff --git a/compiler/qsc_linter/src/lib.rs b/compiler/qsc_linter/src/lib.rs index 06904ca278..8538209d87 100644 --- a/compiler/qsc_linter/src/lib.rs +++ b/compiler/qsc_linter/src/lib.rs @@ -67,3 +67,4 @@ mod lints; mod tests; pub use linter::{run_lints, Lint, LintConfig, LintKind, LintLevel}; +pub use lints::{ast::AstLint, hir::HirLint}; diff --git a/compiler/qsc_linter/src/linter.rs b/compiler/qsc_linter/src/linter.rs index a1026e8215..55821144b1 100644 --- a/compiler/qsc_linter/src/linter.rs +++ b/compiler/qsc_linter/src/linter.rs @@ -39,6 +39,8 @@ pub struct Lint { pub message: &'static str, /// The help text the user will see in the code editor. pub help: &'static str, + /// An enum identifying this lint. + pub kind: LintKind, } impl std::fmt::Display for Lint { diff --git a/compiler/qsc_linter/src/linter/ast.rs b/compiler/qsc_linter/src/linter/ast.rs index d62cd00868..843e80bf7a 100644 --- a/compiler/qsc_linter/src/linter/ast.rs +++ b/compiler/qsc_linter/src/linter/ast.rs @@ -76,7 +76,7 @@ pub(crate) trait AstLintPass { macro_rules! declare_ast_lints { ($( ($lint_name:ident, $default_level:expr, $msg:expr, $help:expr) ),* $(,)?) => { // Declare the structs representing each lint. - use crate::{Lint, LintLevel, linter::ast::AstLintPass}; + use crate::{Lint, LintKind, LintLevel, linter::ast::AstLintPass}; $(declare_ast_lints!{ @LINT_STRUCT $lint_name, $default_level, $msg, $help})* // This is a silly wrapper module to avoid contaminating the environment @@ -110,17 +110,18 @@ macro_rules! declare_ast_lints { level: LintLevel, message: &'static str, help: &'static str, + kind: LintKind, } impl Default for $lint_name { fn default() -> Self { - Self { level: Self::DEFAULT_LEVEL, message: $msg, help: $help } + Self { level: Self::DEFAULT_LEVEL, message: $msg, help: $help, kind: LintKind::Ast(AstLint::$lint_name) } } } impl From for $lint_name { fn from(value: LintLevel) -> Self { - Self { level: value, message: $msg, help: $help } + Self { level: value, message: $msg, help: $help, kind: LintKind::Ast(AstLint::$lint_name) } } } @@ -133,10 +134,14 @@ macro_rules! declare_ast_lints { (@CONFIG_ENUM $($lint_name:ident),*) => { use serde::{Deserialize, Serialize}; + /// An enum listing all existing AST lints. #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum AstLint { - $($lint_name),* + $( + #[doc = stringify!($lint_name)] + $lint_name + ),* } }; diff --git a/compiler/qsc_linter/src/linter/hir.rs b/compiler/qsc_linter/src/linter/hir.rs index ee64c05b8b..9f9d252278 100644 --- a/compiler/qsc_linter/src/linter/hir.rs +++ b/compiler/qsc_linter/src/linter/hir.rs @@ -67,7 +67,7 @@ pub(crate) trait HirLintPass { macro_rules! declare_hir_lints { ($( ($lint_name:ident, $default_level:expr, $msg:expr, $help:expr) ),* $(,)?) => { // Declare the structs representing each lint. - use crate::{Lint, LintLevel, linter::hir::HirLintPass}; + use crate::{Lint, LintKind, LintLevel, linter::hir::HirLintPass}; $(declare_hir_lints!{ @LINT_STRUCT $lint_name, $default_level, $msg, $help })* // This is a silly wrapper module to avoid contaminating the environment @@ -98,17 +98,18 @@ macro_rules! declare_hir_lints { level: LintLevel, message: &'static str, help: &'static str, + kind: LintKind, } impl Default for $lint_name { fn default() -> Self { - Self { level: Self::DEFAULT_LEVEL, message: $msg, help: $help } + Self { level: Self::DEFAULT_LEVEL, message: $msg, help: $help, kind: LintKind::Hir(HirLint::$lint_name) } } } impl From for $lint_name { fn from(value: LintLevel) -> Self { - Self { level: value, message: $msg, help: $help } + Self { level: value, message: $msg, help: $help, kind: LintKind::Hir(HirLint::$lint_name) } } } @@ -121,10 +122,14 @@ macro_rules! declare_hir_lints { (@CONFIG_ENUM $($lint_name:ident),*) => { use serde::{Deserialize, Serialize}; + /// An enum listing all existing HIR lints. #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum HirLint { - $($lint_name),* + $( + #[doc = stringify!($lint_name)] + $lint_name + ),* } }; diff --git a/compiler/qsc_linter/src/lints.rs b/compiler/qsc_linter/src/lints.rs index 3b8b1c632f..ffa3997be9 100644 --- a/compiler/qsc_linter/src/lints.rs +++ b/compiler/qsc_linter/src/lints.rs @@ -11,6 +11,7 @@ macro_rules! lint { level: $lint.level, message: $lint.message, help: $lint.help, + kind: $lint.kind, } }; } diff --git a/language_service/src/code_action.rs b/language_service/src/code_action.rs new file mode 100644 index 0000000000..0bfd519e0d --- /dev/null +++ b/language_service/src/code_action.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use miette::Diagnostic; +use qsc::{ + compile::ErrorKind, + error::WithSource, + line_column::{Encoding, Range}, + Span, +}; +use qsc_linter::{AstLint, HirLint}; + +use crate::{ + compilation::Compilation, + protocol::{CodeAction, CodeActionKind, TextEdit, WorkspaceEdit}, +}; + +pub(crate) fn get_code_actions( + compilation: &Compilation, + source_name: &str, + range: Range, + position_encoding: Encoding, +) -> Vec { + // Compute quick_fixes and other code_actions, and then merge them together + let span = compilation.source_range_to_package_span(source_name, range, position_encoding); + quick_fixes(compilation, source_name, span, position_encoding) +} + +fn quick_fixes( + compilation: &Compilation, + source_name: &str, + span: Span, + encoding: Encoding, +) -> Vec { + let mut code_actions = Vec::new(); + + // get relevant diagnostics + let diagnostics = compilation + .errors + .iter() + .filter(|error| is_error_relevant(error, source_name, span)); + + // An example of what quickfixes could look like if they were generated here. + // The other option I considered was generating the quickfixes when the errors + // are initially issued. But that has two problems: + // 1. It does unnecesary computations at compile time, that would go to waste if using the CLI compiler. + // 2. The quickfix logic would be spread across many crates in the compiler. + for diagnostic in diagnostics { + if let ErrorKind::Lint(lint) = diagnostic.error() { + use qsc::linter::LintKind; + match lint.kind { + LintKind::Ast(AstLint::RedundantSemicolons) => code_actions.push(CodeAction { + title: diagnostic.to_string(), + edit: Some(WorkspaceEdit { + changes: vec![( + source_name.to_string(), + vec![TextEdit { + // We want to remove the redundant semicolons, so the + // replacement text is just an empty string. + new_text: String::new(), + range: resolve_range(diagnostic, encoding) + .expect("range should exist"), + }], + )], + }), + kind: Some(CodeActionKind::QuickFix), + is_preferred: None, + }), + LintKind::Ast(AstLint::NeedlessParens) => code_actions.push(CodeAction { + title: diagnostic.to_string(), + edit: Some(WorkspaceEdit { + changes: vec![( + source_name.to_string(), + vec![TextEdit { + // Same source code without the first and last characters + // which should correspond to the redundant parentheses. + new_text: get_source_code( + compilation, + lint.span.lo + 1, + lint.span.hi - 1, + ), + range: resolve_range(diagnostic, encoding) + .expect("range should exist"), + }], + )], + }), + kind: Some(CodeActionKind::QuickFix), + is_preferred: None, + }), + LintKind::Ast(AstLint::DivisionByZero) | LintKind::Hir(HirLint::Placeholder) => (), + } + } + } + + code_actions +} + +/// Returns true if the error: +/// - is in the file named `source_name` +/// - has a `Range` and it overlaps with the `code_action`'s range +fn is_error_relevant(error: &WithSource, source_name: &str, span: Span) -> bool { + let Some((uri, error_span)) = resolve_source_and_span(error) else { + return false; + }; + uri == source_name && span.intersection(&error_span).is_some() +} + +/// Extracts the uri and `Span` from an error. +fn resolve_source_and_span(e: &WithSource) -> Option<(String, Span)> { + e.labels() + .into_iter() + .flatten() + .map(|labeled_span| { + let (source, source_span) = e.resolve_span(labeled_span.inner()); + let start = u32::try_from(source_span.offset()).expect("offset should fit in u32"); + let len = u32::try_from(source_span.len()).expect("length should fit in u32"); + let span = qsc::Span { + lo: start, + hi: start + len, + }; + + (source.name.to_string(), span) + }) + .next() +} + +/// Extracts the `Range` from an error. +fn resolve_range(e: &WithSource, encoding: Encoding) -> Option { + e.labels() + .into_iter() + .flatten() + .map(|labeled_span| { + let (source, span) = e.resolve_span(labeled_span.inner()); + let start = u32::try_from(span.offset()).expect("offset should fit in u32"); + let len = u32::try_from(span.len()).expect("length should fit in u32"); + qsc::line_column::Range::from_span( + encoding, + &source.contents, + &qsc::Span { + lo: start, + hi: start + len, + }, + ) + }) + .next() +} + +/// Returns a substring of the user code's `SourceMap` in the range `lo..hi`. +fn get_source_code(compilation: &Compilation, lo: u32, hi: u32) -> String { + let unit = compilation + .package_store + .get(compilation.user_package_id) + .expect("user package should exist"); + + let source = unit + .sources + .find_by_offset(lo) + .expect("source should exist"); + + let lo = (lo - source.offset) as usize; + let hi = (hi - source.offset) as usize; + source.contents[lo..hi].to_string() +} diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs index a3a61602ca..4448411805 100644 --- a/language_service/src/compilation.rs +++ b/language_service/src/compilation.rs @@ -9,7 +9,7 @@ use qsc::{ error::WithSource, hir::{self, PackageId}, incremental::Compiler, - line_column::{Encoding, Position}, + line_column::{Encoding, Position, Range}, resolve, target::Profile, CompileUnit, LanguageFeatures, PackageStore, PackageType, PassContext, SourceMap, Span, @@ -191,6 +191,25 @@ impl Compilation { source.offset + offset } + pub(crate) fn source_range_to_package_span( + &self, + source_name: &str, + source_range: Range, + position_encoding: Encoding, + ) -> Span { + let lo = self.source_position_to_package_offset( + source_name, + source_range.start, + position_encoding, + ); + let hi = self.source_position_to_package_offset( + source_name, + source_range.end, + position_encoding, + ); + Span { lo, hi } + } + /// 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(); diff --git a/language_service/src/lib.rs b/language_service/src/lib.rs index c8ae936589..37a284e153 100644 --- a/language_service/src/lib.rs +++ b/language_service/src/lib.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod code_action; pub mod code_lens; mod compilation; pub mod completion; @@ -25,8 +26,8 @@ use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; use futures_util::StreamExt; use log::{trace, warn}; use protocol::{ - CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, TextEdit, - WorkspaceConfigurationUpdate, + CodeAction, CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp, + TextEdit, WorkspaceConfigurationUpdate, }; use qsc::{ line_column::{Encoding, Position, Range}, @@ -178,6 +179,16 @@ impl LanguageService { }); } + #[must_use] + pub fn get_code_actions(&self, uri: &str, range: Range) -> Vec { + self.document_op( + code_action::get_code_actions, + "get_code_actions", + uri, + range, + ) + } + /// LSP: textDocument/completion #[must_use] pub fn get_completions(&self, uri: &str, position: Position) -> CompletionList { diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs index de37d4e376..4d688c8786 100644 --- a/language_service/src/protocol.rs +++ b/language_service/src/protocol.rs @@ -20,6 +20,29 @@ pub struct DiagnosticUpdate { pub errors: Vec, } +#[derive(Debug)] +pub enum CodeActionKind { + Empty, + QuickFix, + Refactor, + RefactorExtract, + RefactorInline, + RefactorMove, + RefactorRewrite, + Source, + SourceOrganizeImports, + SourceFixAll, + Notebook, +} + +#[derive(Debug)] +pub struct CodeAction { + pub title: String, + pub edit: Option, + pub kind: Option, + pub is_preferred: Option, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum CompletionItemKind { // It would have been nice to match the numeric values to the ones used by @@ -91,6 +114,11 @@ pub struct Hover { pub span: Range, } +#[derive(Debug)] +pub struct WorkspaceEdit { + pub changes: Vec<(String, Vec)>, +} + #[derive(Debug, PartialEq)] pub struct TextEdit { pub new_text: String, diff --git a/language_service/src/state/tests.rs b/language_service/src/state/tests.rs index a215d8d5fa..f2de95e54b 100644 --- a/language_service/src/state/tests.rs +++ b/language_service/src/state/tests.rs @@ -728,6 +728,9 @@ fn notebook_document_lints() { level: Warn, message: "redundant semicolons", help: "remove the redundant semicolons", + kind: Ast( + RedundantSemicolons, + ), }, ), ], @@ -747,6 +750,9 @@ fn notebook_document_lints() { level: Error, message: "attempt to divide by zero", help: "division by zero will fail at runtime", + kind: Ast( + DivisionByZero, + ), }, ), ], @@ -1492,6 +1498,7 @@ async fn loading_lints_config_from_manifest() { .await; } +#[allow(clippy::too_many_lines)] #[tokio::test] async fn lints_update_after_manifest_change() { let this_file_qs = @@ -1531,30 +1538,36 @@ async fn lints_update_after_manifest_change() { check_lints( lints, &expect![[r#" - [ - Lint( - Lint { - span: Span { - lo: 72, - hi: 79, + [ + Lint( + Lint { + span: Span { + lo: 72, + hi: 79, + }, + level: Error, + message: "unnecessary parentheses", + help: "remove the extra parentheses for clarity", + kind: Ast( + NeedlessParens, + ), }, - level: Error, - message: "unnecessary parentheses", - help: "remove the extra parentheses for clarity", - }, - ), - Lint( - Lint { - span: Span { - lo: 64, - hi: 69, + ), + Lint( + Lint { + span: Span { + lo: 64, + hi: 69, + }, + level: Error, + message: "attempt to divide by zero", + help: "division by zero will fail at runtime", + kind: Ast( + DivisionByZero, + ), }, - level: Error, - message: "attempt to divide by zero", - help: "division by zero will fail at runtime", - }, - ), - ]"#]], + ), + ]"#]], ); // Modify the manifest. @@ -1573,30 +1586,36 @@ async fn lints_update_after_manifest_change() { check_lints( lints, &expect![[r#" - [ - Lint( - Lint { - span: Span { - lo: 72, - hi: 79, + [ + Lint( + Lint { + span: Span { + lo: 72, + hi: 79, + }, + level: Warn, + message: "unnecessary parentheses", + help: "remove the extra parentheses for clarity", + kind: Ast( + NeedlessParens, + ), }, - level: Warn, - message: "unnecessary parentheses", - help: "remove the extra parentheses for clarity", - }, - ), - Lint( - Lint { - span: Span { - lo: 64, - hi: 69, + ), + Lint( + Lint { + span: Span { + lo: 64, + hi: 69, + }, + level: Warn, + message: "attempt to divide by zero", + help: "division by zero will fail at runtime", + kind: Ast( + DivisionByZero, + ), }, - level: Warn, - message: "attempt to divide by zero", - help: "division by zero will fail at runtime", - }, - ), - ]"#]], + ), + ]"#]], ); } diff --git a/npm/qsharp/src/browser.ts b/npm/qsharp/src/browser.ts index 23392bd104..25015adc4c 100644 --- a/npm/qsharp/src/browser.ts +++ b/npm/qsharp/src/browser.ts @@ -161,6 +161,7 @@ export function getLanguageServiceWorker( export { StepResultId } from "../lib/web/qsc_wasm.js"; export type { IBreakpointSpan, + ICodeAction, ICodeLens, IDocFile, ILocation, @@ -169,6 +170,7 @@ export type { IQSharpError, IRange, IStackFrame, + IWorkspaceEdit, IStructStepResult, VSDiagnostic, } from "../lib/web/qsc_wasm.js"; diff --git a/npm/qsharp/src/language-service/language-service.ts b/npm/qsharp/src/language-service/language-service.ts index d438ba5eab..a4d16e67f4 100644 --- a/npm/qsharp/src/language-service/language-service.ts +++ b/npm/qsharp/src/language-service/language-service.ts @@ -2,12 +2,14 @@ // Licensed under the MIT License. import type { + ICodeAction, ICodeLens, ICompletionList, IHover, ILocation, INotebookMetadata, IPosition, + IRange, ISignatureHelp, ITextEdit, IWorkspaceConfiguration, @@ -50,6 +52,7 @@ export interface ILanguageService { ): Promise; closeDocument(uri: string): Promise; closeNotebookDocument(notebookUri: string): Promise; + getCodeActions(documentUri: string, range: IRange): Promise; getCompletions( documentUri: string, position: IPosition, @@ -161,6 +164,13 @@ export class QSharpLanguageService implements ILanguageService { this.languageService.close_notebook_document(documentUri); } + async getCodeActions( + documentUri: string, + range: IRange, + ): Promise { + return this.languageService.get_code_actions(documentUri, range); + } + async getCompletions( documentUri: string, position: IPosition, @@ -279,6 +289,7 @@ export const languageServiceProtocol: ServiceProtocol< updateNotebookDocument: "request", closeDocument: "request", closeNotebookDocument: "request", + getCodeActions: "request", getCompletions: "request", getFormatChanges: "request", getHover: "request", diff --git a/playground/src/main.tsx b/playground/src/main.tsx index b0a98964a9..46fff00b53 100644 --- a/playground/src/main.tsx +++ b/playground/src/main.tsx @@ -34,7 +34,9 @@ import { import { compressedBase64ToCode, lsRangeToMonacoRange, + lsToMonacoWorkspaceEdit, monacoPositionToLsPosition, + monacoRangetoLsRange, } from "./utils.js"; // Set up the Markdown renderer with KaTeX support @@ -422,20 +424,7 @@ function registerMonacoLanguageServiceProviders( newName, ); if (!rename) return null; - - const edits = rename.changes.flatMap(([uri, edits]) => { - return edits.map((edit) => { - const textEdit: monaco.languages.TextEdit = { - range: lsRangeToMonacoRange(edit.range), - text: edit.newText, - }; - return { - resource: monaco.Uri.parse(uri), - textEdit: textEdit, - } as monaco.languages.IWorkspaceTextEdit; - }); - }); - return { edits: edits } as monaco.languages.WorkspaceEdit; + return lsToMonacoWorkspaceEdit(rename); }, resolveRenameLocation: async ( model: monaco.editor.ITextModel, @@ -494,6 +483,37 @@ function registerMonacoLanguageServiceProviders( return getFormatChanges(model, range); }, }); + + monaco.languages.registerCodeActionProvider("qsharp", { + provideCodeActions: async ( + model: monaco.editor.ITextModel, + range: monaco.Range, + ) => { + const lsCodeActions = await languageService.getCodeActions( + model.uri.toString(), + monacoRangetoLsRange(range), + ); + + const codeActions = lsCodeActions.map((lsCodeAction) => { + let edit; + if (lsCodeAction.edit) { + edit = lsToMonacoWorkspaceEdit(lsCodeAction.edit); + } + + return { + title: lsCodeAction.title, + edit: edit, + kind: lsCodeAction.kind, + isPreferred: lsCodeAction.isPreferred, + } as monaco.languages.CodeAction; + }); + + return { + actions: codeActions, + dispose: () => {}, + } as monaco.languages.CodeActionList; + }, + }); } // Monaco provides the 'require' global for loading modules. diff --git a/playground/src/utils.ts b/playground/src/utils.ts index 3fb0e48aa1..5a5978e0ba 100644 --- a/playground/src/utils.ts +++ b/playground/src/utils.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { IPosition, IRange } from "qsharp-lang"; +import { IPosition, IRange, IWorkspaceEdit } from "qsharp-lang"; // Utility functions to convert source code to and from base64. // @@ -98,3 +98,31 @@ export function lsRangeToMonacoRange(range: IRange): monaco.IRange { range.end.character + 1, ); } + +export function monacoRangetoLsRange(range: monaco.Range): IRange { + return { + start: { + line: range.startLineNumber - 1, + character: range.startColumn - 1, + }, + end: { line: range.endLineNumber - 1, character: range.endColumn - 1 }, + }; +} + +export function lsToMonacoWorkspaceEdit( + iWorkspaceEdit: IWorkspaceEdit, +): monaco.languages.WorkspaceEdit { + const edits = iWorkspaceEdit.changes.flatMap(([uri, edits]) => { + return edits.map((edit) => { + const textEdit: monaco.languages.TextEdit = { + range: lsRangeToMonacoRange(edit.range), + text: edit.newText, + }; + return { + resource: monaco.Uri.parse(uri), + textEdit: textEdit, + } as monaco.languages.IWorkspaceTextEdit; + }); + }); + return { edits: edits } as monaco.languages.WorkspaceEdit; +} diff --git a/vscode/src/codeActions.ts b/vscode/src/codeActions.ts new file mode 100644 index 0000000000..03fb7869c7 --- /dev/null +++ b/vscode/src/codeActions.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILanguageService, ICodeAction } from "qsharp-lang"; +import * as vscode from "vscode"; +import { toVscodeWorkspaceEdit } from "./common"; + +export function createCodeActionsProvider(languageService: ILanguageService) { + return new QSharpCodeActionProvider(languageService); +} + +class QSharpCodeActionProvider implements vscode.CodeActionProvider { + constructor(public languageService: ILanguageService) {} + async provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + ) { + const iCodeActions = await this.languageService.getCodeActions( + document.uri.toString(), + range, + ); + + // Convert language-service type to vscode type + return iCodeActions.map(toCodeAction); + } +} + +function toCodeAction(iCodeAction: ICodeAction): vscode.CodeAction { + const codeAction = new vscode.CodeAction( + iCodeAction.title, + toCodeActionKind(iCodeAction.kind), + ); + if (iCodeAction.edit) { + codeAction.edit = toVscodeWorkspaceEdit(iCodeAction.edit); + } + codeAction.isPreferred = iCodeAction.isPreferred; + return codeAction; +} + +function toCodeActionKind( + codeActionKind?: string, +): vscode.CodeActionKind | undefined { + switch (codeActionKind) { + case "Empty": + return vscode.CodeActionKind.Empty; + case "QuickFix": + return vscode.CodeActionKind.QuickFix; + case "Refactor": + return vscode.CodeActionKind.Refactor; + case "RefactorExtract": + return vscode.CodeActionKind.RefactorExtract; + case "RefactorInline": + return vscode.CodeActionKind.RefactorInline; + case "RefactorMove": + return vscode.CodeActionKind.RefactorMove; + case "RefactorRewrite": + return vscode.CodeActionKind.RefactorRewrite; + case "Source": + return vscode.CodeActionKind.Source; + case "SourceOrganizeImports": + return vscode.CodeActionKind.SourceOrganizeImports; + case "SourceFixAll": + return vscode.CodeActionKind.SourceFixAll; + case "Notebook": + return vscode.CodeActionKind.Notebook; + } +} diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 29217adc69..99e366e3a3 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import { TextDocument, Uri, Range, Location } from "vscode"; -import { ILocation, IRange } from "qsharp-lang"; +import { ILocation, IRange, IWorkspaceEdit } from "qsharp-lang"; +import * as vscode from "vscode"; export const qsharpLanguageId = "qsharp"; @@ -37,3 +38,17 @@ export function toVscodeRange(range: IRange): Range { export function toVscodeLocation(location: ILocation): any { return new Location(Uri.parse(location.source), toVscodeRange(location.span)); } + +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); + }); + workspaceEdit.set(uri, vsEdits); + } + return workspaceEdit; +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index cfe08847b7..e6433b6430 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -12,6 +12,7 @@ import { import * as vscode from "vscode"; import { initAzureWorkspaces } from "./azure/commands.js"; import { createCodeLensProvider } from "./codeLens.js"; +import { createCodeActionsProvider } from "./codeActions.js"; import { isQsharpDocument, isQsharpNotebookCell, @@ -299,6 +300,13 @@ async function activateLanguageService(extensionUri: vscode.Uri) { ), ); + subscriptions.push( + vscode.languages.registerCodeActionsProvider( + qsharpLanguageId, + createCodeActionsProvider(languageService), + ), + ); + // add the language service dispose handler as well subscriptions.push(languageService); diff --git a/vscode/src/rename.ts b/vscode/src/rename.ts index dbfd1dd0ac..0569a73801 100644 --- a/vscode/src/rename.ts +++ b/vscode/src/rename.ts @@ -3,7 +3,7 @@ import { ILanguageService } from "qsharp-lang"; import * as vscode from "vscode"; -import { toVscodeRange } from "./common"; +import { toVscodeRange, toVscodeWorkspaceEdit } from "./common"; export function createRenameProvider(languageService: ILanguageService) { return new QSharpRenameProvider(languageService); @@ -25,19 +25,7 @@ class QSharpRenameProvider implements vscode.RenameProvider { newName, ); if (!rename) return null; - - const workspaceEdit = new vscode.WorkspaceEdit(); - - for (const [source, edits] of rename.changes) { - const uri = vscode.Uri.parse(source, true); - - const vsEdits = edits.map((edit) => { - return new vscode.TextEdit(toVscodeRange(edit.range), edit.newText); - }); - workspaceEdit.set(uri, vsEdits); - } - - return workspaceEdit; + return toVscodeWorkspaceEdit(rename); } async prepareRename( diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs index e3abf60c33..c4ae6eb40f 100644 --- a/wasm/src/language_service.rs +++ b/wasm/src/language_service.rs @@ -4,7 +4,7 @@ use crate::{ diagnostic::VSDiagnostic, into_async_rust_fn_with, - line_column::{ILocation, IPosition, Location, Position, Range}, + line_column::{ILocation, IPosition, IRange, Location, Position, Range}, project_system::{ get_manifest_transformer, list_directory_transformer, read_file_transformer, GetManifestCallback, ListDirectoryCallback, ReadFileCallback, @@ -153,6 +153,15 @@ impl LanguageService { self.0.close_notebook_document(notebook_uri); } + pub fn get_code_actions(&self, uri: &str, range: IRange) -> Vec { + let range: Range = range.into(); + let code_actions = self.0.get_code_actions(uri, range.into()); + code_actions + .into_iter() + .map(|code_action| Into::::into(code_action).into()) + .collect() + } + pub fn get_completions(&self, uri: &str, position: IPosition) -> ICompletionList { let position: Position = position.into(); let completion_list = self.0.get_completions(uri, position.into()); @@ -344,6 +353,52 @@ serializable_type! { IWorkspaceConfiguration } +serializable_type! { + CodeAction, + { + pub title: String, + pub edit: Option, + pub kind: Option, + pub is_preferred: Option, + }, + r#"export interface ICodeAction { + title: string; + edit?: IWorkspaceEdit; + kind?: "Empty" | "QuickFix" | "Refactor" | "RefactorExtract" | "RefactorInline" | "RefactorMove" | "RefactorRewrite" | "Source" | "SourceOrganizeImports" | "SourceFixAll" | "Notebook"; + isPreferred?: boolean; + }"#, + ICodeAction +} + +impl From for CodeAction { + fn from(code_action: qsls::protocol::CodeAction) -> Self { + let kind = code_action.kind.map(|kind| { + use qsls::protocol::CodeActionKind; + match kind { + CodeActionKind::Empty => "Empty", + CodeActionKind::QuickFix => "QuickFix", + CodeActionKind::Refactor => "Refactor", + CodeActionKind::RefactorExtract => "RefactorExtract", + CodeActionKind::RefactorInline => "RefactorInline", + CodeActionKind::RefactorMove => "RefactorMove", + CodeActionKind::RefactorRewrite => "RefactorRewrite", + CodeActionKind::Source => "Source", + CodeActionKind::SourceOrganizeImports => "SourceOrganizeImports", + CodeActionKind::SourceFixAll => "SourceFixAll", + CodeActionKind::Notebook => "Notebook", + } + .to_string() + }); + + Self { + title: code_action.title, + edit: code_action.edit.map(Into::into), + kind, + is_preferred: code_action.is_preferred, + } + } +} + serializable_type! { CompletionList, { @@ -386,6 +441,15 @@ serializable_type! { ITextEdit } +impl From for TextEdit { + fn from(text_edit: qsls::protocol::TextEdit) -> Self { + Self { + range: text_edit.range.into(), + newText: text_edit.new_text, + } + } +} + serializable_type! { Hover, { @@ -484,6 +548,18 @@ serializable_type! { IWorkspaceEdit } +impl From for WorkspaceEdit { + fn from(workspace_edit: qsls::protocol::WorkspaceEdit) -> Self { + Self { + changes: workspace_edit + .changes + .into_iter() + .map(|(uri, edits)| (uri, edits.into_iter().map(Into::into).collect())) + .collect(), + } + } +} + serializable_type! { Cell, { diff --git a/wasm/src/line_column.rs b/wasm/src/line_column.rs index 7c7a771d25..71e5d06f14 100644 --- a/wasm/src/line_column.rs +++ b/wasm/src/line_column.rs @@ -28,7 +28,8 @@ serializable_type! { r#"export interface IRange { start: IPosition; end: IPosition; - }"# + }"#, + IRange } serializable_type! { @@ -62,6 +63,15 @@ impl From for Position { } } +impl From for qsc::line_column::Range { + fn from(range: Range) -> Self { + qsc::line_column::Range { + start: range.start.into(), + end: range.end.into(), + } + } +} + impl From for Range { fn from(range: qsc::line_column::Range) -> Self { Range {