tinymist_query/
diagnostics.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
use std::borrow::Cow;

use tinymist_project::LspWorld;
use tinymist_world::vfs::WorkspaceResolver;
use typst::syntax::Span;

use crate::{analysis::Analysis, prelude::*, LspWorldExt};

use regex::RegexSet;

/// Stores diagnostics for files.
pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;

type TypstDiagnostic = typst::diag::SourceDiagnostic;
type TypstSeverity = typst::diag::Severity;

/// Converts a list of Typst diagnostics to LSP diagnostics,
/// with potential refinements on the error messages.
pub fn convert_diagnostics<'a>(
    world: &LspWorld,
    errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
    position_encoding: PositionEncoding,
) -> DiagnosticsMap {
    let analysis = Analysis {
        position_encoding,
        ..Analysis::default()
    };
    let mut ctx = analysis.enter(world.clone());
    DiagWorker::new(&mut ctx).convert_all(errors)
}

/// The worker for collecting diagnostics.
pub(crate) struct DiagWorker<'a> {
    /// The world surface for Typst compiler.
    pub ctx: &'a mut LocalContext,
    /// Results
    pub results: DiagnosticsMap,
}

impl<'w> DiagWorker<'w> {
    /// Creates a new `CheckDocWorker` instance.
    pub fn new(ctx: &'w mut LocalContext) -> Self {
        Self {
            ctx,
            results: DiagnosticsMap::default(),
        }
    }

    /// Runs code check on the document.
    pub fn check(mut self) -> Self {
        for dep in self.ctx.world.depended_files() {
            if WorkspaceResolver::is_package_file(dep) {
                continue;
            }

            let Ok(source) = self.ctx.world.source(dep) else {
                continue;
            };

            for diag in self.ctx.lint(&source) {
                self.handle(&diag);
            }
        }

        self
    }

    /// Converts a list of Typst diagnostics to LSP diagnostics.
    pub fn convert_all<'a>(
        mut self,
        errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
    ) -> DiagnosticsMap {
        for diag in errors {
            self.handle(diag);
        }

        self.results
    }

    /// Converts a list of Typst diagnostics to LSP diagnostics.
    pub fn handle(&mut self, diag: &TypstDiagnostic) {
        match self.convert_diagnostic(diag) {
            Ok((uri, diagnostic)) => {
                self.results.entry(uri).or_default().push(diagnostic);
            }
            Err(error) => {
                log::error!("Failed to convert Typst diagnostic: {error:?}");
            }
        }
    }

    fn convert_diagnostic(
        &self,
        typst_diagnostic: &TypstDiagnostic,
    ) -> anyhow::Result<(Url, Diagnostic)> {
        let typst_diagnostic = {
            let mut diag = Cow::Borrowed(typst_diagnostic);

            // Extend more refiners here by adding their instances.
            let refiners: &[&dyn DiagnosticRefiner] =
                &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];

            // NOTE: It would be nice to have caching here.
            for refiner in refiners {
                if refiner.matches(&diag) {
                    diag = Cow::Owned(refiner.refine(diag.into_owned()));
                }
            }
            diag
        };

        let (id, span) = self.diagnostic_span_id(&typst_diagnostic);
        let uri = self.ctx.uri_for_id(id)?;
        let source = self.ctx.source_by_id(id)?;
        let lsp_range = self.diagnostic_range(&source, span);

        let lsp_severity = diagnostic_severity(typst_diagnostic.severity);
        let lsp_message = diagnostic_message(&typst_diagnostic);

        let diagnostic = Diagnostic {
            range: lsp_range,
            severity: Some(lsp_severity),
            message: lsp_message,
            source: Some("typst".to_owned()),
            related_information: (!typst_diagnostic.trace.is_empty()).then(|| {
                typst_diagnostic
                    .trace
                    .iter()
                    .flat_map(|tracepoint| self.to_related_info(tracepoint))
                    .collect()
            }),
            ..Default::default()
        };

        Ok((uri, diagnostic))
    }

    fn to_related_info(
        &self,
        tracepoint: &Spanned<Tracepoint>,
    ) -> Option<DiagnosticRelatedInformation> {
        let id = tracepoint.span.id()?;
        // todo: expensive uri_for_id
        let uri = self.ctx.uri_for_id(id).ok()?;
        let source = self.ctx.source_by_id(id).ok()?;

        let typst_range = source.range(tracepoint.span)?;
        let lsp_range = self.ctx.to_lsp_range(typst_range, &source);

        Some(DiagnosticRelatedInformation {
            location: LspLocation {
                uri,
                range: lsp_range,
            },
            message: tracepoint.v.to_string(),
        })
    }

    fn diagnostic_span_id(&self, typst_diagnostic: &TypstDiagnostic) -> (TypstFileId, Span) {
        iter::once(typst_diagnostic.span)
            .chain(typst_diagnostic.trace.iter().map(|trace| trace.span))
            .find_map(|span| Some((span.id()?, span)))
            .unwrap_or_else(|| (self.ctx.world.main(), Span::detached()))
    }

    fn diagnostic_range(&self, source: &Source, typst_span: Span) -> LspRange {
        // Due to nvaner/typst-lsp#241 and maybe typst/typst#2035, we sometimes fail to
        // find the span. In that case, we use a default span as a better
        // alternative to panicking.
        //
        // This may have been fixed after Typst 0.7.0, but it's still nice to avoid
        // panics in case something similar reappears.
        match source.find(typst_span) {
            Some(node) => self.ctx.to_lsp_range(node.range(), source),
            None => LspRange::new(LspPosition::new(0, 0), LspPosition::new(0, 0)),
        }
    }
}

fn diagnostic_severity(typst_severity: TypstSeverity) -> DiagnosticSeverity {
    match typst_severity {
        TypstSeverity::Error => DiagnosticSeverity::ERROR,
        TypstSeverity::Warning => DiagnosticSeverity::WARNING,
    }
}

fn diagnostic_message(typst_diagnostic: &TypstDiagnostic) -> String {
    let mut message = typst_diagnostic.message.to_string();
    for hint in &typst_diagnostic.hints {
        message.push_str("\nHint: ");
        message.push_str(hint);
    }
    message
}

trait DiagnosticRefiner {
    fn matches(&self, raw: &TypstDiagnostic) -> bool;
    fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic;
}

struct DeprecationRefiner<const MINOR: usize>();

static DEPRECATION_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
    RegexSet::new([
        r"unknown variable: style",
        r"unexpected argument: fill",
        r"type state has no method `display`",
        r"only element functions can be used as selectors",
    ])
    .expect("Invalid regular expressions")
});

impl DiagnosticRefiner for DeprecationRefiner<13> {
    fn matches(&self, raw: &TypstDiagnostic) -> bool {
        DEPRECATION_PATTERNS.is_match(&raw.message)
    }

    fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic {
        raw.with_hint(concat!(
            r#"Typst 0.13 has introduced breaking changes. Try downgrading "#,
            r#"Tinymist to v0.12 to use a compatible version of Typst, "#,
            r#"or consider migrating your code according to "#,
            r#"[this guide](https://typst.app/blog/2025/typst-0.13/#migrating)."#
        ))
    }
}

struct OutOfRootHintRefiner();

impl DiagnosticRefiner for OutOfRootHintRefiner {
    fn matches(&self, raw: &TypstDiagnostic) -> bool {
        raw.message.contains("failed to load file (access denied)")
            && raw
                .hints
                .iter()
                .any(|hint| hint.contains("cannot read file outside of project root"))
    }

    fn refine(&self, mut raw: TypstDiagnostic) -> TypstDiagnostic {
        raw.hints.clear();
        raw.with_hint("Cannot read file outside of project root.")
    }
}