tinymist_query/
diagnostics.rs

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