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