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