tinymist_query/
diagnostics.rs1use 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
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 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
32pub(crate) struct DiagWorker<'a> {
34 pub ctx: &'a mut LocalContext,
36 pub results: DiagnosticsMap,
38}
39
40impl<'w> DiagWorker<'w> {
41 pub fn new(ctx: &'w mut LocalContext) -> Self {
43 Self {
44 ctx,
45 results: DiagnosticsMap::default(),
46 }
47 }
48
49 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 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 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 let refiners: &[&dyn DiagnosticRefiner] =
101 &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
102
103 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 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 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}