tinymist_query/
diagnostics.rs1use 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
11pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;
13
14type TypstDiagnostic = typst::diag::SourceDiagnostic;
15type TypstSeverity = typst::diag::Severity;
16
17pub 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
32pub(crate) struct DiagWorker<'a> {
34 pub ctx: &'a mut LocalContext,
36 pub source: &'static str,
37 pub results: DiagnosticsMap,
39}
40
41impl<'w> DiagWorker<'w> {
42 pub fn new(ctx: &'w mut LocalContext) -> Self {
44 Self {
45 ctx,
46 source: "typst",
47 results: DiagnosticsMap::default(),
48 }
49 }
50
51 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 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 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 let refiners: &[&dyn DiagnosticRefiner] =
106 &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
107
108 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 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 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}