tinymist_query/
diagnostics.rs1use std::borrow::Cow;
2
3use tinymist_lint::KnownIssues;
4use tinymist_project::LspWorld;
5use tinymist_world::vfs::WorkspaceResolver;
6use typst::syntax::Span;
7
8use crate::{analysis::Analysis, prelude::*};
9
10use regex::RegexSet;
11
12pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;
14
15type TypstDiagnostic = typst::diag::SourceDiagnostic;
16type TypstSeverity = typst::diag::Severity;
17
18pub fn convert_diagnostics<'a>(
21 world: &LspWorld,
22 errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
23 position_encoding: PositionEncoding,
24) -> DiagnosticsMap {
25 let analysis = Analysis {
26 position_encoding,
27 ..Analysis::default()
28 };
29 let mut ctx = analysis.enter(world.clone());
30 DiagWorker::new(&mut ctx).convert_all(errors)
31}
32
33pub(crate) struct DiagWorker<'a> {
35 pub ctx: &'a mut LocalContext,
37 pub source: &'static str,
38 pub results: DiagnosticsMap,
40}
41
42impl<'w> DiagWorker<'w> {
43 pub fn new(ctx: &'w mut LocalContext) -> Self {
45 Self {
46 ctx,
47 source: "typst",
48 results: DiagnosticsMap::default(),
49 }
50 }
51
52 pub fn check(mut self, known_issues: &KnownIssues) -> Self {
54 let source = self.source;
55 self.source = "tinymist-lint";
56 for dep in self.ctx.world.depended_files() {
57 if WorkspaceResolver::is_package_file(dep) {
58 continue;
59 }
60
61 let Ok(source) = self.ctx.world.source(dep) else {
62 continue;
63 };
64
65 for diag in self.ctx.lint(&source, known_issues) {
66 self.handle(&diag);
67 }
68 }
69 self.source = source;
70
71 self
72 }
73
74 pub fn convert_all<'a>(
76 mut self,
77 errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
78 ) -> DiagnosticsMap {
79 for diag in errors {
80 self.handle(diag);
81 }
82
83 self.results
84 }
85
86 pub fn handle(&mut self, diag: &TypstDiagnostic) {
88 match self.convert_diagnostic(diag) {
89 Ok((uri, diagnostic)) => {
90 self.results.entry(uri).or_default().push(diagnostic);
91 }
92 Err(error) => {
93 log::error!("Failed to convert Typst diagnostic: {error:?}");
94 }
95 }
96 }
97
98 fn convert_diagnostic(
99 &self,
100 typst_diagnostic: &TypstDiagnostic,
101 ) -> anyhow::Result<(Url, Diagnostic)> {
102 let typst_diagnostic = {
103 let mut diag = Cow::Borrowed(typst_diagnostic);
104
105 let refiners: &[&dyn DiagnosticRefiner] =
107 &[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
108
109 for refiner in refiners {
111 if refiner.matches(&diag) {
112 diag = Cow::Owned(refiner.refine(diag.into_owned()));
113 }
114 }
115 diag
116 };
117
118 let (id, span) = self.diagnostic_span_id(&typst_diagnostic);
119 let uri = self.ctx.uri_for_id(id)?;
120 let source = self.ctx.source_by_id(id)?;
121 let lsp_range = self.diagnostic_range(&source, span);
122
123 let lsp_severity = diagnostic_severity(typst_diagnostic.severity);
124 let lsp_message = diagnostic_message(&typst_diagnostic);
125
126 let diagnostic = Diagnostic {
127 range: lsp_range,
128 severity: Some(lsp_severity),
129 message: lsp_message,
130 source: Some(self.source.to_owned()),
131 related_information: (!typst_diagnostic.trace.is_empty()).then(|| {
132 typst_diagnostic
133 .trace
134 .iter()
135 .flat_map(|tracepoint| self.to_related_info(tracepoint))
136 .collect()
137 }),
138 ..Default::default()
139 };
140
141 Ok((uri, diagnostic))
142 }
143
144 fn to_related_info(
145 &self,
146 tracepoint: &Spanned<Tracepoint>,
147 ) -> Option<DiagnosticRelatedInformation> {
148 let id = tracepoint.span.id()?;
149 let uri = self.ctx.uri_for_id(id).ok()?;
151 let source = self.ctx.source_by_id(id).ok()?;
152
153 let typst_range = source.range(tracepoint.span)?;
154 let lsp_range = self.ctx.to_lsp_range(typst_range, &source);
155
156 Some(DiagnosticRelatedInformation {
157 location: LspLocation {
158 uri,
159 range: lsp_range,
160 },
161 message: tracepoint.v.to_string(),
162 })
163 }
164
165 fn diagnostic_span_id(&self, typst_diagnostic: &TypstDiagnostic) -> (TypstFileId, Span) {
166 iter::once(typst_diagnostic.span)
167 .chain(typst_diagnostic.trace.iter().map(|trace| trace.span))
168 .find_map(|span| Some((span.id()?, span)))
169 .unwrap_or_else(|| (self.ctx.world.main(), Span::detached()))
170 }
171
172 fn diagnostic_range(&self, source: &Source, typst_span: Span) -> LspRange {
173 match source.find(typst_span) {
180 Some(node) => self.ctx.to_lsp_range(node.range(), source),
181 None => LspRange::new(LspPosition::new(0, 0), LspPosition::new(0, 0)),
182 }
183 }
184}
185
186fn diagnostic_severity(typst_severity: TypstSeverity) -> DiagnosticSeverity {
187 match typst_severity {
188 TypstSeverity::Error => DiagnosticSeverity::ERROR,
189 TypstSeverity::Warning => DiagnosticSeverity::WARNING,
190 }
191}
192
193fn diagnostic_message(typst_diagnostic: &TypstDiagnostic) -> String {
194 let mut message = typst_diagnostic.message.to_string();
195 for hint in &typst_diagnostic.hints {
196 message.push_str("\nHint: ");
197 message.push_str(hint);
198 }
199 message
200}
201
202trait DiagnosticRefiner {
203 fn matches(&self, raw: &TypstDiagnostic) -> bool;
204 fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic;
205}
206
207struct DeprecationRefiner<const MINOR: usize>();
208
209static DEPRECATION_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
210 RegexSet::new([
211 r"unknown variable: style",
212 r"unexpected argument: fill",
213 r"type state has no method `display`",
214 r"only element functions can be used as selectors",
215 ])
216 .expect("Invalid regular expressions")
217});
218
219impl DiagnosticRefiner for DeprecationRefiner<13> {
220 fn matches(&self, raw: &TypstDiagnostic) -> bool {
221 DEPRECATION_PATTERNS.is_match(&raw.message)
222 }
223
224 fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic {
225 raw.with_hint(concat!(
226 r#"Typst 0.13 has introduced breaking changes. Try downgrading "#,
227 r#"Tinymist to v0.12 to use a compatible version of Typst, "#,
228 r#"or consider migrating your code according to "#,
229 r#"[this guide](https://typst.app/blog/2025/typst-0.13/#migrating)."#
230 ))
231 }
232}
233
234struct OutOfRootHintRefiner();
235
236impl DiagnosticRefiner for OutOfRootHintRefiner {
237 fn matches(&self, raw: &TypstDiagnostic) -> bool {
238 raw.message.contains("failed to load file (access denied)")
239 && raw
240 .hints
241 .iter()
242 .any(|hint| hint.contains("cannot read file outside of project root"))
243 }
244
245 fn refine(&self, mut raw: TypstDiagnostic) -> TypstDiagnostic {
246 raw.hints.clear();
247 raw.with_hint("Cannot read file outside of project root.")
248 }
249}