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