tinymist_query/
diagnostics.rs

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