From 38974a3b5e43837c1982c707465abfb27d233302 Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com> Date: Fri, 9 May 2025 23:44:12 +0800 Subject: [PATCH] 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 --- .../src/analysis/code_action.rs | 342 +++++++++++++++--- .../tinymist-query/src/analysis/completion.rs | 6 +- crates/tinymist-query/src/analysis/global.rs | 8 + .../tinymist-query/src/analysis/link_expr.rs | 6 +- crates/tinymist-query/src/code_action.rs | 16 +- .../tinymist-query/src/code_action/proto.rs | 309 ++++++++++++++++ .../snaps/test@absolute_path_import.typ.snap | 3 +- .../snaps/test@path_and_equation.typ.snap | 7 +- .../snaps/test@path_import.typ.snap | 3 +- .../code_action/snaps/test@path_json.typ.snap | 3 +- crates/tinymist-query/src/jump.rs | 2 +- crates/tinymist-query/src/lib.rs | 2 +- crates/tinymist-query/src/prelude.rs | 17 +- crates/tinymist-query/src/signature_help.rs | 2 +- crates/tinymist-query/src/tests.rs | 4 +- crates/tinymist-vfs/src/path_mapper.rs | 9 + crates/tinymist/src/config.rs | 4 + crates/tinymist/src/lsp/query.rs | 3 +- crates/tinymist/src/project.rs | 1 + editors/vscode/Configuration.md | 1 - editors/vscode/src/extension.shared.ts | 1 + editors/vscode/src/extension.ts | 3 + editors/vscode/src/lsp.code-action.ts | 100 +++++ editors/vscode/src/lsp.ts | 55 +++ editors/vscode/src/util.ts | 10 + .../initialization/vscode-1.87.2.json | 1 + 26 files changed, 834 insertions(+), 84 deletions(-) create mode 100644 crates/tinymist-query/src/code_action/proto.rs create mode 100644 editors/vscode/src/lsp.code-action.ts diff --git a/crates/tinymist-query/src/analysis/code_action.rs b/crates/tinymist-query/src/analysis/code_action.rs index 2ed256fb..c70a6fc2 100644 --- a/crates/tinymist-query/src/analysis/code_action.rs +++ b/crates/tinymist-query/src/analysis/code_action.rs @@ -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, + pub actions: Vec, /// The lazily calculated local URL to [`Self::source`]. local_url: OnceLock>, } @@ -39,20 +44,200 @@ impl<'a> CodeActionWorker<'a> { } #[must_use] - fn local_edits(&self, edits: Vec) -> Option { - Some(WorkspaceEdit { + fn local_edits(&self, edits: Vec) -> Option { + Some(EcoWorkspaceEdit { changes: Some(HashMap::from_iter([(self.local_url()?.clone(), edits)])), ..Default::default() }) } #[must_use] - fn local_edit(&self, edit: TextEdit) -> Option { + fn local_edit(&self, edit: EcoSnippetTextEdit) -> Option { self.local_edits(vec![edit]) } + pub(crate) fn autofix( + &mut self, + root: &LinkedNode<'_>, + range: &Range, + 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, + ) -> 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::() { + break 'determine_ident ident.get().clone(); + } + if let Some(ident) = node.cast::() { + 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, + ) -> Option<()> { + let cursor = (range.start + 1).min(self.source.text().len()); + let node = root.leaf_at_compat(cursor)?; + + let importing = node.cast::()?.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) -> Option<()> { + pub fn scoped(&mut self, root: &LinkedNode, range: &Range) -> 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 { + fn edit_str(&mut self, node: &LinkedNode, new_content: String) -> Option { 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) -> Option<()> { + fn wrap_actions(&mut self, node: &LinkedNode, range: &Range) -> 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 { + 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 } diff --git a/crates/tinymist-query/src/analysis/completion.rs b/crates/tinymist-query/src/analysis/completion.rs index 8b574a63..08c15ce4 100644 --- a/crates/tinymist-query/src/analysis/completion.rs +++ b/crates/tinymist-query/src/analysis/completion.rs @@ -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. `` or ``. + /// Selects a label, e.g. `` or ``. Label(LinkedNode<'a>), - /// Selects a reference, e.g. `@foo|` or `@fo|o`. + /// Selects a reference, e.g. `@foobar|` or `@foo|bar`. Ref(LinkedNode<'a>), } diff --git a/crates/tinymist-query/src/analysis/global.rs b/crates/tinymist-query/src/analysis/global.rs index d1deccf2..78a1e60e 100644 --- a/crates/tinymist-query/src/analysis/global.rs +++ b/crates/tinymist-query/src/analysis/global.rs @@ -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. diff --git a/crates/tinymist-query/src/analysis/link_expr.rs b/crates/tinymist-query/src/analysis/link_expr.rs index 04d6c8f6..20c73746 100644 --- a/crates/tinymist-query/src/analysis/link_expr.rs +++ b/crates/tinymist-query/src/analysis/link_expr.rs @@ -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() } } } diff --git a/crates/tinymist-query/src/code_action.rs b/crates/tinymist-query/src/code_action.rs index 6626cab0..7abfd093 100644 --- a/crates/tinymist-query/src/code_action.rs +++ b/crates/tinymist-query/src/code_action.rs @@ -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; + type Response = Vec; fn request(self, ctx: &mut LocalContext) -> Option { + 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)); }) diff --git a/crates/tinymist-query/src/code_action/proto.rs b/crates/tinymist-query/src/code_action/proto.rs new file mode 100644 index 00000000..06f0929f --- /dev/null +++ b/crates/tinymist-query/src/code_action/proto.rs @@ -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, +} + +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>, +} + +#[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, + + /// The diagnostics that this code action resolves. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + + /// The workspace edit this code action performs. + #[serde(skip_serializing_if = "Option::is_none")] + pub edit: Option, + + /// 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, + + /// 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, + + /// 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, + + /// 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, +} + +/// 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>>, + + /// 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, + + /// 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>, +} + +#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum EcoDocumentChanges { + Edits(Vec), + Operations(Vec), +} + +#[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>, D::Error> + where + D: serde::Deserializer<'de>, + V: de::DeserializeOwned, + { + struct UrlMapVisitor { + _marker: PhantomData, + } + + impl Default for UrlMapVisitor { + fn default() -> Self { + UrlMapVisitor { + _marker: PhantomData, + } + } + } + impl<'de, V: de::DeserializeOwned> de::Visitor<'de> for UrlMapVisitor { + type Value = HashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("map") + } + + fn visit_map(self, mut visitor: M) -> Result + 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::()? { + values.insert(key, value); + } + + Ok(values) + } + } + + struct OptionUrlMapVisitor { + _marker: PhantomData, + } + impl Default for OptionUrlMapVisitor { + fn default() -> Self { + OptionUrlMapVisitor { + _marker: PhantomData, + } + } + } + impl<'de, V: de::DeserializeOwned> de::Visitor<'de> for OptionUrlMapVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("option") + } + + #[inline] + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + #[inline] + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + #[inline] + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer + .deserialize_map(UrlMapVisitor::::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( + changes: &Option>, + serializer: S, + ) -> Result + 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(), + } + } +} diff --git a/crates/tinymist-query/src/fixtures/code_action/snaps/test@absolute_path_import.typ.snap b/crates/tinymist-query/src/fixtures/code_action/snaps/test@absolute_path_import.typ.snap index 6c204430..e2dc0925 100644 --- a/crates/tinymist-query/src/fixtures/code_action/snaps/test@absolute_path_import.typ.snap +++ b/crates/tinymist-query/src/fixtures/code_action/snaps/test@absolute_path_import.typ.snap @@ -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" } diff --git a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_and_equation.typ.snap b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_and_equation.typ.snap index f4e0d963..72993361 100644 --- a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_and_equation.typ.snap +++ b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_and_equation.typ.snap @@ -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" } diff --git a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_import.typ.snap b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_import.typ.snap index 7f12cb6c..2787d8d2 100644 --- a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_import.typ.snap +++ b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_import.typ.snap @@ -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" } diff --git a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_json.typ.snap b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_json.typ.snap index e3ab7fa1..875bbd6a 100644 --- a/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_json.typ.snap +++ b/crates/tinymist-query/src/fixtures/code_action/snaps/test@path_json.typ.snap @@ -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" } diff --git a/crates/tinymist-query/src/jump.rs b/crates/tinymist-query/src/jump.rs index 22213132..534930dd 100644 --- a/crates/tinymist-query/src/jump.rs +++ b/crates/tinymist-query/src/jump.rs @@ -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); }) diff --git a/crates/tinymist-query/src/lib.rs b/crates/tinymist-query/src/lib.rs index d8a6debe..dde3c5ac 100644 --- a/crates/tinymist-query/src/lib.rs +++ b/crates/tinymist-query/src/lib.rs @@ -297,7 +297,7 @@ mod polymorphic { DocumentLink(Option>), DocumentHighlight(Option>), ColorPresentation(Option>), - CodeAction(Option>), + CodeAction(Option>), CodeLens(Option>), Completion(Option), SignatureHelp(Option), diff --git a/crates/tinymist-query/src/prelude.rs b/crates/tinymist-query/src/prelude.rs index fe3cd8f1..f9fea5f4 100644 --- a/crates/tinymist-query/src/prelude.rs +++ b/crates/tinymist-query/src/prelude.rs @@ -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, diff --git a/crates/tinymist-query/src/signature_help.rs b/crates/tinymist-query/src/signature_help.rs index ebed07c6..fd253550 100644 --- a/crates/tinymist-query/src/signature_help.rs +++ b/crates/tinymist-query/src/signature_help.rs @@ -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 }; diff --git a/crates/tinymist-query/src/tests.rs b/crates/tinymist-query/src/tests.rs index c2eb64c1..35507ae8 100644 --- a/crates/tinymist-query/src/tests.rs +++ b/crates/tinymist-query/src/tests.rs @@ -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(); diff --git a/crates/tinymist-vfs/src/path_mapper.rs b/crates/tinymist-vfs/src/path_mapper.rs index 908ccfb6..e4015692 100644 --- a/crates/tinymist-vfs/src/path_mapper.rs +++ b/crates/tinymist-vfs/src/path_mapper.rs @@ -48,6 +48,15 @@ impl PathResolution { } } } + + pub fn resolve_to(&self, path: &VirtualPath) -> Option { + match self { + PathResolution::Resolved(root) => Some(PathResolution::Resolved(path.resolve(root)?)), + PathResolution::Rootless(root) => Some(PathResolution::Rootless(Cow::Owned( + VirtualPath::new(path.resolve(root.as_ref().as_rooted_path())?), + ))), + } + } } pub trait RootResolver { diff --git a/crates/tinymist/src/config.rs b/crates/tinymist/src/config.rs index 31fe9d10..8c5133df 100644 --- a/crates/tinymist/src/config.rs +++ b/crates/tinymist/src/config.rs @@ -72,6 +72,9 @@ pub struct Config { pub notify_status: bool, /// Whether to remove HTML from markup content in responses. pub support_html_in_markdown: bool, + /// Whether to utilize the extended `tinymist.resolveCodeAction` at client + /// side. + pub extended_code_action: bool, /// The preferred color theme for rendering. pub color_theme: Option, @@ -323,6 +326,7 @@ impl Config { assign_config!(lint := "lint"?: LintFeat); assign_config!(semantic_tokens := "semanticTokens"?: SemanticTokensMode); assign_config!(support_html_in_markdown := "supportHtmlInMarkdown"?: bool); + assign_config!(extended_code_action := "supportExtendedCodeAction"?: bool); assign_config!(system_fonts := "systemFonts"?: Option); self.notify_status = match try_(|| update.get("compileStatus")?.as_str()) { diff --git a/crates/tinymist/src/lsp/query.rs b/crates/tinymist/src/lsp/query.rs index cbe685b9..8100ba3f 100644 --- a/crates/tinymist/src/lsp/query.rs +++ b/crates/tinymist/src/lsp/query.rs @@ -207,7 +207,8 @@ impl ServerState { ) -> ScheduledResult { let path = as_path(params.text_document); let range = params.range; - run_query!(req_id, self.CodeAction(path, range)) + let context = params.context; + run_query!(req_id, self.CodeAction(path, range, context)) } pub(crate) fn code_lens( diff --git a/crates/tinymist/src/project.rs b/crates/tinymist/src/project.rs index 8218a101..39fc2009 100644 --- a/crates/tinymist/src/project.rs +++ b/crates/tinymist/src/project.rs @@ -156,6 +156,7 @@ impl ServerState { allow_overlapping_token: const_config.tokens_overlapping_token_support, allow_multiline_token: const_config.tokens_multiline_token_support, remove_html: !config.support_html_in_markdown, + extended_code_action: config.extended_code_action, completion_feat: config.completion.clone(), color_theme: match config.color_theme.as_deref() { Some("dark") => tinymist_query::ColorTheme::Dark, diff --git a/editors/vscode/Configuration.md b/editors/vscode/Configuration.md index 0ff8c51b..5cf98bce 100644 --- a/editors/vscode/Configuration.md +++ b/editors/vscode/Configuration.md @@ -69,7 +69,6 @@ Enable or disable semantic tokens (LSP syntax highlighting) Enable or disable lint checks. Note: restarting the editor is required to change this setting. - **Type**: `boolean` -- **Default**: `true` ## `tinymist.lint.when` diff --git a/editors/vscode/src/extension.shared.ts b/editors/vscode/src/extension.shared.ts index 0059edcc..a8d03909 100644 --- a/editors/vscode/src/extension.shared.ts +++ b/editors/vscode/src/extension.shared.ts @@ -40,6 +40,7 @@ function configureEditorAndLanguage(context: ExtensionContext, trait: TinymistTr config.triggerSuggestAndParameterHints = true; config.triggerParameterHints = true; config.supportHtmlInMarkdown = true; + config.supportExtendedCodeAction = true; config.customizedShowDocument = true; // Sets shared features extensionState.features.preview = !isWeb && config.previewFeature === "enable"; diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index f8dccd8c..5ba5f64e 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -34,6 +34,7 @@ import { testingActivate } from "./features/testing"; import { testingDebugActivate } from "./features/testing/debug"; import { FeatureEntry, tinymistActivate, tinymistDeactivate } from "./extension.shared"; import { commandShow, exportActivate, quickExports } from "./features/export"; +import { resolveCodeAction } from "./lsp.code-action"; LanguageState.Client = LanguageClient; @@ -224,6 +225,8 @@ async function languageActivate(context: IContext) { commands.registerCommand("tinymist.createLocalPackage", commandCreateLocalPackage), commands.registerCommand("tinymist.openLocalPackage", commandOpenLocalPackage), + // similar to `rust-analyzer.resolveCodeAction` from https://github.com/rust-lang/rust-analyzer + commands.registerCommand("tinymist.resolveCodeAction", resolveCodeAction()), // We would like to define it at the server side, but it is not possible for now. // https://github.com/microsoft/language-server-protocol/issues/1117 commands.registerCommand("tinymist.triggerSuggestAndParameterHints", triggerSuggestAndParameterHints), diff --git a/editors/vscode/src/lsp.code-action.ts b/editors/vscode/src/lsp.code-action.ts new file mode 100644 index 00000000..ca46799a --- /dev/null +++ b/editors/vscode/src/lsp.code-action.ts @@ -0,0 +1,100 @@ +import * as vscode from "vscode"; +import * as lc from "vscode-languageclient"; +import { applySnippetWorkspaceEdit, SnippetTextDocumentEdit } from "./snippets"; +import { tinymist } from "./lsp"; +import type { LanguageClient } from "vscode-languageclient/node"; + +export function resolveCodeAction(): any { + return async (params: lc.CodeAction) => { + console.log("triggered resolveCodeAction", params); + // tinymist doesn't have resolve action. + // const client = ctx.client; + // params.command = undefined; + // const item = await client.sendRequest(lc.CodeActionResolveRequest.type, params); + // const itemEdit = item.edit; + + const client = await tinymist.getClient(); + + const itemEdit = params.edit; + if (!itemEdit) { + return; + } + + // console.log("itemEdit", itemEdit); + + if (itemEdit.changes) { + itemEdit.documentChanges ||= []; + for (const [uri, edits] of Object.entries(itemEdit.changes)) { + itemEdit.documentChanges.push( + lc.TextDocumentEdit.create(lc.VersionedTextDocumentIdentifier.create(uri, 0), edits), + ); + } + itemEdit.changes = undefined; + } + + console.log("itemEdit merged", itemEdit); + + // filter out all text edits and recreate the WorkspaceEdit without them so we can apply + // snippet edits on our own + const lcFileSystemEdit = { + ...itemEdit, + documentChanges: itemEdit.documentChanges?.filter((change) => "kind" in change), + }; + const fileSystemEdit = await client.protocol2CodeConverter.asWorkspaceEdit(lcFileSystemEdit); + await vscode.workspace.applyEdit(fileSystemEdit); + + // replace all text edits so that we can convert snippet text edits into `vscode.SnippetTextEdit`s + // FIXME: this is a workaround until vscode-languageclient supports doing the SnippeTextEdit conversion itself + // also need to carry the snippetTextDocumentEdits separately, since we can't retrieve them again using WorkspaceEdit.entries + const [workspaceTextEdit, snippetTextDocumentEdits] = asWorkspaceSnippetEdit(client, itemEdit); + console.log("applying snippet workspace edit", workspaceTextEdit, snippetTextDocumentEdits); + await applySnippetWorkspaceEdit(workspaceTextEdit, snippetTextDocumentEdits); + if (params.command != null) { + await vscode.commands.executeCommand(params.command.command, params.command.arguments); + } + }; +} +function asWorkspaceSnippetEdit( + client: LanguageClient, + item: lc.WorkspaceEdit, +): [vscode.WorkspaceEdit, SnippetTextDocumentEdit[]] { + // partially borrowed from https://github.com/microsoft/vscode-languageserver-node/blob/295aaa393fda8ecce110c38880a00466b9320e63/client/src/common/protocolConverter.ts#L1060-L1101 + const result = new vscode.WorkspaceEdit(); + + if (item.documentChanges) { + const snippetTextDocumentEdits: SnippetTextDocumentEdit[] = []; + + for (const change of item.documentChanges) { + if (lc.TextDocumentEdit.is(change)) { + const uri = client.protocol2CodeConverter.asUri(change.textDocument.uri); + const snippetTextEdits: (vscode.TextEdit | vscode.SnippetTextEdit)[] = []; + + for (const edit of change.edits) { + if ("insertTextFormat" in edit && edit.insertTextFormat === lc.InsertTextFormat.Snippet) { + // is a snippet text edit + snippetTextEdits.push( + new vscode.SnippetTextEdit( + client.protocol2CodeConverter.asRange(edit.range), + new vscode.SnippetString(edit.newText), + ), + ); + } else { + // always as a text document edit + snippetTextEdits.push( + vscode.TextEdit.replace( + client.protocol2CodeConverter.asRange(edit.range), + edit.newText, + ), + ); + } + } + + snippetTextDocumentEdits.push([uri, snippetTextEdits]); + } + } + return [result, snippetTextDocumentEdits]; + } else { + // we don't handle WorkspaceEdit.changes since it's not relevant for code actions + return [result, []]; + } +} diff --git a/editors/vscode/src/lsp.ts b/editors/vscode/src/lsp.ts index f5877abf..ccba9fda 100644 --- a/editors/vscode/src/lsp.ts +++ b/editors/vscode/src/lsp.ts @@ -2,6 +2,8 @@ import { spawnSync } from "child_process"; import { resolve } from "path"; import * as vscode from "vscode"; +import * as lc from "vscode-languageclient"; +import * as Is from "vscode-languageclient/lib/common/utils/is"; import { ExtensionMode } from "vscode"; import type { LanguageClient, @@ -238,6 +240,46 @@ export class LanguageState { await hoverHandler.finish(); return hover; }, + // Using custom handling of CodeActions to support action groups and snippet edits. + // Note that this means we have to re-implement lazy edit resolving ourselves as well. + async provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range, + context: vscode.CodeActionContext, + token: vscode.CancellationToken, + _next: lc.ProvideCodeActionsSignature, + ) { + const params: lc.CodeActionParams = { + textDocument: client.code2ProtocolConverter.asTextDocumentIdentifier(document), + range: client.code2ProtocolConverter.asRange(range), + context: await client.code2ProtocolConverter.asCodeActionContext(context, token), + }; + const callback = async ( + values: (lc.Command | lc.CodeAction)[] | null, + ): Promise<(vscode.Command | vscode.CodeAction)[] | undefined> => { + if (values === null) return undefined; + const result: (vscode.CodeAction | vscode.Command)[] = []; + for (const item of values) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kind = client.protocol2CodeConverter.asCodeActionKind((item as any).kind); + const action = new vscode.CodeAction(item.title, kind); + action.command = { + command: "tinymist.resolveCodeAction", + title: item.title, + arguments: [item], + }; + // console.log("replace", action, "=>", action); + + // Set a dummy edit, so that VS Code doesn't try to resolve this. + action.edit = new vscode.WorkspaceEdit(); + result.push(action); + } + return result; + }; + return client + .sendRequest(lc.CodeActionRequest.type, params, token) + .then(callback, (_error) => undefined); + }, }, }; @@ -598,3 +640,16 @@ export interface SymbolInfo { kind: string; children: SymbolInfo[]; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isCodeActionWithoutEditsAndCommands(value: any): boolean { + const candidate: lc.CodeAction = value; + return ( + candidate && + Is.string(candidate.title) && + (candidate.diagnostics === void 0 || Is.typedArray(candidate.diagnostics, lc.Diagnostic.is)) && + (candidate.kind === void 0 || Is.string(candidate.kind)) && + candidate.edit === void 0 && + candidate.command === void 0 + ); +} diff --git a/editors/vscode/src/util.ts b/editors/vscode/src/util.ts index 2df80b85..f6523a59 100644 --- a/editors/vscode/src/util.ts +++ b/editors/vscode/src/util.ts @@ -3,12 +3,22 @@ import * as path from "path"; import { ViewColumn } from "vscode"; import { readFile } from "fs/promises"; import { isGitpod, translateGitpodURL } from "./gitpod"; +import { strict as nativeAssert } from "assert"; export const typstDocumentSelector = [ { scheme: "file", language: "typst" }, { scheme: "untitled", language: "typst" }, ]; +export function assert(condition: boolean, explanation: string): asserts condition { + try { + nativeAssert(condition, explanation); + } catch (err) { + console.error(`Assertion failed:`, explanation); + throw err; + } +} + const bytes2utf8 = new TextDecoder("utf-8"); const utf82bytes = new TextEncoder(); diff --git a/tests/fixtures/initialization/vscode-1.87.2.json b/tests/fixtures/initialization/vscode-1.87.2.json index 9f8d3db7..107b4574 100644 --- a/tests/fixtures/initialization/vscode-1.87.2.json +++ b/tests/fixtures/initialization/vscode-1.87.2.json @@ -276,6 +276,7 @@ "triggerParameterHints": true, "triggerSuggestAndParameterHints": true, "supportHtmlInMarkdown": true, + "supportExtendedCodeAction": true, "customizedShowDocument": true, "experimentalFormatterMode": "disable" },