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 || 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 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 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 let refiners: &[&dyn DiagnosticRefiner] =
112 &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
113
114 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 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 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}