mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-11-22 12:34:39 +00:00
Some checks failed
tinymist::auto_tag / auto-tag (push) Has been cancelled
tinymist::ci / Duplicate Actions Detection (push) Has been cancelled
tinymist::ci / Check Clippy, Formatting, Completion, Documentation, and Tests (Linux) (push) Has been cancelled
tinymist::ci / Check Minimum Rust version and Tests (Windows) (push) Has been cancelled
tinymist::ci / prepare-build (push) Has been cancelled
tinymist::gh_pages / build-gh-pages (push) Has been cancelled
tinymist::ci / announce (push) Has been cancelled
tinymist::ci / build (push) Has been cancelled
> Hm. Sorry about the lint failures here, they slipped my attention locally since they show up as warnings, not errors, in my editor. I'm happy to put a PR fixing them later unless you get to it first. > > Not too sure how to deal with the unused DiagWorker::check method, since it is used, just in a test-only module. We can certainly mark it as #[cfg(test)] but that feels a little nasty. https://github.com/Myriad-Dreamin/tinymist/pull/2062#issuecomment-3237812684 I ended up inlining the logic of `DiagWorker::check` into the test.
243 lines
7.8 KiB
Rust
243 lines
7.8 KiB
Rust
use std::borrow::Cow;
|
|
|
|
use tinymist_project::LspWorld;
|
|
use tinymist_world::vfs::WorkspaceResolver;
|
|
use typst::syntax::Span;
|
|
|
|
use crate::{analysis::Analysis, prelude::*};
|
|
|
|
use regex::RegexSet;
|
|
|
|
/// Stores diagnostics for files.
|
|
pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;
|
|
|
|
type TypstDiagnostic = typst::diag::SourceDiagnostic;
|
|
type TypstSeverity = typst::diag::Severity;
|
|
|
|
/// Converts a list of Typst diagnostics to LSP diagnostics,
|
|
/// with potential refinements on the error messages.
|
|
pub fn convert_diagnostics<'a>(
|
|
world: &LspWorld,
|
|
errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
|
|
position_encoding: PositionEncoding,
|
|
) -> DiagnosticsMap {
|
|
let analysis = Analysis {
|
|
position_encoding,
|
|
..Analysis::default()
|
|
};
|
|
let mut ctx = analysis.enter(world.clone());
|
|
DiagWorker::new(&mut ctx).convert_all(errors)
|
|
}
|
|
|
|
/// The worker for collecting diagnostics.
|
|
pub(crate) struct DiagWorker<'a> {
|
|
/// The world surface for Typst compiler.
|
|
pub ctx: &'a mut LocalContext,
|
|
/// Results
|
|
pub results: DiagnosticsMap,
|
|
}
|
|
|
|
impl<'w> DiagWorker<'w> {
|
|
/// Creates a new `CheckDocWorker` instance.
|
|
pub fn new(ctx: &'w mut LocalContext) -> Self {
|
|
Self {
|
|
ctx,
|
|
results: DiagnosticsMap::default(),
|
|
}
|
|
}
|
|
|
|
/// Runs code check on the main document and all its dependencies.
|
|
pub fn check(mut self) -> Self {
|
|
for dep in self.ctx.world.depended_files() {
|
|
if WorkspaceResolver::is_package_file(dep) {
|
|
continue;
|
|
}
|
|
|
|
let Ok(source) = self.ctx.world.source(dep) else {
|
|
continue;
|
|
};
|
|
|
|
for diag in self.ctx.lint(&source) {
|
|
self.handle(&diag);
|
|
}
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
/// Converts a list of Typst diagnostics to LSP diagnostics.
|
|
pub fn convert_all<'a>(
|
|
mut self,
|
|
errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
|
|
) -> DiagnosticsMap {
|
|
for diag in errors {
|
|
self.handle(diag);
|
|
}
|
|
|
|
self.results
|
|
}
|
|
|
|
/// Converts a list of Typst diagnostics to LSP diagnostics.
|
|
pub fn handle(&mut self, diag: &TypstDiagnostic) {
|
|
match self.convert_diagnostic(diag) {
|
|
Ok((uri, diagnostic)) => {
|
|
self.results.entry(uri).or_default().push(diagnostic);
|
|
}
|
|
Err(error) => {
|
|
log::error!("Failed to convert Typst diagnostic: {error:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_diagnostic(
|
|
&self,
|
|
typst_diagnostic: &TypstDiagnostic,
|
|
) -> anyhow::Result<(Url, Diagnostic)> {
|
|
let typst_diagnostic = {
|
|
let mut diag = Cow::Borrowed(typst_diagnostic);
|
|
|
|
// Extend more refiners here by adding their instances.
|
|
let refiners: &[&dyn DiagnosticRefiner] =
|
|
&[&DeprecationRefiner::<13> {}, &OutOfRootHintRefiner {}];
|
|
|
|
// NOTE: It would be nice to have caching here.
|
|
for refiner in refiners {
|
|
if refiner.matches(&diag) {
|
|
diag = Cow::Owned(refiner.refine(diag.into_owned()));
|
|
}
|
|
}
|
|
diag
|
|
};
|
|
|
|
let (id, span) = self.diagnostic_span_id(&typst_diagnostic);
|
|
let uri = self.ctx.uri_for_id(id)?;
|
|
let source = self.ctx.source_by_id(id)?;
|
|
let lsp_range = self.diagnostic_range(&source, span);
|
|
|
|
let lsp_severity = diagnostic_severity(typst_diagnostic.severity);
|
|
let lsp_message = diagnostic_message(&typst_diagnostic);
|
|
|
|
let diagnostic = Diagnostic {
|
|
range: lsp_range,
|
|
severity: Some(lsp_severity),
|
|
message: lsp_message,
|
|
source: Some("typst".to_owned()),
|
|
related_information: (!typst_diagnostic.trace.is_empty()).then(|| {
|
|
typst_diagnostic
|
|
.trace
|
|
.iter()
|
|
.flat_map(|tracepoint| self.to_related_info(tracepoint))
|
|
.collect()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
Ok((uri, diagnostic))
|
|
}
|
|
|
|
fn to_related_info(
|
|
&self,
|
|
tracepoint: &Spanned<Tracepoint>,
|
|
) -> Option<DiagnosticRelatedInformation> {
|
|
let id = tracepoint.span.id()?;
|
|
// todo: expensive uri_for_id
|
|
let uri = self.ctx.uri_for_id(id).ok()?;
|
|
let source = self.ctx.source_by_id(id).ok()?;
|
|
|
|
let typst_range = source.range(tracepoint.span)?;
|
|
let lsp_range = self.ctx.to_lsp_range(typst_range, &source);
|
|
|
|
Some(DiagnosticRelatedInformation {
|
|
location: LspLocation {
|
|
uri,
|
|
range: lsp_range,
|
|
},
|
|
message: tracepoint.v.to_string(),
|
|
})
|
|
}
|
|
|
|
fn diagnostic_span_id(&self, typst_diagnostic: &TypstDiagnostic) -> (TypstFileId, Span) {
|
|
iter::once(typst_diagnostic.span)
|
|
.chain(typst_diagnostic.trace.iter().map(|trace| trace.span))
|
|
.find_map(|span| Some((span.id()?, span)))
|
|
.unwrap_or_else(|| (self.ctx.world.main(), Span::detached()))
|
|
}
|
|
|
|
fn diagnostic_range(&self, source: &Source, typst_span: Span) -> LspRange {
|
|
// Due to nvaner/typst-lsp#241 and maybe typst/typst#2035, we sometimes fail to
|
|
// find the span. In that case, we use a default span as a better
|
|
// alternative to panicking.
|
|
//
|
|
// This may have been fixed after Typst 0.7.0, but it's still nice to avoid
|
|
// panics in case something similar reappears.
|
|
match source.find(typst_span) {
|
|
Some(node) => self.ctx.to_lsp_range(node.range(), source),
|
|
None => LspRange::new(LspPosition::new(0, 0), LspPosition::new(0, 0)),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn diagnostic_severity(typst_severity: TypstSeverity) -> DiagnosticSeverity {
|
|
match typst_severity {
|
|
TypstSeverity::Error => DiagnosticSeverity::ERROR,
|
|
TypstSeverity::Warning => DiagnosticSeverity::WARNING,
|
|
}
|
|
}
|
|
|
|
fn diagnostic_message(typst_diagnostic: &TypstDiagnostic) -> String {
|
|
let mut message = typst_diagnostic.message.to_string();
|
|
for hint in &typst_diagnostic.hints {
|
|
message.push_str("\nHint: ");
|
|
message.push_str(hint);
|
|
}
|
|
message
|
|
}
|
|
|
|
trait DiagnosticRefiner {
|
|
fn matches(&self, raw: &TypstDiagnostic) -> bool;
|
|
fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic;
|
|
}
|
|
|
|
struct DeprecationRefiner<const MINOR: usize>();
|
|
|
|
static DEPRECATION_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
|
|
RegexSet::new([
|
|
r"unknown variable: style",
|
|
r"unexpected argument: fill",
|
|
r"type state has no method `display`",
|
|
r"only element functions can be used as selectors",
|
|
])
|
|
.expect("Invalid regular expressions")
|
|
});
|
|
|
|
impl DiagnosticRefiner for DeprecationRefiner<13> {
|
|
fn matches(&self, raw: &TypstDiagnostic) -> bool {
|
|
DEPRECATION_PATTERNS.is_match(&raw.message)
|
|
}
|
|
|
|
fn refine(&self, raw: TypstDiagnostic) -> TypstDiagnostic {
|
|
raw.with_hint(concat!(
|
|
r#"Typst 0.13 has introduced breaking changes. Try downgrading "#,
|
|
r#"Tinymist to v0.12 to use a compatible version of Typst, "#,
|
|
r#"or consider migrating your code according to "#,
|
|
r#"[this guide](https://typst.app/blog/2025/typst-0.13/#migrating)."#
|
|
))
|
|
}
|
|
}
|
|
|
|
struct OutOfRootHintRefiner();
|
|
|
|
impl DiagnosticRefiner for OutOfRootHintRefiner {
|
|
fn matches(&self, raw: &TypstDiagnostic) -> bool {
|
|
raw.message.contains("failed to load file (access denied)")
|
|
&& raw
|
|
.hints
|
|
.iter()
|
|
.any(|hint| hint.contains("cannot read file outside of project root"))
|
|
}
|
|
|
|
fn refine(&self, mut raw: TypstDiagnostic) -> TypstDiagnostic {
|
|
raw.hints.clear();
|
|
raw.with_hint("Cannot read file outside of project root.")
|
|
}
|
|
}
|