tinymist_query/
diagnostics.rs

1use std::borrow::Cow;
2
3use tinymist_lint::KnownIssues;
4use tinymist_world::vfs::WorkspaceResolver;
5use typst::syntax::Span;
6
7use crate::{analysis::Analysis, prelude::*};
8
9use regex::RegexSet;
10
11/// Stores diagnostics for files.
12pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;
13
14type TypstDiagnostic = typst::diag::SourceDiagnostic;
15type TypstSeverity = typst::diag::Severity;
16
17/// Collects Tinymist lint diagnostics for the current compilation dependencies.
18pub fn collect_lint_diagnostics<'a>(
19    ctx: &mut LocalContext,
20    compiler_diagnostics: impl IntoIterator<Item = &'a TypstDiagnostic>,
21) -> EcoVec<TypstDiagnostic> {
22    let known_issues = KnownIssues::from_compiler_diagnostics(compiler_diagnostics.into_iter());
23    collect_lint_diagnostics_with_known(ctx, &known_issues)
24}
25
26fn collect_lint_diagnostics_with_known(
27    ctx: &mut LocalContext,
28    known_issues: &KnownIssues,
29) -> EcoVec<TypstDiagnostic> {
30    let mut diagnostics = EcoVec::new();
31    for dep in ctx.world().depended_files() {
32        if WorkspaceResolver::is_package_file(dep)
33            || dep
34                .vpath()
35                .as_rooted_path_compat()
36                .extension()
37                .is_none_or(|e| e != "typ")
38        {
39            continue;
40        }
41
42        let Ok(source) = ctx.world().source(dep) else {
43            continue;
44        };
45
46        diagnostics.extend(ctx.lint(&source, known_issues));
47    }
48
49    diagnostics
50}
51
52/// Converts a list of Typst diagnostics to LSP diagnostics,
53/// with potential refinements on the error messages.
54pub fn convert_diagnostics<'a>(
55    graph: LspComputeGraph,
56    errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
57    position_encoding: PositionEncoding,
58) -> DiagnosticsMap {
59    let analysis = Analysis {
60        position_encoding,
61        ..Analysis::default()
62    };
63    let mut ctx = analysis.enter(graph);
64    DiagWorker::new(&mut ctx).convert_all(errors)
65}
66
67/// The worker for collecting diagnostics.
68pub(crate) struct DiagWorker<'a> {
69    /// The world surface for Typst compiler.
70    pub ctx: &'a mut LocalContext,
71    pub source: &'static str,
72    /// Results
73    pub results: DiagnosticsMap,
74}
75
76impl<'w> DiagWorker<'w> {
77    /// Creates a new `CheckDocWorker` instance.
78    pub fn new(ctx: &'w mut LocalContext) -> Self {
79        Self {
80            ctx,
81            source: "typst",
82            results: DiagnosticsMap::default(),
83        }
84    }
85
86    /// Runs code check on the main document and all its dependencies.
87    pub fn check(mut self, known_issues: &KnownIssues) -> Self {
88        let source = self.source;
89        self.source = "tinymist-lint";
90        for diag in collect_lint_diagnostics_with_known(self.ctx, known_issues) {
91            self.handle(&diag);
92        }
93        self.source = source;
94
95        self
96    }
97
98    /// Converts a list of Typst diagnostics to LSP diagnostics.
99    pub fn convert_all<'a>(
100        mut self,
101        errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
102    ) -> DiagnosticsMap {
103        for diag in errors {
104            self.handle(diag);
105        }
106
107        self.results
108    }
109
110    /// Converts a list of Typst diagnostics to LSP diagnostics.
111    pub fn handle(&mut self, diag: &TypstDiagnostic) {
112        match self.convert_diagnostic(diag) {
113            Ok((uri, diagnostic)) => {
114                self.results.entry(uri).or_default().push(diagnostic);
115            }
116            Err(error) => {
117                log::error!("Failed to convert Typst diagnostic: {error:?}");
118            }
119        }
120    }
121
122    fn convert_diagnostic(
123        &self,
124        typst_diagnostic: &TypstDiagnostic,
125    ) -> anyhow::Result<(Url, Diagnostic)> {
126        let typst_diagnostic = {
127            let mut diag = Cow::Borrowed(typst_diagnostic);
128
129            // Extend more refiners here by adding their instances.
130            let refiners: &[&dyn DiagnosticRefiner] =
131                &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
132
133            // NOTE: It would be nice to have caching here.
134            for refiner in refiners {
135                if refiner.matches(&diag) {
136                    diag = Cow::Owned(refiner.refine(diag.into_owned()));
137                }
138            }
139            diag
140        };
141
142        let (id, span) = self.diagnostic_span_id(&typst_diagnostic);
143        let uri = self.ctx.uri_for_id(id)?;
144        let source = self.ctx.source_by_id(id)?;
145        let lsp_range = self.diagnostic_range(&source, span);
146
147        let lsp_severity = diagnostic_severity(typst_diagnostic.severity);
148        let lsp_message = diagnostic_message(&typst_diagnostic);
149
150        let diagnostic = Diagnostic {
151            range: lsp_range,
152            severity: Some(lsp_severity),
153            message: lsp_message,
154            source: Some(self.source.to_owned()),
155            related_information: (!typst_diagnostic.trace.is_empty()).then(|| {
156                typst_diagnostic
157                    .trace
158                    .iter()
159                    .flat_map(|tracepoint| self.to_related_info(tracepoint))
160                    .collect()
161            }),
162            ..Default::default()
163        };
164
165        Ok((uri, diagnostic))
166    }
167
168    fn to_related_info(
169        &self,
170        tracepoint: &Spanned<Tracepoint>,
171    ) -> Option<DiagnosticRelatedInformation> {
172        let id = tracepoint.span.id()?;
173        // todo: expensive uri_for_id
174        let uri = self.ctx.uri_for_id(id).ok()?;
175        let source = self.ctx.source_by_id(id).ok()?;
176
177        let typst_range = source_range(&source, tracepoint.span)?;
178        let lsp_range = self.ctx.to_lsp_range(typst_range, &source);
179
180        Some(DiagnosticRelatedInformation {
181            location: LspLocation {
182                uri,
183                range: lsp_range,
184            },
185            message: tracepoint.v.to_string(),
186        })
187    }
188
189    fn diagnostic_span_id(&self, typst_diagnostic: &TypstDiagnostic) -> (TypstFileId, DiagSpan) {
190        iter::once(typst_diagnostic.span)
191            .chain(typst_diagnostic.trace.iter().map(|trace| trace.span.into()))
192            .find_map(|span| Some((span.id()?, span)))
193            .unwrap_or_else(|| (self.ctx.world().main(), Span::detached().into()))
194    }
195
196    fn diagnostic_range(&self, source: &Source, typst_span: DiagSpan) -> LspRange {
197        // Due to nvaner/typst-lsp#241 and maybe typst/typst#2035, we sometimes fail to
198        // find the span. In that case, we use a default span as a better
199        // alternative to panicking.
200        //
201        // This may have been fixed after Typst 0.7.0, but it's still nice to avoid
202        // panics in case something similar reappears.
203        match source_range(source, typst_span) {
204            Some(range) => self.ctx.to_lsp_range(range, source),
205            None => LspRange::new(LspPosition::new(0, 0), LspPosition::new(0, 0)),
206        }
207    }
208}
209
210fn diagnostic_severity(typst_severity: TypstSeverity) -> DiagnosticSeverity {
211    match typst_severity {
212        TypstSeverity::Error => DiagnosticSeverity::ERROR,
213        TypstSeverity::Warning => DiagnosticSeverity::WARNING,
214    }
215}
216
217fn diagnostic_message(typst_diagnostic: &TypstDiagnostic) -> String {
218    let mut message = typst_diagnostic.message.to_string();
219    for hint in &typst_diagnostic.hints {
220        message.push_str("\nHint: ");
221        message.push_str(&hint.v);
222    }
223    message
224}
225
226trait DiagnosticRefiner {
227    fn matches(&self, raw: &TypstDiagnostic) -> bool;
228    fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic;
229}
230
231struct DeprecationRefiner<const MINOR: usize>();
232
233static DEPRECATION_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
234    RegexSet::new([
235        r"unknown variable: style",
236        r"unexpected argument: fill",
237        r"type state has no method `display`",
238        r"only element functions can be used as selectors",
239    ])
240    .expect("Invalid regular expressions")
241});
242
243impl DiagnosticRefiner for DeprecationRefiner<13> {
244    fn matches(&self, raw: &TypstDiagnostic) -> bool {
245        DEPRECATION_PATTERNS.is_match(&raw.message)
246    }
247
248    fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic {
249        raw.with_hint(concat!(
250            r#"Typst 0.13 has introduced breaking changes. Try downgrading "#,
251            r#"Tinymist to v0.12 to use a compatible version of Typst, "#,
252            r#"or consider migrating your code according to "#,
253            r#"[this guide](https://typst.app/blog/2025/typst-0.13/#migrating)."#
254        ))
255    }
256}
257
258struct OutOfRootHintRefiner();
259
260impl DiagnosticRefiner for OutOfRootHintRefiner {
261    fn matches(&self, raw: &TypstDiagnostic) -> bool {
262        raw.message.contains("failed to load file (access denied)")
263            && raw
264                .hints
265                .iter()
266                .any(|hint| hint.v.contains("cannot read file outside of project root"))
267    }
268
269    fn refine(&self, mut raw: TypstDiagnostic) -> TypstDiagnostic {
270        raw.hints.clear();
271        raw.with_hint("Cannot read file outside of project root.")
272    }
273}