forked from bai/curriculum-project-hub
feat(checker): cph-typst — embed typst 0.15, driver-gen, compile, PDF (WU-4)
The heart of the pipeline. Embeds the typst 0.15 compiler as a library to compile-check a lesson and export a PDF, with diagnostics mapped back to source spans (the product's value). - LessonWorld: a typst World over the engineering-file directory tree. vpath = real relative path (ADR-0007); the per-target driver is mounted in-memory as `main` (.cph/driver-<target>.typ, never written to disk); @local/cph-render is resolved from render_dir (CPH_RENDER_DIR / with_render_dir), no package install or network. Fonts via typst-kit FontStore (system CJK). - Driver generation: from the manifest, per target — static root-relative `include` of each content file (⇒ module body content, ADR-0006), assembled into the frozen `display(info, target, parts)` call; optional lemma proof emitted only if proof.typ exists; example `source` scalar threaded through. - Diagnostics: typst SourceDiagnostic (errors + warnings) → cph_diag::Diagnostic. 0.15 DiagSpan handled via span.id() + WorldExt::range → file-relative SourceSpan with 1-based line/col; driver-internal spans re-pointed with a "generated driver" hint, never dropped. typst hints/trace carried through. - Engine API: new / with_render_dir / compile_check / build_pdf. - 6 tests incl. build_pdf_with_real_render — full World→driver→cph-render→PDF producing real PDF-1.7 output (student 42KB / teacher 54KB). clippy/fmt clean. Also fixes render/typst.toml `compiler = ">=0.15.0"` → `"0.15.0"`: typst 0.15's manifest parser rejects a `>=` comparator on the compiler field (the 0.14 CLU WU-2 tested against was lenient). This unblocks loading the real render package. Pinned: typst/typst-pdf/typst-syntax/typst-library/typst-layout/typst-kit =0.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
//! 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<FileId>` for the owning file (`None` ⇒ detached, so
|
||||
//! no span is emitted);
|
||||
//! 2. `WorldExt::range(span) -> Option<Range<usize>>` 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`).
|
||||
//!
|
||||
//! A span whose `FileId` is the generated **driver** is re-pointed: its `file`
|
||||
//! is set to the driver vpath and a hint flags it as driver-internal (a
|
||||
//! driver-gen bug indicator) rather than dropped or 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<String> = 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 id == world.main() {
|
||||
hints.push(
|
||||
"this span is inside the generated driver (.cph/driver-*.typ), \
|
||||
not a lesson source file — likely a driver-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
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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()
|
||||
))),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user