Skip to content

Commit

Permalink
Add support for CodeActions in the Language Service (#1495)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
orpuente-MS authored May 29, 2024
1 parent 9aa8aaf commit f20c06a
Show file tree
Hide file tree
Showing 21 changed files with 588 additions and 87 deletions.
22 changes: 22 additions & 0 deletions compiler/qsc_data_structures/src/span.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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<u32> for Span {
type Output = Self;

Expand Down
1 change: 1 addition & 0 deletions compiler/qsc_linter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ mod lints;
mod tests;

pub use linter::{run_lints, Lint, LintConfig, LintKind, LintLevel};
pub use lints::{ast::AstLint, hir::HirLint};
2 changes: 2 additions & 0 deletions compiler/qsc_linter/src/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 9 additions & 4 deletions compiler/qsc_linter/src/linter/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LintLevel> 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) }
}
}

Expand All @@ -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
),*
}
};

Expand Down
13 changes: 9 additions & 4 deletions compiler/qsc_linter/src/linter/hir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LintLevel> 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) }
}
}

Expand All @@ -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
),*
}
};

Expand Down
1 change: 1 addition & 0 deletions compiler/qsc_linter/src/lints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ macro_rules! lint {
level: $lint.level,
message: $lint.message,
help: $lint.help,
kind: $lint.kind,
}
};
}
Expand Down
163 changes: 163 additions & 0 deletions language_service/src/code_action.rs
Original file line number Diff line number Diff line change
@@ -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<CodeAction> {
// 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<CodeAction> {
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<ErrorKind>, 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<ErrorKind>) -> 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<ErrorKind>, encoding: Encoding) -> Option<Range> {
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()
}
21 changes: 20 additions & 1 deletion language_service/src/compilation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 13 additions & 2 deletions language_service/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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},
Expand Down Expand Up @@ -178,6 +179,16 @@ impl LanguageService {
});
}

#[must_use]
pub fn get_code_actions(&self, uri: &str, range: Range) -> Vec<CodeAction> {
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 {
Expand Down
Loading

0 comments on commit f20c06a

Please sign in to comment.