mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-08-03 17:58:17 +00:00
feat: autofix unknown variable: a
and file not found (searched at a.typ)
by code action (#1743)
* feat: check context * feat: implement it * fix: warnings * test: update snapshot
This commit is contained in:
parent
d6d3766b6f
commit
38974a3b5e
26 changed files with 834 additions and 84 deletions
|
@ -1,8 +1,13 @@
|
|||
//! Provides code actions for the document.
|
||||
|
||||
use ecow::eco_format;
|
||||
use lsp_types::{ChangeAnnotation, CreateFile, CreateFileOptions};
|
||||
use regex::Regex;
|
||||
use tinymist_analysis::syntax::{adjust_expr, node_ancestors, SyntaxClass};
|
||||
use tinymist_analysis::syntax::{
|
||||
adjust_expr, node_ancestors, previous_items, PreviousItem, SyntaxClass,
|
||||
};
|
||||
use tinymist_std::path::{diff, unix_slash};
|
||||
use typst::syntax::Side;
|
||||
|
||||
use super::get_link_exprs_in;
|
||||
use crate::analysis::LinkTarget;
|
||||
|
@ -16,7 +21,7 @@ pub struct CodeActionWorker<'a> {
|
|||
/// The source document to analyze.
|
||||
source: Source,
|
||||
/// The code actions to provide.
|
||||
pub actions: Vec<CodeActionOrCommand>,
|
||||
pub actions: Vec<CodeAction>,
|
||||
/// The lazily calculated local URL to [`Self::source`].
|
||||
local_url: OnceLock<Option<Url>>,
|
||||
}
|
||||
|
@ -39,20 +44,200 @@ impl<'a> CodeActionWorker<'a> {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
fn local_edits(&self, edits: Vec<TextEdit>) -> Option<WorkspaceEdit> {
|
||||
Some(WorkspaceEdit {
|
||||
fn local_edits(&self, edits: Vec<EcoSnippetTextEdit>) -> Option<EcoWorkspaceEdit> {
|
||||
Some(EcoWorkspaceEdit {
|
||||
changes: Some(HashMap::from_iter([(self.local_url()?.clone(), edits)])),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn local_edit(&self, edit: TextEdit) -> Option<WorkspaceEdit> {
|
||||
fn local_edit(&self, edit: EcoSnippetTextEdit) -> Option<EcoWorkspaceEdit> {
|
||||
self.local_edits(vec![edit])
|
||||
}
|
||||
|
||||
pub(crate) fn autofix(
|
||||
&mut self,
|
||||
root: &LinkedNode<'_>,
|
||||
range: &Range<usize>,
|
||||
context: &lsp_types::CodeActionContext,
|
||||
) -> Option<()> {
|
||||
if let Some(only) = &context.only {
|
||||
if !only.is_empty()
|
||||
&& !only
|
||||
.iter()
|
||||
.any(|kind| *kind == CodeActionKind::EMPTY || *kind == CodeActionKind::QUICKFIX)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
for diag in &context.diagnostics {
|
||||
if diag.source.as_ref().is_none_or(|t| t != "typst") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match match_autofix_kind(diag.message.as_str()) {
|
||||
Some(AutofixKind::UnknownVariable) => {
|
||||
self.autofix_unknown_variable(root, range);
|
||||
}
|
||||
Some(AutofixKind::FileNotFound) => {
|
||||
self.autofix_file_not_found(root, range);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Automatically fixes unknown variable errors.
|
||||
pub fn autofix_unknown_variable(
|
||||
&mut self,
|
||||
root: &LinkedNode,
|
||||
range: &Range<usize>,
|
||||
) -> Option<()> {
|
||||
let cursor = (range.start + 1).min(self.source.text().len());
|
||||
let node = root.leaf_at_compat(cursor)?;
|
||||
|
||||
let ident = 'determine_ident: {
|
||||
if let Some(ident) = node.cast::<ast::Ident>() {
|
||||
break 'determine_ident ident.get().clone();
|
||||
}
|
||||
if let Some(ident) = node.cast::<ast::MathIdent>() {
|
||||
break 'determine_ident ident.get().clone();
|
||||
}
|
||||
|
||||
return None;
|
||||
};
|
||||
|
||||
enum CreatePosition {
|
||||
Before(usize),
|
||||
After(usize),
|
||||
Bad,
|
||||
}
|
||||
|
||||
let previous_decl = previous_items(node, |item| {
|
||||
match item {
|
||||
PreviousItem::Parent(parent, ..) => match parent.kind() {
|
||||
SyntaxKind::LetBinding => {
|
||||
let mut create_before = parent.clone();
|
||||
while let Some(before) = create_before.prev_sibling() {
|
||||
if matches!(before.kind(), SyntaxKind::Hash) {
|
||||
create_before = before;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return Some(CreatePosition::Before(create_before.range().start));
|
||||
}
|
||||
SyntaxKind::CodeBlock | SyntaxKind::ContentBlock => {
|
||||
let child = parent.children().find(|child| {
|
||||
matches!(
|
||||
child.kind(),
|
||||
SyntaxKind::LeftBrace | SyntaxKind::LeftBracket
|
||||
)
|
||||
})?;
|
||||
|
||||
return Some(CreatePosition::After(child.range().end));
|
||||
}
|
||||
SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude => {
|
||||
return Some(CreatePosition::Bad);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
PreviousItem::Sibling(node) => {
|
||||
if matches!(
|
||||
node.kind(),
|
||||
SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude
|
||||
) {
|
||||
// todo: hash
|
||||
return Some(CreatePosition::After(node.range().end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
});
|
||||
|
||||
let (create_pos, side) = match previous_decl {
|
||||
Some(CreatePosition::Before(pos)) => (pos, Side::Before),
|
||||
Some(CreatePosition::After(pos)) => (pos, Side::After),
|
||||
None => (0, Side::After),
|
||||
Some(CreatePosition::Bad) => return None,
|
||||
};
|
||||
|
||||
let pos_node = root.leaf_at(create_pos, side.clone());
|
||||
let mode = match interpret_mode_at(pos_node.as_ref()) {
|
||||
InterpretMode::Markup => "#",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let extend_assign = if self.ctx.analysis.extended_code_action {
|
||||
" = ${1:none}$0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let new_text = if matches!(side, Side::Before) {
|
||||
eco_format!("{mode}let {ident}{extend_assign}\n\n")
|
||||
} else {
|
||||
eco_format!("\n\n{mode}let {ident}{extend_assign}")
|
||||
};
|
||||
|
||||
let range = self.ctx.to_lsp_range(create_pos..create_pos, &self.source);
|
||||
let edit = self.local_edit(EcoSnippetTextEdit::new(range, new_text))?;
|
||||
let action = CodeAction {
|
||||
title: "Create missing variable".to_string(),
|
||||
kind: Some(CodeActionKind::QUICKFIX),
|
||||
edit: Some(edit),
|
||||
..CodeAction::default()
|
||||
};
|
||||
self.actions.push(action);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Automatically fixes file not found errors.
|
||||
pub fn autofix_file_not_found(
|
||||
&mut self,
|
||||
root: &LinkedNode,
|
||||
range: &Range<usize>,
|
||||
) -> Option<()> {
|
||||
let cursor = (range.start + 1).min(self.source.text().len());
|
||||
let node = root.leaf_at_compat(cursor)?;
|
||||
|
||||
let importing = node.cast::<ast::Str>()?.get();
|
||||
if importing.starts_with('@') {
|
||||
// todo: create local package?
|
||||
// if importing.starts_with("@local") { return None; }
|
||||
|
||||
// This is a package import, not a file import.
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_id = node.span().id()?;
|
||||
let root_path = self.ctx.path_for_id(file_id.join("/")).ok()?;
|
||||
let path_in_workspace = file_id.vpath().join(importing.as_str());
|
||||
let new_path = path_in_workspace.resolve(root_path.as_path())?;
|
||||
let new_file_url = path_to_url(&new_path).ok()?;
|
||||
|
||||
let edit = self.create_file(new_file_url, false);
|
||||
|
||||
let file_to_create = unix_slash(path_in_workspace.as_rooted_path());
|
||||
let action = CodeAction {
|
||||
title: format!("Create missing file at `{file_to_create}`"),
|
||||
kind: Some(CodeActionKind::QUICKFIX),
|
||||
edit: Some(edit),
|
||||
..CodeAction::default()
|
||||
};
|
||||
self.actions.push(action);
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Starts to work.
|
||||
pub fn work(&mut self, root: LinkedNode, range: Range<usize>) -> Option<()> {
|
||||
pub fn scoped(&mut self, root: &LinkedNode, range: &Range<usize>) -> Option<()> {
|
||||
let cursor = (range.start + 1).min(self.source.text().len());
|
||||
let node = root.leaf_at_compat(cursor)?;
|
||||
let mut node = &node;
|
||||
|
@ -133,12 +318,12 @@ impl<'a> CodeActionWorker<'a> {
|
|||
let cur_path = id.vpath().as_rooted_path().parent().unwrap();
|
||||
let new_path = diff(path, cur_path)?;
|
||||
let edit = self.edit_str(node, unix_slash(&new_path))?;
|
||||
let action = CodeActionOrCommand::CodeAction(CodeAction {
|
||||
let action = CodeAction {
|
||||
title: "Convert to relative path".to_string(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(edit),
|
||||
..CodeAction::default()
|
||||
});
|
||||
};
|
||||
self.actions.push(action);
|
||||
} else {
|
||||
// Convert relative path to absolute path
|
||||
|
@ -155,32 +340,32 @@ impl<'a> CodeActionWorker<'a> {
|
|||
}
|
||||
}
|
||||
let edit = self.edit_str(node, unix_slash(&new_path))?;
|
||||
let action = CodeActionOrCommand::CodeAction(CodeAction {
|
||||
let action = CodeAction {
|
||||
title: "Convert to absolute path".to_string(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(edit),
|
||||
..CodeAction::default()
|
||||
});
|
||||
};
|
||||
self.actions.push(action);
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn edit_str(&mut self, node: &LinkedNode, new_content: String) -> Option<WorkspaceEdit> {
|
||||
fn edit_str(&mut self, node: &LinkedNode, new_content: String) -> Option<EcoWorkspaceEdit> {
|
||||
if !matches!(node.kind(), SyntaxKind::Str) {
|
||||
log::warn!("edit_str only works on string AST nodes: {:?}", node.kind());
|
||||
return None;
|
||||
}
|
||||
|
||||
self.local_edit(TextEdit {
|
||||
range: self.ctx.to_lsp_range(node.range(), &self.source),
|
||||
// todo: this is merely ocasionally correct, abusing string escape (`fmt::Debug`)
|
||||
new_text: format!("{new_content:?}"),
|
||||
})
|
||||
self.local_edit(EcoSnippetTextEdit::new_plain(
|
||||
self.ctx.to_lsp_range(node.range(), &self.source),
|
||||
// todo: this is merely occasionally correct, abusing string escape (`fmt::Debug`)
|
||||
eco_format!("{new_content:?}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn wrap_actions(&mut self, node: &LinkedNode, range: Range<usize>) -> Option<()> {
|
||||
fn wrap_actions(&mut self, node: &LinkedNode, range: &Range<usize>) -> Option<()> {
|
||||
if range.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
@ -191,24 +376,23 @@ impl<'a> CodeActionWorker<'a> {
|
|||
}
|
||||
|
||||
let edit = self.local_edits(vec![
|
||||
TextEdit {
|
||||
range: self
|
||||
.ctx
|
||||
EcoSnippetTextEdit::new_plain(
|
||||
self.ctx
|
||||
.to_lsp_range(range.start..range.start, &self.source),
|
||||
new_text: "#[".into(),
|
||||
},
|
||||
TextEdit {
|
||||
range: self.ctx.to_lsp_range(range.end..range.end, &self.source),
|
||||
new_text: "]".into(),
|
||||
},
|
||||
EcoString::inline("#["),
|
||||
),
|
||||
EcoSnippetTextEdit::new_plain(
|
||||
self.ctx.to_lsp_range(range.end..range.end, &self.source),
|
||||
EcoString::inline("]"),
|
||||
),
|
||||
])?;
|
||||
|
||||
let action = CodeActionOrCommand::CodeAction(CodeAction {
|
||||
let action = CodeAction {
|
||||
title: "Wrap with content block".to_string(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(edit),
|
||||
..CodeAction::default()
|
||||
});
|
||||
};
|
||||
self.actions.push(action);
|
||||
|
||||
Some(())
|
||||
|
@ -226,28 +410,28 @@ impl<'a> CodeActionWorker<'a> {
|
|||
|
||||
if depth > 1 {
|
||||
// Decrease depth of heading
|
||||
let action = CodeActionOrCommand::CodeAction(CodeAction {
|
||||
let action = CodeAction {
|
||||
title: "Decrease depth of heading".to_string(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(self.local_edit(TextEdit {
|
||||
range: self.ctx.to_lsp_range(marker_range.clone(), &self.source),
|
||||
new_text: "=".repeat(depth - 1),
|
||||
})?),
|
||||
edit: Some(self.local_edit(EcoSnippetTextEdit::new_plain(
|
||||
self.ctx.to_lsp_range(marker_range.clone(), &self.source),
|
||||
EcoString::inline("=").repeat(depth - 1),
|
||||
))?),
|
||||
..CodeAction::default()
|
||||
});
|
||||
};
|
||||
self.actions.push(action);
|
||||
}
|
||||
|
||||
// Increase depth of heading
|
||||
let action = CodeActionOrCommand::CodeAction(CodeAction {
|
||||
let action = CodeAction {
|
||||
title: "Increase depth of heading".to_string(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(self.local_edit(TextEdit {
|
||||
range: self.ctx.to_lsp_range(marker_range, &self.source),
|
||||
new_text: "=".repeat(depth + 1),
|
||||
})?),
|
||||
edit: Some(self.local_edit(EcoSnippetTextEdit::new_plain(
|
||||
self.ctx.to_lsp_range(marker_range, &self.source),
|
||||
EcoString::inline("=").repeat(depth + 1),
|
||||
))?),
|
||||
..CodeAction::default()
|
||||
});
|
||||
};
|
||||
self.actions.push(action);
|
||||
|
||||
Some(())
|
||||
|
@ -302,10 +486,7 @@ impl<'a> CodeActionWorker<'a> {
|
|||
let ch_range = self
|
||||
.ctx
|
||||
.to_lsp_range(node_end..node_end + nx.len_utf8(), &self.source);
|
||||
let remove_edit = TextEdit {
|
||||
range: ch_range,
|
||||
new_text: "".to_owned(),
|
||||
};
|
||||
let remove_edit = EcoSnippetTextEdit::new_plain(ch_range, EcoString::new());
|
||||
Some((nx, remove_edit))
|
||||
} else {
|
||||
None
|
||||
|
@ -313,22 +494,19 @@ impl<'a> CodeActionWorker<'a> {
|
|||
|
||||
let rewrite_action = |title: &str, new_text: &str| {
|
||||
let mut edits = vec![
|
||||
TextEdit {
|
||||
range: front_range,
|
||||
new_text: new_text.to_owned(),
|
||||
},
|
||||
TextEdit {
|
||||
range: back_range,
|
||||
new_text: if !new_text.is_empty() {
|
||||
EcoSnippetTextEdit::new_plain(front_range, new_text.into()),
|
||||
EcoSnippetTextEdit::new_plain(
|
||||
back_range,
|
||||
if !new_text.is_empty() {
|
||||
if let Some((ch, _)) = &punc_modify {
|
||||
ch.to_string() + new_text
|
||||
EcoString::from(*ch) + new_text
|
||||
} else {
|
||||
new_text.to_owned()
|
||||
new_text.into()
|
||||
}
|
||||
} else {
|
||||
"".to_owned()
|
||||
EcoString::new()
|
||||
},
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
if !new_text.is_empty() {
|
||||
|
@ -337,12 +515,12 @@ impl<'a> CodeActionWorker<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
Some(CodeActionOrCommand::CodeAction(CodeAction {
|
||||
Some(CodeAction {
|
||||
title: title.to_owned(),
|
||||
kind: Some(CodeActionKind::REFACTOR_REWRITE),
|
||||
edit: Some(self.local_edits(edits)?),
|
||||
..CodeAction::default()
|
||||
}))
|
||||
})
|
||||
};
|
||||
|
||||
// Prepare actions
|
||||
|
@ -360,4 +538,54 @@ impl<'a> CodeActionWorker<'a> {
|
|||
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn create_file(&self, uri: Url, needs_confirmation: bool) -> EcoWorkspaceEdit {
|
||||
let change_id = "Typst Create Missing Files".to_string();
|
||||
|
||||
let create_op = EcoDocumentChangeOperation::Op(lsp_types::ResourceOp::Create(CreateFile {
|
||||
uri,
|
||||
options: Some(CreateFileOptions {
|
||||
overwrite: Some(false),
|
||||
ignore_if_exists: None,
|
||||
}),
|
||||
annotation_id: Some(change_id.clone()),
|
||||
}));
|
||||
|
||||
let mut change_annotations = HashMap::new();
|
||||
change_annotations.insert(
|
||||
change_id.clone(),
|
||||
ChangeAnnotation {
|
||||
label: change_id,
|
||||
needs_confirmation: Some(needs_confirmation),
|
||||
description: Some("The file is missing but required by code".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
EcoWorkspaceEdit {
|
||||
changes: None,
|
||||
document_changes: Some(EcoDocumentChanges::Operations(vec![create_op])),
|
||||
change_annotations: Some(change_annotations),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AutofixKind {
|
||||
UnknownVariable,
|
||||
FileNotFound,
|
||||
}
|
||||
|
||||
fn match_autofix_kind(msg: &str) -> Option<AutofixKind> {
|
||||
static PATTERNS: &[(&str, AutofixKind)] = &[
|
||||
("unknown variable", AutofixKind::UnknownVariable),
|
||||
("file not found", AutofixKind::FileNotFound),
|
||||
];
|
||||
|
||||
for (pattern, kind) in PATTERNS {
|
||||
if msg.starts_with(pattern) {
|
||||
return Some(*kind);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
|
|
@ -377,11 +377,11 @@ type Cursor<'a> = CompletionCursor<'a>;
|
|||
|
||||
/// A node selected by [`CompletionCursor`].
|
||||
enum SelectedNode<'a> {
|
||||
/// Selects an identifier, e.g. `foo|` or `fo|o`.
|
||||
/// Selects an identifier, e.g. `foobar|` or `foo|bar`.
|
||||
Ident(LinkedNode<'a>),
|
||||
/// Selects a label, e.g. `<foo|>` or `<fo|o>`.
|
||||
/// Selects a label, e.g. `<foobar|>` or `<foo|bar>`.
|
||||
Label(LinkedNode<'a>),
|
||||
/// Selects a reference, e.g. `@foo|` or `@fo|o`.
|
||||
/// Selects a reference, e.g. `@foobar|` or `@foo|bar`.
|
||||
Ref(LinkedNode<'a>),
|
||||
}
|
||||
|
||||
|
|
|
@ -65,6 +65,14 @@ pub struct Analysis {
|
|||
pub allow_multiline_token: bool,
|
||||
/// Whether to remove html from markup content in responses.
|
||||
pub remove_html: bool,
|
||||
/// Whether to utilize the extended `tinymist.resolveCodeAction` at client
|
||||
/// side.
|
||||
///
|
||||
/// The extended feature by `tinymist.resolveCodeAction`:
|
||||
/// - supports Snippet edit.
|
||||
///
|
||||
/// The example implementation can be found in the VS Code extension.
|
||||
pub extended_code_action: bool,
|
||||
/// Tinymist's completion features.
|
||||
pub completion_feat: CompletionFeat,
|
||||
/// The editor's color theme.
|
||||
|
|
|
@ -60,8 +60,10 @@ impl LinkTarget {
|
|||
LinkTarget::Url(url) => Some(url.as_ref().clone()),
|
||||
LinkTarget::Path(id, path) => {
|
||||
// Avoid creating new ids here.
|
||||
let root = ctx.path_for_id(id.join("")).ok()?;
|
||||
crate::path_res_to_url(root.join(path).ok()?).ok()
|
||||
let root = ctx.path_for_id(id.join("/")).ok()?;
|
||||
let path_in_workspace = id.vpath().join(Path::new(path.as_str()));
|
||||
let path = root.resolve_to(&path_in_workspace)?;
|
||||
crate::path_res_to_url(path).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
use lsp_types::CodeActionContext;
|
||||
|
||||
use crate::{analysis::CodeActionWorker, prelude::*, SemanticRequest};
|
||||
|
||||
pub(crate) mod proto;
|
||||
|
||||
/// The [`textDocument/codeAction`] request is sent from the client to the
|
||||
/// server to compute commands for a given text document and range. These
|
||||
/// commands are typically code fixes to either fix problems or to
|
||||
|
@ -64,18 +68,23 @@ pub struct CodeActionRequest {
|
|||
pub path: PathBuf,
|
||||
/// The range of the document to get code actions for.
|
||||
pub range: LspRange,
|
||||
/// The context of the code action request.
|
||||
pub context: CodeActionContext,
|
||||
}
|
||||
|
||||
impl SemanticRequest for CodeActionRequest {
|
||||
type Response = Vec<CodeActionOrCommand>;
|
||||
type Response = Vec<CodeAction>;
|
||||
|
||||
fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
|
||||
log::info!("requested code action: {self:?}");
|
||||
|
||||
let source = ctx.source_by_path(&self.path).ok()?;
|
||||
let range = ctx.to_typst_range(self.range, &source)?;
|
||||
|
||||
let root = LinkedNode::new(source.root());
|
||||
let mut worker = CodeActionWorker::new(ctx, source.clone());
|
||||
worker.work(root, range);
|
||||
worker.autofix(&root, &range, &self.context);
|
||||
worker.scoped(&root, &range);
|
||||
|
||||
(!worker.actions.is_empty()).then_some(worker.actions)
|
||||
}
|
||||
|
@ -94,12 +103,13 @@ mod tests {
|
|||
let request = CodeActionRequest {
|
||||
path: path.clone(),
|
||||
range: find_test_range(&source),
|
||||
context: CodeActionContext::default(),
|
||||
};
|
||||
|
||||
let result = request.request(ctx);
|
||||
|
||||
with_settings!({
|
||||
description => format!("Code Action on {}", make_range_annoation(&source)),
|
||||
description => format!("Code Action on {}", make_range_annotation(&source)),
|
||||
}, {
|
||||
assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
|
||||
})
|
||||
|
|
309
crates/tinymist-query/src/code_action/proto.rs
Normal file
309
crates/tinymist-query/src/code_action/proto.rs
Normal file
|
@ -0,0 +1,309 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use ecow::EcoString;
|
||||
use lsp_types::{
|
||||
ChangeAnnotation, ChangeAnnotationIdentifier, CodeActionDisabled, CodeActionKind, Command,
|
||||
Diagnostic, InsertTextFormat, OneOf, OptionalVersionedTextDocumentIdentifier, ResourceOp, Url,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::LspRange;
|
||||
use crate::completion::EcoTextEdit;
|
||||
|
||||
/// A textual edit applicable to a text document.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EcoSnippetTextEdit {
|
||||
/// The text edit
|
||||
#[serde(flatten)]
|
||||
edit: EcoTextEdit,
|
||||
/// The format of the insert text. The format applies to both the
|
||||
insert_text_format: Option<InsertTextFormat>,
|
||||
}
|
||||
|
||||
impl EcoSnippetTextEdit {
|
||||
pub fn new_plain(range: LspRange, new_text: EcoString) -> EcoSnippetTextEdit {
|
||||
EcoSnippetTextEdit {
|
||||
edit: EcoTextEdit::new(range, new_text),
|
||||
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(range: LspRange, new_text: EcoString) -> EcoSnippetTextEdit {
|
||||
EcoSnippetTextEdit {
|
||||
edit: EcoTextEdit::new(range, new_text),
|
||||
insert_text_format: Some(InsertTextFormat::SNIPPET),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A special text edit with an additional change annotation.
|
||||
///
|
||||
/// @since 3.16.0
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EcoAnnotatedTextEdit {
|
||||
#[serde(flatten)]
|
||||
pub text_edit: EcoSnippetTextEdit,
|
||||
|
||||
/// The actual annotation
|
||||
pub annotation_id: ChangeAnnotationIdentifier,
|
||||
}
|
||||
|
||||
/// Describes textual changes on a single text document. The text document is
|
||||
/// referred to as a `OptionalVersionedTextDocumentIdentifier` to allow clients
|
||||
/// to check the text document version before an edit is applied. A
|
||||
/// `TextDocumentEdit` describes all changes on a version Si and after they are
|
||||
/// applied move the document to version Si+1. So the creator of a
|
||||
/// `TextDocumentEdit` doesn't need to sort the array or do any kind of
|
||||
/// ordering. However the edits must be non overlapping.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EcoTextDocumentEdit {
|
||||
/// The text document to change.
|
||||
pub text_document: OptionalVersionedTextDocumentIdentifier,
|
||||
|
||||
/// The edits to be applied.
|
||||
///
|
||||
/// @since 3.16.0 - support for AnnotatedTextEdit. This is guarded by the
|
||||
/// client capability `workspace.workspaceEdit.changeAnnotationSupport`
|
||||
pub edits: Vec<OneOf<EcoSnippetTextEdit, EcoAnnotatedTextEdit>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeAction {
|
||||
/// A short, human-readable, title for this code action.
|
||||
pub title: String,
|
||||
|
||||
/// The kind of the code action.
|
||||
/// Used to filter code actions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<CodeActionKind>,
|
||||
|
||||
/// The diagnostics that this code action resolves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub diagnostics: Option<Vec<Diagnostic>>,
|
||||
|
||||
/// The workspace edit this code action performs.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edit: Option<EcoWorkspaceEdit>,
|
||||
|
||||
/// A command this code action executes. If a code action
|
||||
/// provides an edit and a command, first the edit is
|
||||
/// executed and then the command.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Marks this as a preferred action. Preferred actions are used by the
|
||||
/// `auto fix` command and can be targeted by keybindings.
|
||||
/// A quick fix should be marked preferred if it properly addresses the
|
||||
/// underlying error. A refactoring should be marked preferred if it is
|
||||
/// the most reasonable choice of actions to take.
|
||||
///
|
||||
/// @since 3.15.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_preferred: Option<bool>,
|
||||
|
||||
/// Marks that the code action cannot currently be applied.
|
||||
///
|
||||
/// Clients should follow the following guidelines regarding disabled code
|
||||
/// actions:
|
||||
///
|
||||
/// - Disabled code actions are not shown in automatic [lightbulb](https://code.visualstudio.com/docs/editor/editingevolved#_code-action)
|
||||
/// code action menu.
|
||||
///
|
||||
/// - Disabled actions are shown as faded out in the code action menu when
|
||||
/// the user request a more specific type of code action, such as
|
||||
/// refactorings.
|
||||
///
|
||||
/// - If the user has a keybinding that auto applies a code action and only
|
||||
/// a disabled code actions are returned, the client should show the user
|
||||
/// an error message with `reason` in the editor.
|
||||
///
|
||||
/// @since 3.16.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<CodeActionDisabled>,
|
||||
|
||||
/// A data entry field that is preserved on a code action between
|
||||
/// a `textDocument/codeAction` and a `codeAction/resolve` request.
|
||||
///
|
||||
/// @since 3.16.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// A workspace edit represents changes to many resources managed in the
|
||||
/// workspace. The edit should either provide `changes` or `documentChanges`.
|
||||
/// If the client can handle versioned document edits and if `documentChanges`
|
||||
/// are present, the latter are preferred over `changes`.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EcoWorkspaceEdit {
|
||||
/// Holds changes to existing resources.
|
||||
#[serde(with = "url_map")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub changes: Option<HashMap<Url, Vec<EcoSnippetTextEdit>>>,
|
||||
|
||||
/// Depending on the client capability
|
||||
/// `workspace.workspaceEdit.resourceOperations` document changes
|
||||
/// are either an array of `TextDocumentEdit`s to express changes to n
|
||||
/// different text documents where each text document edit addresses a
|
||||
/// specific version of a text document. Or it can contain
|
||||
/// above `TextDocumentEdit`s mixed with create, rename and delete file /
|
||||
/// folder operations.
|
||||
///
|
||||
/// Whether a client supports versioned document edits is expressed via
|
||||
/// `workspace.workspaceEdit.documentChanges` client capability.
|
||||
///
|
||||
/// If a client neither supports `documentChanges` nor
|
||||
/// `workspace.workspaceEdit.resourceOperations` then only plain
|
||||
/// `TextEdit`s using the `changes` property are supported.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_changes: Option<EcoDocumentChanges>,
|
||||
|
||||
/// A map of change annotations that can be referenced in
|
||||
/// `AnnotatedTextEdit`s or create, rename and delete file / folder
|
||||
/// operations.
|
||||
///
|
||||
/// Whether clients honor this property depends on the client capability
|
||||
/// `workspace.changeAnnotationSupport`.
|
||||
///
|
||||
/// @since 3.16.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub change_annotations: Option<HashMap<ChangeAnnotationIdentifier, ChangeAnnotation>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EcoDocumentChanges {
|
||||
Edits(Vec<EcoTextDocumentEdit>),
|
||||
Operations(Vec<EcoDocumentChangeOperation>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
|
||||
#[serde(untagged, rename_all = "lowercase")]
|
||||
pub enum EcoDocumentChangeOperation {
|
||||
Op(ResourceOp),
|
||||
Edit(EcoTextDocumentEdit),
|
||||
}
|
||||
|
||||
mod url_map {
|
||||
use std::marker::PhantomData;
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use lsp_types::Url;
|
||||
use serde::de;
|
||||
|
||||
pub fn deserialize<'de, D, V>(deserializer: D) -> Result<Option<HashMap<Url, V>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
V: de::DeserializeOwned,
|
||||
{
|
||||
struct UrlMapVisitor<V> {
|
||||
_marker: PhantomData<V>,
|
||||
}
|
||||
|
||||
impl<V: de::DeserializeOwned> Default for UrlMapVisitor<V> {
|
||||
fn default() -> Self {
|
||||
UrlMapVisitor {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'de, V: de::DeserializeOwned> de::Visitor<'de> for UrlMapVisitor<V> {
|
||||
type Value = HashMap<Url, V>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("map")
|
||||
}
|
||||
|
||||
fn visit_map<M>(self, mut visitor: M) -> Result<Self::Value, M::Error>
|
||||
where
|
||||
M: de::MapAccess<'de>,
|
||||
{
|
||||
let mut values = HashMap::with_capacity(visitor.size_hint().unwrap_or(0));
|
||||
|
||||
// While there are entries remaining in the input, add them
|
||||
// into our map.
|
||||
while let Some((key, value)) = visitor.next_entry::<Url, _>()? {
|
||||
values.insert(key, value);
|
||||
}
|
||||
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionUrlMapVisitor<V> {
|
||||
_marker: PhantomData<V>,
|
||||
}
|
||||
impl<V: de::DeserializeOwned> Default for OptionUrlMapVisitor<V> {
|
||||
fn default() -> Self {
|
||||
OptionUrlMapVisitor {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'de, V: de::DeserializeOwned> de::Visitor<'de> for OptionUrlMapVisitor<V> {
|
||||
type Value = Option<HashMap<Url, V>>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("option")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_none<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
deserializer
|
||||
.deserialize_map(UrlMapVisitor::<V>::default())
|
||||
.map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
// Instantiate our Visitor and ask the Deserializer to drive
|
||||
// it over the input data, resulting in an instance of MyMap.
|
||||
deserializer.deserialize_option(OptionUrlMapVisitor::default())
|
||||
}
|
||||
|
||||
pub fn serialize<S, V>(
|
||||
changes: &Option<HashMap<Url, V>>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
match *changes {
|
||||
Some(ref changes) => {
|
||||
let mut map = serializer.serialize_map(Some(changes.len()))?;
|
||||
for (k, v) in changes {
|
||||
map.serialize_entry(k.as_str(), v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/code_action.rs
|
||||
description: "Code Action on t \"/base.t||yp\"/* rang)"
|
||||
description: "Code Action on t \"/base.t||yp\"/* rang"
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/code_action/absolute_path_import.typ
|
||||
---
|
||||
|
@ -10,6 +10,7 @@ input_file: crates/tinymist-query/src/fixtures/code_action/absolute_path_import.
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\"base.typ\"",
|
||||
"range": "0:8:0:19"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/code_action.rs
|
||||
description: "Code Action on rt \"base.t||yp\"/* rang)"
|
||||
description: "Code Action on rt \"base.t||yp\"/* rang"
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/code_action/path_and_equation.typ
|
||||
---
|
||||
|
@ -10,6 +10,7 @@ input_file: crates/tinymist-query/src/fixtures/code_action/path_and_equation.typ
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\"/base.typ\"",
|
||||
"range": "0:9:0:19"
|
||||
}
|
||||
|
@ -24,10 +25,12 @@ input_file: crates/tinymist-query/src/fixtures/code_action/path_and_equation.typ
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": " ",
|
||||
"range": "0:1:0:1"
|
||||
},
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": " ",
|
||||
"range": "0:38:0:38"
|
||||
}
|
||||
|
@ -42,10 +45,12 @@ input_file: crates/tinymist-query/src/fixtures/code_action/path_and_equation.typ
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\n",
|
||||
"range": "0:1:0:1"
|
||||
},
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\n",
|
||||
"range": "0:38:0:38"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/code_action.rs
|
||||
description: "Code Action on rt \"base.t||yp\"/* rang)"
|
||||
description: "Code Action on rt \"base.t||yp\"/* rang"
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/code_action/path_import.typ
|
||||
---
|
||||
|
@ -10,6 +10,7 @@ input_file: crates/tinymist-query/src/fixtures/code_action/path_import.typ
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\"/base.typ\"",
|
||||
"range": "0:8:0:18"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
source: crates/tinymist-query/src/code_action.rs
|
||||
description: "Code Action on n(\"base.js||on\" /* ran)"
|
||||
description: "Code Action on n(\"base.js||on\" /* ran"
|
||||
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
|
||||
input_file: crates/tinymist-query/src/fixtures/code_action/path_json.typ
|
||||
---
|
||||
|
@ -10,6 +10,7 @@ input_file: crates/tinymist-query/src/fixtures/code_action/path_json.typ
|
|||
"changes": {
|
||||
"s1.typ": [
|
||||
{
|
||||
"insertTextFormat": 1,
|
||||
"newText": "\"/base.json\"",
|
||||
"range": "0:6:0:17"
|
||||
}
|
||||
|
|
|
@ -250,7 +250,7 @@ mod tests {
|
|||
.join("\n");
|
||||
|
||||
with_settings!({
|
||||
description => format!("Jump cursor on {})", make_range_annoation(&source)),
|
||||
description => format!("Jump cursor on {})", make_range_annotation(&source)),
|
||||
}, {
|
||||
assert_snapshot!(results);
|
||||
})
|
||||
|
|
|
@ -297,7 +297,7 @@ mod polymorphic {
|
|||
DocumentLink(Option<Vec<DocumentLink>>),
|
||||
DocumentHighlight(Option<Vec<DocumentHighlight>>),
|
||||
ColorPresentation(Option<Vec<ColorPresentation>>),
|
||||
CodeAction(Option<Vec<CodeActionOrCommand>>),
|
||||
CodeAction(Option<Vec<CodeAction>>),
|
||||
CodeLens(Option<Vec<CodeLens>>),
|
||||
Completion(Option<CompletionList>),
|
||||
SignatureHelp(Option<SignatureHelp>),
|
||||
|
|
|
@ -7,14 +7,14 @@ pub use std::sync::{Arc, LazyLock, OnceLock};
|
|||
pub use ecow::{eco_vec, EcoVec};
|
||||
pub use itertools::Itertools;
|
||||
pub use lsp_types::{
|
||||
request::GotoDeclarationResponse, CodeAction, CodeActionKind, CodeActionOrCommand, CodeLens,
|
||||
ColorInformation, ColorPresentation, Diagnostic, DiagnosticRelatedInformation,
|
||||
DiagnosticSeverity, DocumentHighlight, DocumentLink, DocumentSymbol, DocumentSymbolResponse,
|
||||
Documentation, FoldingRange, GotoDefinitionResponse, Hover, HoverContents, InlayHint,
|
||||
Location as LspLocation, LocationLink, MarkedString, MarkupContent, MarkupKind,
|
||||
ParameterInformation, Position as LspPosition, PrepareRenameResponse, SelectionRange,
|
||||
SemanticTokens, SemanticTokensDelta, SemanticTokensFullDeltaResult, SemanticTokensResult,
|
||||
SignatureHelp, SignatureInformation, SymbolInformation, TextEdit, Url, WorkspaceEdit,
|
||||
request::GotoDeclarationResponse, CodeActionKind, CodeLens, ColorInformation,
|
||||
ColorPresentation, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity,
|
||||
DocumentHighlight, DocumentLink, DocumentSymbol, DocumentSymbolResponse, Documentation,
|
||||
FoldingRange, GotoDefinitionResponse, Hover, HoverContents, InlayHint, Location as LspLocation,
|
||||
LocationLink, MarkedString, MarkupContent, MarkupKind, ParameterInformation,
|
||||
Position as LspPosition, PrepareRenameResponse, SelectionRange, SemanticTokens,
|
||||
SemanticTokensDelta, SemanticTokensFullDeltaResult, SemanticTokensResult, SignatureHelp,
|
||||
SignatureInformation, SymbolInformation, TextEdit, Url, WorkspaceEdit,
|
||||
};
|
||||
pub use serde_json::Value as JsonValue;
|
||||
pub use tinymist_project::LspComputeGraph;
|
||||
|
@ -29,6 +29,7 @@ pub use typst::World;
|
|||
pub use typst_shim::syntax::LinkedNodeExt;
|
||||
|
||||
pub use crate::analysis::{Definition, LocalContext};
|
||||
pub use crate::code_action::proto::*;
|
||||
pub use crate::docs::DefDocs;
|
||||
pub use crate::lsp_typst_boundary::{
|
||||
path_to_url, to_lsp_position, to_lsp_range, to_typst_position, to_typst_range, LspRange,
|
||||
|
|
|
@ -141,7 +141,7 @@ mod tests {
|
|||
fn test() {
|
||||
snapshot_testing("signature_help", &|ctx, path| {
|
||||
let source = ctx.source_by_path(&path).unwrap();
|
||||
let (position, anno) = make_pos_annoation(&source);
|
||||
let (position, anno) = make_pos_annotation(&source);
|
||||
|
||||
let request = SignatureHelpRequest { path, position };
|
||||
|
||||
|
|
|
@ -275,7 +275,7 @@ fn match_by_pos(mut n: LinkedNode, prev: bool, ident: bool) -> usize {
|
|||
n.offset()
|
||||
}
|
||||
|
||||
pub fn make_pos_annoation(source: &Source) -> (LspPosition, String) {
|
||||
pub fn make_pos_annotation(source: &Source) -> (LspPosition, String) {
|
||||
let pos = find_test_typst_pos(source);
|
||||
let range_before = pos.saturating_sub(10)..pos;
|
||||
let range_after = pos..pos.saturating_add(10).min(source.text().len());
|
||||
|
@ -287,7 +287,7 @@ pub fn make_pos_annoation(source: &Source) -> (LspPosition, String) {
|
|||
(pos, format!("{window_before}|{window_after}"))
|
||||
}
|
||||
|
||||
pub fn make_range_annoation(source: &Source) -> String {
|
||||
pub fn make_range_annotation(source: &Source) -> String {
|
||||
let range = find_test_range_(source);
|
||||
let range_before = range.start.saturating_sub(10)..range.start;
|
||||
let range_window = range.clone();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue