//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s. //! //! ## Span mapping (typst 0.15 specifics) //! //! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare //! `Span` of 0.14). To locate it we: //! 1. `span.id() -> Option` for the owning file (`None` ⇒ detached, so //! no span is emitted); //! 2. `WorldExt::range(span) -> Option>` for the byte range — this //! handles every `DiagSpanKind` variant internally (numbered source spans //! *and* raw external byte ranges), so we never match the enum by hand; //! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via //! `lines().byte_to_line_column` (0-based ⇒ `+1`). //! //! The `main` file is now the real **template** (`exports/.typ`), so a //! span there resolves naturally to an authored file — no re-pointing needed. A //! span landing in the **virtual augmented manifest** ([`crate::MANIFEST_VPATH`], //! served in-memory, not on disk) is reported with its vpath plus a hint marking //! it as the framework-synthesized manifest (a manifest-generation bug //! indicator) rather than mis-attributed to a user file. Spans in the //! `cph-render` package are reported with an `@local/cph-render:…` pseudo-path. use std::path::PathBuf; use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan}; use typst::diag::{Severity as TypstSeverity, SourceDiagnostic}; use typst::syntax::{FileId, VirtualRoot}; use typst::{World, WorldExt}; use crate::world::LessonWorld; /// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its /// span against `world`. pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic { let severity = match d.severity { TypstSeverity::Error => Severity::Error, TypstSeverity::Warning => Severity::Warning, }; // Flatten the typst call trace into the message tail so call-site context // isn't lost (the primary span points at the innermost node). let mut message = d.message.to_string(); for tp in &d.trace { message.push_str(&format!("\n in {}", tp.v)); } let mut diag = Diagnostic { severity, code: DiagCode::TypstCompile, message, span: None, hint: None, }; // typst's own hints are the product's core value — carry them through. let mut hints: Vec = d.hints.iter().map(|h| h.v.to_string()).collect(); if let Some(id) = d.span.id() { if let Some(file) = file_for(world, id) { if is_virtual_manifest(id) { hints.push( "this span is inside the framework-synthesized manifest \ (/.cph/manifest.toml), not a lesson source file — likely a \ manifest-generation issue" .to_string(), ); } // Byte range via WorldExt::range (DiagSpan is Copy). let byte_range = world.range(d.span).unwrap_or(0..0); let (line, col) = line_col(world, id, byte_range.start); diag.span = Some(SourceSpan { file, byte_range, line, col, }); } } if !hints.is_empty() { diag.hint = Some(hints.join("; ")); } diag } /// Whether `id` is the in-memory augmented-manifest virtual file /// ([`crate::MANIFEST_VPATH`]) the engine injects (not a real lesson file). fn is_virtual_manifest(id: FileId) -> bool { matches!(id.root(), VirtualRoot::Project) && id.vpath().get_with_slash() == crate::MANIFEST_VPATH } /// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)` /// when the source can't be read or the offset is out of range. fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) { let Ok(source) = world.source(id) else { return (1, 1); }; match source.lines().byte_to_line_column(byte) { // byte_to_line_column is 0-based; editors are 1-based. Some((line, col)) => (line as u32 + 1, col as u32 + 1), None => (1, 1), } } /// File path for a `FileId`. Project files are relative to the lesson root (no /// leading slash); package files use an `@namespace/name:version/path` form. fn file_for(world: &LessonWorld, id: FileId) -> Option { let _ = world; match id.root() { VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())), VirtualRoot::Package(spec) => Some(PathBuf::from(format!( "@{}/{}:{}{}", spec.namespace, spec.name, spec.version, id.vpath().get_with_slash() ))), } }