feat: new lint warning for unknown math vars (#2065)
Some checks failed
tinymist::auto_tag / auto-tag (push) Has been cancelled
tinymist::ci / Duplicate Actions Detection (push) Has been cancelled
tinymist::ci / Check Clippy, Formatting, Completion, Documentation, and Tests (Linux) (push) Has been cancelled
tinymist::ci / Check Minimum Rust version and Tests (Windows) (push) Has been cancelled
tinymist::ci / prepare-build (push) Has been cancelled
tinymist::gh_pages / build-gh-pages (push) Has been cancelled
tinymist::ci / announce (push) Has been cancelled
tinymist::ci / build (push) Has been cancelled

Add a lint warning for unknown math variables[^1], powered by Tinymist's
existing reference analysis. This allows users to see all such undefined
variables at once if they have the linter enabled, instead of just the
first error from the compiler. The autofix from #2062 is also applicable
to these warnings.

Example:
![example of new warning in VS
Code](https://github.com/user-attachments/assets/42672d50-e637-49b2-907b-aa413431e55e)
(The first error is pre-existing and emitted by the Typst compiler; the
second warning is new from this PR and emitted by us.)

Implementation notes:
- The generated diagnostic tries to closely match the corresponding
Typst compiler error, with one deliberate change: to differentiate the
Tinymist warning and the compiler error, we use the message `potentially
unknown variable: ...` instead of `unknown variable: ...`. I am not the
biggest fan of this choice, but I think it is very important that users
don't blame the Typst compiler for warnings that we generate; changing
the message so that it isn't an exact clone is the best way I thought of
for now.
- We avoid duplicating a warning if the compiler has already generated
an error for a given identifier by computing a set of `KnownLintIssues`
from the compiler diagnostics and threading this information through the
lint routines.

[^1]: If users like this warning, we can extend it later to apply to
undefined variables outside of math as well.

---------

Co-authored-by: Myriad-Dreamin <camiyoru@gmail.com>
This commit is contained in:
Joseph Liu 2025-09-23 13:09:10 -04:00 committed by GitHub
parent 3b6de80a2f
commit 6b52aa70ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 148 additions and 29 deletions

View file

@ -610,6 +610,8 @@ mod call_info_tests {
mod lint_tests {
use std::collections::BTreeMap;
use tinymist_lint::KnownIssues;
use crate::tests::*;
#[test]
@ -617,7 +619,7 @@ mod lint_tests {
snapshot_testing("lint", &|ctx, path| {
let source = ctx.source_by_path(&path).unwrap();
let result = ctx.lint(&source);
let result = ctx.lint(&source, &KnownIssues::default());
let result = crate::diagnostics::DiagWorker::new(ctx).convert_all(result.iter());
let result = result
.into_iter()

View file

@ -206,7 +206,8 @@ impl<'a> CodeActionWorker<'a> {
Some(())
}
/// Add spaces between letters in an unknown math identifier: `$xyz$` -> `$x y z$`.
/// 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();
@ -613,7 +614,7 @@ enum AutofixKind {
fn match_autofix_kind(msg: &str) -> Option<AutofixKind> {
static PATTERNS: &[(&str, AutofixKind)] = &[
("unknown variable", AutofixKind::UnknownVariable),
("unknown variable", AutofixKind::UnknownVariable), // typst compiler error
("file not found", AutofixKind::FileNotFound),
];

View file

@ -13,7 +13,7 @@ use tinymist_analysis::stats::AllocStats;
use tinymist_analysis::syntax::classify_def_loosely;
use tinymist_analysis::ty::term_value;
use tinymist_analysis::{analyze_expr_, analyze_import_};
use tinymist_lint::LintInfo;
use tinymist_lint::{KnownIssues, LintInfo};
use tinymist_project::{LspComputeGraph, LspWorld, TaskWhen};
use tinymist_std::hash::{FxDashMap, hash128};
use tinymist_std::typst::TypstDocument;
@ -475,8 +475,12 @@ impl LocalContext {
cache.get_or_init(|| self.shared.type_check(source)).clone()
}
pub(crate) fn lint(&mut self, source: &Source) -> EcoVec<SourceDiagnostic> {
self.shared.lint(source).diagnostics
pub(crate) fn lint(
&mut self,
source: &Source,
known_issues: &KnownIssues,
) -> EcoVec<SourceDiagnostic> {
self.shared.lint(source, known_issues).diagnostics
}
/// Get the type check information of a source file.
@ -794,13 +798,13 @@ impl SharedContext {
/// Get the lint result of a source file.
#[typst_macros::time(span = source.root().span())]
pub(crate) fn lint(self: &Arc<Self>, source: &Source) -> LintInfo {
pub(crate) fn lint(self: &Arc<Self>, source: &Source, issues: &KnownIssues) -> LintInfo {
let ei = self.expr_stage(source);
let ti = self.type_check(source);
let guard = self.query_stat(source.id(), "lint");
self.slot.lint.compute(hash128(&(&ei, &ti)), |_prev| {
self.slot.lint.compute(hash128(&(&ei, &ti, issues)), |_| {
guard.miss();
tinymist_lint::lint_file(&self.world, &ei, ti)
tinymist_lint::lint_file(&self.world, &ei, ti, issues.clone())
})
}

View file

@ -1,3 +1,4 @@
use tinymist_lint::KnownIssues;
use tinymist_project::LspCompiledArtifact;
use crate::{DiagWorker, DiagnosticsMap, SemanticRequest, prelude::*};
@ -14,6 +15,9 @@ 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()))
let compiler_diags = self.snap.diagnostics();
let known_issues = KnownIssues::from_compiler_diagnostics(compiler_diags.clone());
Some(worker.check(&known_issues).convert_all(compiler_diags))
}
}

View file

@ -93,6 +93,7 @@ impl SemanticRequest for CodeActionRequest {
#[cfg(test)]
mod tests {
use tinymist_lint::KnownIssues;
use typst::{diag::Warned, layout::PagedDocument};
use super::*;
@ -131,18 +132,15 @@ mod tests {
warnings: compiler_warnings,
} = typst::compile::<PagedDocument>(&ctx.world);
let compiler_errors = output.err().unwrap_or_default();
let compiler_diags = compiler_warnings.iter().chain(compiler_errors.iter());
let known_issues = KnownIssues::from_compiler_diagnostics(compiler_diags.clone());
let lint_warnings = ctx.lint(source, &known_issues);
let lint_warnings = ctx.lint(source);
let diagnostics = DiagWorker::new(ctx)
.convert_all(
compiler_errors
.iter()
.chain(compiler_warnings.iter())
.chain(lint_warnings.iter()),
)
.convert_all(compiler_diags.chain(lint_warnings.iter()))
.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:

View file

@ -1,5 +1,6 @@
use std::borrow::Cow;
use tinymist_lint::KnownIssues;
use tinymist_project::LspWorld;
use tinymist_world::vfs::WorkspaceResolver;
use typst::syntax::Span;
@ -33,6 +34,7 @@ pub fn convert_diagnostics<'a>(
pub(crate) struct DiagWorker<'a> {
/// The world surface for Typst compiler.
pub ctx: &'a mut LocalContext,
pub source: &'static str,
/// Results
pub results: DiagnosticsMap,
}
@ -42,12 +44,15 @@ impl<'w> DiagWorker<'w> {
pub fn new(ctx: &'w mut LocalContext) -> Self {
Self {
ctx,
source: "typst",
results: DiagnosticsMap::default(),
}
}
/// Runs code check on the main document and all its dependencies.
pub fn check(mut self) -> Self {
pub fn check(mut self, known_issues: &KnownIssues) -> Self {
let source = self.source;
self.source = "tinymist-lint";
for dep in self.ctx.world.depended_files() {
if WorkspaceResolver::is_package_file(dep) {
continue;
@ -57,10 +62,11 @@ impl<'w> DiagWorker<'w> {
continue;
};
for diag in self.ctx.lint(&source) {
for diag in self.ctx.lint(&source, known_issues) {
self.handle(&diag);
}
}
self.source = source;
self
}
@ -121,7 +127,7 @@ impl<'w> DiagWorker<'w> {
range: lsp_range,
severity: Some(lsp_severity),
message: lsp_message,
source: Some("typst".to_owned()),
source: Some(self.source.to_owned()),
related_information: (!typst_diagnostic.trace.is_empty()).then(|| {
typst_diagnostic
.trace