feat: offer quickfix to add spaces separating letters in unknown math var (#2062)
Some checks are pending
tinymist::auto_tag / auto-tag (push) Waiting to run
tinymist::ci / Duplicate Actions Detection (push) Waiting to run
tinymist::ci / Check Clippy, Formatting, Completion, Documentation, and Tests (Linux) (push) Waiting to run
tinymist::ci / Check Minimum Rust version and Tests (Windows) (push) Waiting to run
tinymist::ci / prepare-build (push) Waiting to run
tinymist::ci / announce (push) Blocked by required conditions
tinymist::ci / build (push) Blocked by required conditions
tinymist::gh_pages / build-gh-pages (push) Waiting to run

Examples:

| Input | Suggested fix |
| -- | -- |
| `$xyz$` | `$x y z$` |
| `$a_ij$` | `$a_(i j)$` |
| `$a_(ij)$` | `$a_(i j)$` |
| `$xy/z$` | `$(x y)/z$` |

If the unknown identifier appears as a subscript or as the
numerator/denominator of a fraction, we parenthesize the suggested fix.
For example, `a_ij` turns into `a_(i j)`, not `a_i j`, because the
latter is unlikely to be what the user intended.
This commit is contained in:
Joseph Liu 2025-08-29 13:47:51 -04:00 committed by GitHub
parent 25d5bfa222
commit 4324f8fd70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 405 additions and 7 deletions

View file

@ -99,7 +99,16 @@ impl<'a> CodeActionWorker<'a> {
) -> Option<()> {
let cursor = (range.start + 1).min(self.source.text().len());
let node = root.leaf_at_compat(cursor)?;
self.create_missing_variable(root, &node);
self.add_spaces_to_math_unknown_variable(&node);
Some(())
}
fn create_missing_variable(
&mut self,
root: &LinkedNode<'_>,
node: &LinkedNode<'_>,
) -> Option<()> {
let ident = 'determine_ident: {
if let Some(ident) = node.cast::<ast::Ident>() {
break 'determine_ident ident.get().clone();
@ -117,7 +126,7 @@ impl<'a> CodeActionWorker<'a> {
Bad,
}
let previous_decl = previous_items(node, |item| {
let previous_decl = previous_items(node.clone(), |item| {
match item {
PreviousItem::Parent(parent, ..) => match parent.kind() {
SyntaxKind::LetBinding => {
@ -198,6 +207,34 @@ impl<'a> CodeActionWorker<'a> {
Some(())
}
/// Add spaces between letters in an unknown math identifier: `$xyz$` -> `$x y z$`.
fn add_spaces_to_math_unknown_variable(&mut self, node: &LinkedNode<'_>) -> Option<()> {
let ident = node.cast::<ast::MathIdent>()?.get();
// Rewrite `a_ij` as `a_(i j)`, not `a_i j`.
// Likewise rewrite `ab/c` as `(a b)/c`, not `a b/c`.
let needs_parens = matches!(
node.parent_kind(),
Some(SyntaxKind::MathAttach | SyntaxKind::MathFrac)
);
let new_text = if needs_parens {
eco_format!("({})", ident.chars().join(" "))
} else {
ident.chars().join(" ").into()
};
let range = self.ctx.to_lsp_range(node.range(), &self.source);
let edit = self.local_edit(EcoSnippetTextEdit::new(range, new_text))?;
let action = CodeAction {
title: "Add spaces between letters".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,

View file

@ -14,6 +14,6 @@ impl SemanticRequest for CheckRequest {
fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
let worker = DiagWorker::new(ctx);
Some(worker.check().convert_all(self.snap.diagnostics()))
Some(worker.full_check().convert_all(self.snap.diagnostics()))
}
}

View file

@ -93,18 +93,22 @@ impl SemanticRequest for CodeActionRequest {
#[cfg(test)]
mod tests {
use typst::{diag::Warned, layout::PagedDocument};
use super::*;
use crate::tests::*;
use crate::{DiagWorker, tests::*};
#[test]
fn test() {
snapshot_testing("code_action", &|ctx, path| {
let source = ctx.source_by_path(&path).unwrap();
let request_range = find_test_range(&source);
let code_action_ctx = compute_code_action_context(ctx, &source, &request_range);
let request = CodeActionRequest {
path: path.clone(),
range: find_test_range(&source),
context: CodeActionContext::default(),
range: request_range,
context: code_action_ctx,
};
let result = request.request(ctx);
@ -116,4 +120,37 @@ mod tests {
})
});
}
fn compute_code_action_context(
ctx: &mut LocalContext,
source: &Source,
request_range: &LspRange,
) -> CodeActionContext {
let Warned { output, warnings } = typst::compile::<PagedDocument>(&ctx.world);
let errors = output.err().unwrap_or_default();
let compiler_diagnostics = warnings.iter().chain(errors.iter());
// Run the linter for additional diagnostics as well.
let diagnostics = DiagWorker::new(ctx)
.check(source)
.convert_all(compiler_diagnostics)
.into_values()
.flatten();
CodeActionContext {
// The filtering here matches the LSP specification and VS Code behavior;
// see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionContext:
// `diagnostics`: An array of diagnostics known on the client side overlapping the range
// provided to the textDocument/codeAction request [...]
diagnostics: diagnostics
.filter(|diag| ranges_overlap(&diag.range, request_range))
.collect(),
only: None,
trigger_kind: None,
}
}
fn ranges_overlap(r1: &LspRange, r2: &LspRange) -> bool {
!(r1.end <= r2.start || r2.end <= r1.start)
}
}

View file

@ -46,8 +46,16 @@ impl<'w> DiagWorker<'w> {
}
}
/// Runs code check on the document.
pub fn check(mut self) -> Self {
/// Runs code check on the given document.
pub fn check(mut self, source: &Source) -> Self {
for diag in self.ctx.lint(&source) {
self.handle(&diag);
}
self
}
/// Runs code check on the main document and all its dependencies.
pub fn full_check(mut self) -> Self {
for dep in self.ctx.world.depended_files() {
if WorkspaceResolver::is_package_file(dep) {
continue;

View file

@ -0,0 +1,78 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on $xy||z/* range "
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/unknown_math_ident.typ
---
[
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "\n\nlet xyz",
"range": "0:0:0:0"
}
]
}
},
"kind": "quickfix",
"title": "Create missing variable"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "x y z",
"range": "0:1:0:4"
}
]
}
},
"kind": "quickfix",
"title": "Add spaces between letters"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:4:0:22"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to block equation"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:4:0:22"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to multiple-line block equation"
}
]

View file

@ -0,0 +1,78 @@
---
source: crates/tinymist-query/src/code_action.rs
description: Code Action on $a||b/c/* rang
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/unknown_math_ident_frac.typ
---
[
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "\n\nlet ab",
"range": "0:0:0:0"
}
]
}
},
"kind": "quickfix",
"title": "Create missing variable"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "(a b)",
"range": "0:1:0:3"
}
]
}
},
"kind": "quickfix",
"title": "Add spaces between letters"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:5:0:23"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to block equation"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:5:0:23"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to multiple-line block equation"
}
]

View file

@ -0,0 +1,78 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on $a_i||j/* range "
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/unknown_math_subscript.typ
---
[
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "\n\nlet ij",
"range": "0:0:0:0"
}
]
}
},
"kind": "quickfix",
"title": "Create missing variable"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "(i j)",
"range": "0:3:0:5"
}
]
}
},
"kind": "quickfix",
"title": "Add spaces between letters"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:5:0:23"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to block equation"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:5:0:23"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to multiple-line block equation"
}
]

View file

@ -0,0 +1,78 @@
---
source: crates/tinymist-query/src/code_action.rs
description: Code Action on $a_(i||j)/* range
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/unknown_math_subscript_paren.typ
---
[
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "\n\nlet ij",
"range": "0:0:0:0"
}
]
}
},
"kind": "quickfix",
"title": "Create missing variable"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 2,
"newText": "i j",
"range": "0:4:0:6"
}
]
}
},
"kind": "quickfix",
"title": "Add spaces between letters"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": " ",
"range": "0:7:0:25"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to block equation"
},
{
"edit": {
"changes": {
"s0.typ": [
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:1:0:1"
},
{
"insertTextFormat": 1,
"newText": "\n",
"range": "0:7:0:25"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to multiple-line block equation"
}
]

View file

@ -0,0 +1 @@
$xyz/* range -1..-1 */$

View file

@ -0,0 +1 @@
$ab/c/* range -3..-3 */$

View file

@ -0,0 +1 @@
$a_ij/* range -1..-1 */$

View file

@ -0,0 +1 @@
$a_(ij)/* range -2..-2 */$