typst_bake/document.rs
1//! Self-contained document for Typst template rendering.
2
3use crate::error::{Diagnostic, Error, Result, SourceLocation};
4#[cfg(feature = "pdf")]
5use crate::pdf_config::PdfConfig;
6use crate::resolver::{file_id_to_path, normalize_file_path, EmbeddedResolver};
7use crate::stats::EmbedStats;
8use crate::util::decompress;
9use include_dir::{Dir, File};
10use std::collections::{BTreeSet, HashMap};
11use std::sync::{Mutex, MutexGuard};
12use typst::diag::SourceDiagnostic;
13use typst::foundations::Dict;
14use typst::layout::PagedDocument;
15use typst::syntax::{FileId, Span};
16use typst::{World, WorldExt};
17use typst_as_lib::{TypstEngine, TypstWorld};
18
19/// A fully self-contained document ready for rendering.
20///
21/// Created by the [`document!`](crate::document!) macro with embedded templates, fonts,
22/// and packages. All resources are compressed with zstd and decompressed lazily at runtime.
23pub struct Document {
24 templates: &'static Dir<'static>,
25 packages: &'static Dir<'static>,
26 fonts: &'static Dir<'static>,
27 entry: &'static str,
28 inputs: Mutex<Option<Dict>>,
29 runtime_files: Mutex<HashMap<String, Vec<u8>>>,
30 stats: EmbedStats,
31 compiled_cache: Mutex<Option<PagedDocument>>,
32 /// PDF export options. Set by [`Document::with_pdf_config`]. A plain field (no
33 /// `Mutex`): the builder takes `self` by value to write it, and rendering reads it
34 /// through `&self`. Affects PDF export only, so it never invalidates `compiled_cache`.
35 #[cfg(feature = "pdf")]
36 pdf_config: PdfConfig,
37}
38
39impl Document {
40 /// Internal constructor used by the macro.
41 /// Do not use directly.
42 #[doc(hidden)]
43 pub fn __new(
44 templates: &'static Dir<'static>,
45 packages: &'static Dir<'static>,
46 fonts: &'static Dir<'static>,
47 entry: &'static str,
48 stats: EmbedStats,
49 ) -> Self {
50 Self {
51 templates,
52 packages,
53 fonts,
54 entry,
55 inputs: Mutex::new(None),
56 runtime_files: Mutex::new(HashMap::new()),
57 stats,
58 compiled_cache: Mutex::new(None),
59 #[cfg(feature = "pdf")]
60 pdf_config: PdfConfig::default(),
61 }
62 }
63
64 fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
65 self.inputs.lock().expect("lock poisoned")
66 }
67
68 fn lock_runtime_files(&self) -> MutexGuard<'_, HashMap<String, Vec<u8>>> {
69 self.runtime_files.lock().expect("lock poisoned")
70 }
71
72 fn lock_cache(&self) -> MutexGuard<'_, Option<PagedDocument>> {
73 self.compiled_cache.lock().expect("lock poisoned")
74 }
75
76 /// Add input data to the document.
77 ///
78 /// Define your data structs using the derive macros:
79 /// - **Top-level struct**: Use both [`IntoValue`](crate::IntoValue) and [`IntoDict`](crate::IntoDict)
80 /// - **Nested structs**: Use [`IntoValue`](crate::IntoValue) only
81 ///
82 /// In `.typ` files, access the data via `sys.inputs`:
83 /// ```typ
84 /// #import sys: inputs
85 /// = #inputs.title
86 /// ```
87 ///
88 /// # Example
89 ///
90 /// ```rust,ignore
91 /// use typst_bake::{IntoValue, IntoDict};
92 ///
93 /// #[derive(IntoValue, IntoDict)] // Top-level: both macros
94 /// struct Inputs {
95 /// title: String,
96 /// products: Vec<Product>,
97 /// }
98 ///
99 /// #[derive(IntoValue)] // Nested: IntoValue only
100 /// struct Product {
101 /// name: String,
102 /// price: f64,
103 /// }
104 ///
105 /// let inputs = Inputs {
106 /// title: "Catalog".to_string(),
107 /// products: vec![
108 /// Product { name: "Apple".to_string(), price: 1.50 },
109 /// ],
110 /// };
111 ///
112 /// let pdf = typst_bake::document!("main.typ")
113 /// .with_inputs(inputs)
114 /// .to_pdf()?;
115 /// ```
116 pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
117 *self.lock_inputs() = Some(inputs.into());
118 *self.lock_cache() = None;
119 self
120 }
121
122 /// Add or replace a runtime file at the given path.
123 ///
124 /// The file becomes available to Typst templates via `#image("path")`,
125 /// `#read("path")`, etc. Runtime files take priority over embedded files
126 /// with the same path.
127 ///
128 /// # Errors
129 /// Returns [`Error::InvalidFilePath`] if the path is empty, absolute, or
130 /// contains `..` segments.
131 ///
132 /// # Example
133 /// ```rust,ignore
134 /// let pdf = typst_bake::document!("main.typ")
135 /// .add_file("images/chart.png", chart_bytes)?
136 /// .to_pdf()?;
137 /// ```
138 pub fn add_file(self, path: impl Into<String>, data: impl Into<Vec<u8>>) -> Result<Self> {
139 let raw = path.into();
140 let normalized = normalize_file_path(&raw);
141
142 if normalized.is_empty() {
143 return Err(Error::InvalidFilePath("path is empty".into()));
144 }
145 if normalized.starts_with('/') {
146 return Err(Error::InvalidFilePath(format!(
147 "absolute path not allowed: {normalized}"
148 )));
149 }
150 if normalized.split('/').any(|s| s == "..") {
151 return Err(Error::InvalidFilePath(format!(
152 "path with '..' not allowed: {normalized}"
153 )));
154 }
155
156 self.lock_runtime_files().insert(normalized, data.into());
157 *self.lock_cache() = None;
158 Ok(self)
159 }
160
161 /// Set PDF export options.
162 ///
163 /// Configures PDF-only settings such as tagging, conformance standard, document
164 /// identifier, and creation timestamp. See [`PdfConfig`]. These options affect
165 /// [`to_pdf`](Self::to_pdf) only; SVG/PNG output ignores them.
166 ///
167 /// This does not invalidate the compiled cache (options apply at the PDF export
168 /// stage, not during compilation). Invalid configurations are reported when
169 /// [`to_pdf`](Self::to_pdf) is called, not here; the default config never errors.
170 ///
171 /// # Example
172 /// ```rust,ignore
173 /// use typst_bake::{PdfConfig, PdfStandard};
174 ///
175 /// // Disable tagging to shrink the PDF (bookmarks are preserved).
176 /// let pdf = typst_bake::document!("main.typ")
177 /// .with_pdf_config(PdfConfig {
178 /// tagged: false,
179 /// standard: PdfStandard::A2b,
180 /// ..Default::default()
181 /// })
182 /// .to_pdf()?;
183 /// ```
184 ///
185 /// Note: a page selection (via [`select_pages`](Self::select_pages)) always forces
186 /// tagging off and drops bookmarks for excluded pages; prefer `tagged: false` on the
187 /// full document if you only want to disable tagging.
188 #[cfg(feature = "pdf")]
189 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
190 pub fn with_pdf_config(mut self, config: PdfConfig) -> Self {
191 self.pdf_config = config;
192 self
193 }
194
195 /// Check if a file exists at the given path.
196 ///
197 /// Checks both embedded (compile-time) and runtime files.
198 pub fn has_file(&self, path: impl AsRef<str>) -> bool {
199 let normalized = normalize_file_path(path.as_ref());
200
201 // Check runtime files first.
202 if self.lock_runtime_files().contains_key(&normalized) {
203 return true;
204 }
205
206 // Check embedded templates.
207 if find_entry(self.templates, &normalized).is_some() {
208 return true;
209 }
210
211 false
212 }
213
214 /// Select specific pages for output, returning a [`Pages`] view.
215 ///
216 /// Pages are 0-indexed. Duplicates are removed and pages are always
217 /// output in document order regardless of input order.
218 ///
219 /// # Errors
220 /// Returns [`Error::InvalidPageSelection`] at render time if any index
221 /// is out of range or the selection is empty.
222 ///
223 /// # Example
224 /// ```rust,ignore
225 /// // Select specific pages
226 /// let pdf = typst_bake::document!("main.typ")
227 /// .select_pages([0, 2, 4])
228 /// .to_pdf()?;
229 ///
230 /// // Works with ranges too
231 /// let svgs = typst_bake::document!("main.typ")
232 /// .select_pages(0..3)
233 /// .to_svg()?;
234 ///
235 /// // Reuse with different selections
236 /// let doc = typst_bake::document!("main.typ");
237 /// let cover = doc.select_pages([0]).to_pdf()?;
238 /// let body = doc.select_pages(1..5).to_pdf()?;
239 /// ```
240 pub fn select_pages(&self, pages: impl IntoIterator<Item = usize>) -> Pages<'_> {
241 Pages {
242 doc: self,
243 indices: pages.into_iter().collect(),
244 }
245 }
246
247 /// Get the total number of pages in the compiled document.
248 ///
249 /// Compiles the document if not already compiled.
250 /// Returns the total page count regardless of `select_pages`.
251 ///
252 /// # Example
253 /// ```rust,ignore
254 /// let doc = typst_bake::document!("main.typ");
255 /// let count = doc.page_count()?;
256 /// let last_page = doc.select_pages([count - 1]).to_pdf()?;
257 /// ```
258 pub fn page_count(&self) -> Result<usize> {
259 self.with_compiled(|compiled| Ok(compiled.pages.len()))
260 }
261
262 /// Get compression statistics for embedded content.
263 pub fn stats(&self) -> &EmbedStats {
264 &self.stats
265 }
266
267 /// Compile the document, reusing the cached result if available.
268 fn compile_cached(&self) -> Result<()> {
269 if self.lock_cache().is_some() {
270 return Ok(());
271 }
272
273 // Read main template content (compressed)
274 let main_file =
275 find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
276
277 let main_bytes = decompress(main_file.contents())?;
278 let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
279
280 let mut resolver = EmbeddedResolver::new(self.templates, self.packages);
281 for (path, data) in self.lock_runtime_files().iter() {
282 resolver.insert_runtime_file(path.clone(), data.clone());
283 }
284
285 // Collect and decompress fonts from the embedded fonts directory
286 let font_data: Vec<Vec<u8>> = self
287 .fonts
288 .files()
289 .map(|f| decompress(f.contents()).map_err(Error::from))
290 .collect::<Result<Vec<_>>>()?;
291
292 let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
293
294 let engine = TypstEngine::builder()
295 .main_file((self.entry, main_content))
296 .add_file_resolver(resolver)
297 .fonts(font_refs)
298 .build();
299
300 // Clone inputs (preserve for retry on failure)
301 let inputs = self.lock_inputs().clone();
302
303 // Drive the world directly (mirrors typst-as-lib's internal `do_compile`) so the
304 // `World` stays in scope to resolve diagnostic spans into source locations.
305 let mut world_builder = engine.world_builder();
306 if let Some(inputs) = inputs {
307 world_builder = world_builder.with_inputs(inputs);
308 }
309 // A build failure is an input-injection error, not a source diagnostic; preserve its
310 // message in a location-less diagnostic.
311 let world = world_builder.build().map_err(|e| {
312 Error::Compilation(vec![Diagnostic {
313 location: None,
314 message: e.to_string(),
315 hints: Vec::new(),
316 trace: Vec::new(),
317 }])
318 })?;
319
320 let warned = typst::compile::<PagedDocument>(&world);
321 // Replicate the engine's default eviction policy (`Some(0)`); `world_builder` does not
322 // evict automatically. The comemo cache is global, so don't enlarge this blindly.
323 typst::comemo::evict(0);
324
325 let main = world.main();
326 let compiled = warned.output.map_err(|diagnostics| {
327 Error::Compilation(
328 diagnostics
329 .iter()
330 .map(|d| diagnostic_from(&world, self.entry, main, d))
331 .collect(),
332 )
333 })?;
334
335 *self.lock_cache() = Some(compiled);
336
337 Ok(())
338 }
339
340 /// Compile if needed, then call `f` with a reference to the compiled document.
341 fn with_compiled<F, T>(&self, f: F) -> Result<T>
342 where
343 F: FnOnce(&PagedDocument) -> Result<T>,
344 {
345 self.compile_cached()?;
346 let cache = self.lock_cache();
347 let compiled = cache
348 .as_ref()
349 .expect("compiled_cache must be Some after successful compile_cached()");
350 f(compiled)
351 }
352
353 /// Compile the document and generate PDF.
354 ///
355 /// # Returns
356 /// PDF data as bytes.
357 ///
358 /// # Errors
359 /// Returns an error if compilation or PDF generation fails.
360 #[cfg(feature = "pdf")]
361 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
362 pub fn to_pdf(&self) -> Result<Vec<u8>> {
363 self.render_pdf(None)
364 }
365
366 /// Compile the document and generate SVG for each page.
367 ///
368 /// # Returns
369 /// A vector of SVG strings, one per page.
370 ///
371 /// # Errors
372 /// Returns an error if compilation fails.
373 #[cfg(feature = "svg")]
374 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
375 pub fn to_svg(&self) -> Result<Vec<String>> {
376 self.render_svg(None)
377 }
378
379 /// Compile the document and generate PNG for each page.
380 ///
381 /// # Arguments
382 /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
383 ///
384 /// # Returns
385 /// A vector of PNG bytes, one per page.
386 ///
387 /// # Errors
388 /// Returns an error if compilation or PNG encoding fails.
389 #[cfg(feature = "png")]
390 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
391 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
392 self.render_png(None, dpi)
393 }
394
395 #[cfg(feature = "pdf")]
396 fn render_pdf(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<u8>> {
397 self.with_compiled(|compiled| {
398 // Base options come from the stored config (incl. `tagged`, standard, ident,
399 // timestamp). `options` borrows `self.pdf_config.ident`; later reads of
400 // `self.pdf_config.standard` (Copy) are additional shared borrows, which is
401 // fine. We are inside the `compiled_cache` guard, but `pdf_config` is a
402 // distinct field accessed only by shared borrow — so this is sound. Nobody
403 // must take `&mut self.pdf_config` here.
404 let mut options = self.pdf_config.to_typst()?;
405
406 let indices = validate_page_selection(selected, compiled.pages.len())?;
407 if let Some(indices) = indices {
408 use std::num::NonZeroUsize;
409 use typst::layout::PageRanges;
410
411 let ranges = indices
412 .iter()
413 .map(|&i| {
414 let n = Some(NonZeroUsize::new(i + 1).unwrap());
415 n..=n
416 })
417 .collect();
418 options.page_ranges = Some(PageRanges::new(ranges));
419
420 // --- Single safety net: page selection forces tagging off ---
421 //
422 // Page selection sets `page_ranges`, which is incompatible with tagged
423 // PDF. typst-pdf does NOT error on `tagged: true` + `page_ranges`; it
424 // silently emits a structure tree referencing ALL pages while only a
425 // subset is exported, yielding a malformed/misaligned tag tree. See:
426 // - typst/typst#7743 (tagged PDF incompatible with page ranges)
427 // So we defensively force tagging off here, overriding `PdfConfig.tagged`
428 // — but ONLY on the page-selection path. Full-document `tagged: false` is
429 // handled by `to_typst` and is unaffected.
430 //
431 // Bookmarks: the document outline (/Outlines) is independent of tagging
432 // (typst-pdf sets the outline unconditionally; the tag tree only when
433 // enabled), so disabling tagging keeps bookmarks. Bookmarks pointing at
434 // EXCLUDED pages are still dropped here — that loss is caused by
435 // `page_ranges`, not by tagging.
436 //
437 // Accessible standards (PDF/A-*a, PDF/UA-1) mandate tagging, so they
438 // cannot coexist with page selection; reject them explicitly rather than
439 // emit a non-conformant PDF.
440 if self.pdf_config.standard.requires_tagging() {
441 return Err(Error::InvalidPdfConfig(format!(
442 "page selection is incompatible with {:?} (requires tagging)",
443 self.pdf_config.standard
444 )));
445 }
446 options.tagged = false;
447 }
448
449 // Invariant backstop: tagged PDF + page ranges must never escape together.
450 debug_assert!(!(options.tagged && options.page_ranges.is_some()));
451
452 typst_pdf::pdf(compiled, &options).map_err(|e| Error::PdfGeneration(format!("{e:?}")))
453 })
454 }
455
456 #[cfg(feature = "svg")]
457 fn render_svg(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<String>> {
458 self.with_compiled(|compiled| {
459 let indices = validate_page_selection(selected, compiled.pages.len())?;
460 match indices {
461 Some(indices) => Ok(indices
462 .iter()
463 .map(|&i| typst_svg::svg(&compiled.pages[i]))
464 .collect()),
465 None => Ok(compiled.pages.iter().map(typst_svg::svg).collect()),
466 }
467 })
468 }
469
470 #[cfg(feature = "png")]
471 fn render_png(&self, selected: Option<&BTreeSet<usize>>, dpi: f32) -> Result<Vec<Vec<u8>>> {
472 self.with_compiled(|compiled| {
473 let pixel_per_pt = dpi / 72.0;
474 let indices = validate_page_selection(selected, compiled.pages.len())?;
475 let pages: Box<dyn Iterator<Item = &_>> = match &indices {
476 Some(indices) => Box::new(indices.iter().map(|&i| &compiled.pages[i])),
477 None => Box::new(compiled.pages.iter()),
478 };
479 pages
480 .map(|page| {
481 typst_render::render(page, pixel_per_pt)
482 .encode_png()
483 .map_err(|e| Error::PngEncoding(e.to_string()))
484 })
485 .collect()
486 })
487 }
488}
489
490/// A lightweight view into a [`Document`] with a page selection filter.
491///
492/// Created by [`Document::select_pages`]. Holds a reference to the
493/// document and an owned set of page indices.
494pub struct Pages<'a> {
495 doc: &'a Document,
496 indices: BTreeSet<usize>,
497}
498
499impl Pages<'_> {
500 /// Compile the document and generate PDF for the selected pages.
501 ///
502 /// # Errors
503 /// Returns an error if compilation, PDF generation, or page selection fails.
504 #[cfg(feature = "pdf")]
505 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
506 pub fn to_pdf(&self) -> Result<Vec<u8>> {
507 self.doc.render_pdf(Some(&self.indices))
508 }
509
510 /// Compile the document and generate SVG for the selected pages.
511 ///
512 /// # Errors
513 /// Returns an error if compilation or page selection fails.
514 #[cfg(feature = "svg")]
515 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
516 pub fn to_svg(&self) -> Result<Vec<String>> {
517 self.doc.render_svg(Some(&self.indices))
518 }
519
520 /// Compile the document and generate PNG for the selected pages.
521 ///
522 /// # Arguments
523 /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
524 ///
525 /// # Errors
526 /// Returns an error if compilation, PNG encoding, or page selection fails.
527 #[cfg(feature = "png")]
528 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
529 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
530 self.doc.render_png(Some(&self.indices), dpi)
531 }
532}
533
534/// Validate page selection and return indices to render.
535/// Returns `None` if no selection (= all pages).
536fn validate_page_selection(
537 selected: Option<&BTreeSet<usize>>,
538 total_pages: usize,
539) -> Result<Option<Vec<usize>>> {
540 if total_pages == 0 {
541 return Err(Error::InvalidPageSelection("document has no pages".into()));
542 }
543 match selected {
544 None => Ok(None),
545 Some(pages) => {
546 if pages.is_empty() {
547 return Err(Error::InvalidPageSelection(
548 "page selection is empty".into(),
549 ));
550 }
551 if let Some(&max) = pages.last() {
552 if max >= total_pages {
553 return Err(Error::InvalidPageSelection(format!(
554 "page index {max} out of range (valid: 0..={})",
555 total_pages - 1
556 )));
557 }
558 }
559 Ok(Some(pages.iter().copied().collect()))
560 }
561 }
562}
563
564/// Resolve a span into a [`SourceLocation`] using the compilation world.
565///
566/// The entry file's `FileId` is mapped back to the user-facing entry path so it
567/// matches exactly what was requested (including nested entries).
568fn span_to_location(
569 world: &TypstWorld,
570 entry: &str,
571 main: FileId,
572 span: Span,
573) -> Option<SourceLocation> {
574 let id = span.id()?;
575 let range = world.range(span)?;
576 let source = world.source(id).ok()?;
577 let (line, column) = source.lines().byte_to_line_column(range.start)?;
578 let file = if id == main {
579 entry.to_string()
580 } else {
581 file_id_to_path(id)
582 };
583 Some(SourceLocation {
584 file,
585 line: line + 1,
586 column: column + 1,
587 })
588}
589
590/// Convert a Typst [`SourceDiagnostic`] into a typst-bake [`Diagnostic`] with
591/// resolved source locations.
592fn diagnostic_from(
593 world: &TypstWorld,
594 entry: &str,
595 main: FileId,
596 diagnostic: &SourceDiagnostic,
597) -> Diagnostic {
598 Diagnostic {
599 location: span_to_location(world, entry, main, diagnostic.span),
600 message: diagnostic.message.to_string(),
601 hints: diagnostic.hints.iter().map(|h| h.to_string()).collect(),
602 trace: diagnostic
603 .trace
604 .iter()
605 .filter_map(|t| span_to_location(world, entry, main, t.span))
606 .collect(),
607 }
608}
609
610/// Find a file in a `Dir` tree by a potentially nested path (e.g. "dir/main.typ").
611fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
612 let normalized = path.trim_start_matches("./").replace('\\', "/");
613 let (dir_path, file_name) = match normalized.rsplit_once('/') {
614 Some((d, f)) => (Some(d), f),
615 None => (None, normalized.as_str()),
616 };
617
618 let target_dir = match dir_path {
619 Some(dir_path) => {
620 let mut current = dir;
621 for segment in dir_path.split('/') {
622 current = current
623 .dirs()
624 .find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
625 }
626 current
627 }
628 None => dir,
629 };
630
631 target_dir
632 .files()
633 .find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 /// Compile a self-contained broken source and resolve its diagnostics. No
641 /// embedded resolver or fonts are needed for an eval-time error.
642 fn compile_error(entry: &'static str, src: &'static str) -> Vec<Diagnostic> {
643 let engine = TypstEngine::builder().main_file((entry, src)).build();
644 let world = engine.world_builder().build().expect("world builds");
645 let warned = typst::compile::<PagedDocument>(&world);
646 typst::comemo::evict(0);
647 let main = world.main();
648 let diagnostics = warned.output.expect_err("source should fail to compile");
649 diagnostics
650 .iter()
651 .map(|d| diagnostic_from(&world, entry, main, d))
652 .collect()
653 }
654
655 #[test]
656 fn compilation_error_exposes_source_location() {
657 // `bad_call` is an unknown variable; the error span points at it on line 2.
658 let diagnostics = compile_error("test.typ", "Hello\n#bad_call()\n");
659 assert!(!diagnostics.is_empty());
660 let loc = diagnostics[0]
661 .location
662 .as_ref()
663 .expect("diagnostic carries a source location");
664 // The entry file path matches exactly what was requested.
665 assert_eq!(loc.file, "test.typ");
666 assert_eq!(loc.line, 2);
667 assert!(loc.column >= 1);
668 assert!(!diagnostics[0].message.is_empty());
669 }
670
671 #[test]
672 fn nested_entry_path_is_preserved() {
673 let diagnostics = compile_error("reports/report.typ", "#oops\n");
674 let loc = diagnostics[0].location.as_ref().expect("has location");
675 assert_eq!(loc.file, "reports/report.typ");
676 assert_eq!(loc.line, 1);
677 }
678
679 #[test]
680 fn diagnostic_display_with_location_hints_and_trace() {
681 let diagnostic = Diagnostic {
682 location: Some(SourceLocation {
683 file: "report.typ".to_string(),
684 line: 42,
685 column: 12,
686 }),
687 message: "boom".to_string(),
688 hints: vec!["try wrapping it".to_string()],
689 trace: vec![SourceLocation {
690 file: "main.typ".to_string(),
691 line: 5,
692 column: 1,
693 }],
694 };
695 assert_eq!(
696 diagnostic.to_string(),
697 "report.typ:42:12: error: boom\n hint: try wrapping it\n called from: main.typ:5:1"
698 );
699 }
700
701 #[test]
702 fn diagnostic_display_without_location() {
703 let diagnostic = Diagnostic {
704 location: None,
705 message: "boom".to_string(),
706 hints: Vec::new(),
707 trace: Vec::new(),
708 };
709 assert_eq!(diagnostic.to_string(), "error: boom");
710 }
711}