use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument;
use typst::syntax::{FileId, Span};
use typst::World;
use typst_pdf::PdfOptions;
use crate::error::{Error, Result};
use crate::typst_compile::CompileOutcome;
use crate::typst_world::{InkhavenWorld, WorldSettings};
pub struct InprocessHandle {
rx: mpsc::Receiver<CompileOutcome>,
pdf_path: PathBuf,
stash: Option<CompileOutcome>,
cancelled: bool,
}
impl InprocessHandle {
pub fn try_wait_mut(&mut self) -> std::io::Result<Option<()>> {
if self.stash.is_some() {
return Ok(Some(()));
}
match self.rx.try_recv() {
Ok(outcome) => {
self.stash = Some(outcome);
Ok(Some(()))
}
Err(mpsc::TryRecvError::Empty) => Ok(None),
Err(mpsc::TryRecvError::Disconnected) => {
self.stash = Some(CompileOutcome {
success: false,
stderr: "in-process compile: worker thread disconnected".to_owned(),
stdout: String::new(),
pdf_path: self.pdf_path.clone(),
});
Ok(Some(()))
}
}
}
pub fn into_outcome(mut self) -> CompileOutcome {
if let Some(out) = self.stash.take() {
return out;
}
if self.cancelled {
return CompileOutcome {
success: false,
stderr: "in-process compile: cancelled by user — worker thread \
abandoned, may continue using CPU until typst finishes \
on its own"
.to_owned(),
stdout: String::new(),
pdf_path: self.pdf_path,
};
}
match self.rx.recv() {
Ok(out) => out,
Err(_) => CompileOutcome {
success: false,
stderr: "in-process compile: worker thread disconnected".to_owned(),
stdout: String::new(),
pdf_path: self.pdf_path,
},
}
}
pub fn cancel(&mut self) {
self.cancelled = true;
}
}
pub fn spawn_thread(
project_root: &Path,
main_typ: &Path,
settings: WorldSettings,
) -> Result<InprocessHandle> {
let pdf_path = main_typ.with_extension("pdf");
let project_root = project_root.to_path_buf();
let main_typ = main_typ.to_path_buf();
let pdf_path_for_thread = pdf_path.clone();
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("inkhaven-typst-compile".into())
.spawn(move || {
let outcome = compile_to_pdf(
&project_root,
&main_typ,
&pdf_path_for_thread,
settings,
);
let _ = tx.send(outcome);
})
.map_err(|e| Error::Store(format!("spawn typst worker thread: {e}")))?;
Ok(InprocessHandle {
rx,
pdf_path,
stash: None,
cancelled: false,
})
}
fn compile_to_pdf(
project_root: &Path,
main_typ: &Path,
pdf_path: &Path,
settings: WorldSettings,
) -> CompileOutcome {
let world = match InkhavenWorld::new(project_root, main_typ, settings) {
Ok(w) => w,
Err(e) => {
return CompileOutcome {
success: false,
stderr: format!("in-process compile: {e}"),
stdout: String::new(),
pdf_path: pdf_path.to_path_buf(),
};
}
};
let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
let document = match output {
Ok(doc) => doc,
Err(errors) => {
return CompileOutcome {
success: false,
stderr: format_diagnostics(&world, &errors),
stdout: format_diagnostics(&world, &warnings),
pdf_path: pdf_path.to_path_buf(),
};
}
};
let options = PdfOptions::default();
let bytes = match typst_pdf::pdf(&document, &options) {
Ok(b) => b,
Err(errors) => {
return CompileOutcome {
success: false,
stderr: format_diagnostics(&world, &errors),
stdout: format_diagnostics(&world, &warnings),
pdf_path: pdf_path.to_path_buf(),
};
}
};
if let Err(e) = std::fs::write(pdf_path, &bytes) {
return CompileOutcome {
success: false,
stderr: format!("write {}: {e}", pdf_path.display()),
stdout: String::new(),
pdf_path: pdf_path.to_path_buf(),
};
}
CompileOutcome {
success: true,
stderr: String::new(),
stdout: format_diagnostics(&world, &warnings),
pdf_path: pdf_path.to_path_buf(),
}
}
fn format_diagnostics(world: &InkhavenWorld, diags: &[SourceDiagnostic]) -> String {
if diags.is_empty() {
return String::new();
}
let mut out = String::new();
for d in diags {
let label = match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
let (path, line, col) = locate(world, d.span);
out.push_str(&format!("{label}: {}\n", d.message));
out.push_str(&format!(" --> {path}:{line}:{col}\n"));
for hint in &d.hints {
out.push_str(&format!(" hint: {hint}\n"));
}
}
out
}
fn locate(world: &InkhavenWorld, span: Span) -> (String, usize, usize) {
let id = match span.id() {
Some(id) => id,
None => return ("<detached>".to_owned(), 0, 0),
};
let path_label = file_id_label(id);
let source = match world.source(id) {
Ok(s) => s,
Err(_) => return (path_label, 0, 0),
};
let range = match source.range(span) {
Some(r) => r,
None => return (path_label, 0, 0),
};
let (line0, col0) = source
.lines()
.byte_to_line_column(range.start)
.unwrap_or((0, 0));
(path_label, line0 + 1, col0 + 1)
}
fn file_id_label(id: FileId) -> String {
match id.package() {
Some(pkg) => format!("{}/{}", pkg, id.vpath().as_rooted_path().display()),
None => id.vpath().as_rooted_path().display().to_string(),
}
}
pub fn check_semantic(
source: &str,
settings: WorldSettings,
) -> Vec<crate::typst_check::TypstDiagnostic> {
let world = InkhavenWorld::in_memory(
std::env::temp_dir(),
source.to_owned(),
settings,
);
let typst::diag::Warned { output, warnings } =
typst::compile::<typst::layout::PagedDocument>(&world);
let main_id = world.main();
let mut diags = Vec::new();
match output {
Ok(_) => {
for w in warnings {
if let Some(d) = lift(&world, &w, main_id) {
diags.push(d);
}
}
}
Err(errors) => {
for e in errors {
if let Some(d) = lift(&world, &e, main_id) {
diags.push(d);
}
}
for w in warnings {
if let Some(d) = lift(&world, &w, main_id) {
diags.push(d);
}
}
}
}
diags
}
fn lift(
world: &InkhavenWorld,
diag: &typst::diag::SourceDiagnostic,
main_id: FileId,
) -> Option<crate::typst_check::TypstDiagnostic> {
let id = diag.span.id()?;
if id != main_id {
return None;
}
let source = world.source(id).ok()?;
let range = source.range(diag.span)?;
let (line0, col0) =
source.lines().byte_to_line_column(range.start).unwrap_or((0, 0));
let mut message = diag.message.to_string();
if matches!(diag.severity, typst::diag::Severity::Warning) {
message = format!("warning: {message}");
}
Some(crate::typst_check::TypstDiagnostic {
line: line0 + 1,
col: col0 + 1,
byte_start: range.start,
byte_end: range.end,
message,
hints: diag.hints.iter().map(|h| h.to_string()).collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn write_tmp(content: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("probe.typ");
std::fs::write(&path, content).expect("write");
(dir, path)
}
#[test]
fn end_to_end_compile_smoke() {
let (dir, path) = write_tmp(
"#set page(width: 10cm, height: 5cm, margin: 1cm)\n\
= Hello\nProse.\n",
);
let mut handle = spawn_thread(
dir.path(),
&path,
WorldSettings {
bundle_fonts: true,
use_system_fonts: true,
packages_enabled: false,
},
)
.expect("spawn");
let started = std::time::Instant::now();
loop {
match handle.try_wait_mut().expect("try_wait") {
Some(_) => break,
None => std::thread::sleep(std::time::Duration::from_millis(50)),
}
assert!(
started.elapsed().as_secs() < 30,
"in-process compile hung",
);
}
let outcome = handle.into_outcome();
if outcome.success {
let bytes = std::fs::metadata(&outcome.pdf_path)
.expect("pdf written")
.len();
assert!(bytes > 100, "PDF suspiciously small: {bytes} bytes");
} else {
assert!(
!outcome.stderr.is_empty(),
"failed compile should populate stderr",
);
eprintln!("in-process compile failed (acceptable on bare hosts):\n{}", outcome.stderr);
}
}
#[test]
fn semantic_catches_undefined_function() {
let source = "#this_function_does_not_exist()\n";
let diags = check_semantic(
source,
WorldSettings {
bundle_fonts: true,
use_system_fonts: true,
packages_enabled: false,
},
);
assert!(
!diags.is_empty(),
"expected a semantic diagnostic for the undefined function",
);
let first = &diags[0];
assert!(first.line >= 1);
assert!(
first.message.contains("this_function_does_not_exist")
|| first.message.to_lowercase().contains("unknown")
|| first.message.to_lowercase().contains("not found"),
"unexpected diagnostic message: {}",
first.message,
);
}
#[test]
fn rejects_main_outside_root() {
let dir = tempfile::tempdir().expect("tempdir");
let other = tempfile::tempdir().expect("tempdir2");
let main = other.path().join("probe.typ");
std::fs::write(&main, "= hi\n").expect("write");
let err = InkhavenWorld::new(
dir.path(),
&main,
WorldSettings {
bundle_fonts: false,
use_system_fonts: false,
packages_enabled: false,
},
)
.err()
.expect("should reject");
assert!(err.contains("not inside project root"), "got: {err}");
}
}