Skip to main content

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}