typst_batch/
world.rs

1//! `SystemWorld` implementation - the core World trait.
2//!
3//! This module implements Typst's `World` trait, which provides the compilation
4//! environment for Typst documents. The `SystemWorld` is a lightweight per-compilation
5//! state that references globally shared resources (fonts, packages, library).
6//!
7//! # Architecture
8//!
9//! ```text
10//! SystemWorld (per-compilation, ~lightweight)
11//! ├── root: PathBuf          // Project root for path resolution
12//! ├── main: FileId           // Entry point file ID
13//! ├── fonts: &'static Fonts  // → Global shared fonts
14//! ├── library: LibraryRef    // → Global or custom library
15//! └── now: Now               // Lazy datetime
16//!
17//! World trait methods:
18//! ├── library() → &GLOBAL_LIBRARY or custom
19//! ├── book()    → &fonts.book
20//! ├── main()    → main FileId
21//! ├── source()  → FileSlot cache
22//! ├── file()    → FileSlot cache
23//! ├── font()    → fonts.fonts[index]
24//! └── today()   → Now (lazy UTC)
25//! ```
26//!
27//! # Performance
28//!
29//! Creating a `SystemWorld` is cheap because:
30//! - Fonts, packages, and library are globally shared (static references)
31//! - File cache is global (not per-instance)
32//! - Datetime is lazily computed on first access
33//!
34//! # Custom Inputs (sys.inputs)
35//!
36//! For documents that need `sys.inputs`, use [`SystemWorld::with_inputs`]
37//! or create a custom library with [`crate::library::create_library_with_inputs`].
38
39use std::path::{Path, PathBuf};
40use std::sync::OnceLock;
41
42use chrono::{DateTime, Datelike, FixedOffset, Local, Utc};
43use typst::diag::FileResult;
44use typst::foundations::{Bytes, Datetime, Dict};
45use typst::syntax::{FileId, Source, VirtualPath};
46use typst::text::{Font, FontBook};
47use typst::utils::LazyHash;
48use typst::{Library, World};
49use typst_kit::fonts::Fonts;
50
51use crate::file::{FileSlot, VirtualFileSystem, GLOBAL_FILE_CACHE};
52use crate::font::get_fonts;
53use crate::library::{create_library_with_inputs, GLOBAL_LIBRARY};
54
55// =============================================================================
56// Path Utilities
57// =============================================================================
58
59/// Normalize a file system path to absolute form.
60///
61/// Tries `canonicalize()` first (resolves symlinks, `.`, `..`).
62/// Falls back to:
63/// - Return as-is if already absolute
64/// - Join with current directory if relative
65#[inline]
66fn normalize_path(path: &Path) -> PathBuf {
67    path.canonicalize().unwrap_or_else(|_| {
68        if path.is_absolute() {
69            path.to_path_buf()
70        } else {
71            std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
72        }
73    })
74}
75
76// =============================================================================
77// DateTime handling
78// =============================================================================
79
80/// Lazy-captured datetime for consistent `World::today()` within a compilation.
81///
82/// The current time is captured on first access and reused for consistency.
83struct LazyNow(OnceLock<DateTime<Utc>>);
84
85// =============================================================================
86// Library Reference
87// =============================================================================
88
89/// Reference to either global shared library or a custom library instance.
90///
91/// This allows `SystemWorld` to efficiently use the global library for batch
92/// compilation, while supporting custom libraries with `sys.inputs` for
93/// single document compilation.
94enum LibraryRef {
95    /// Reference to the global shared library (no inputs).
96    Global,
97    /// Custom library instance with specific inputs.
98    Custom(LazyHash<Library>),
99}
100
101impl LibraryRef {
102    /// Get a reference to the underlying library.
103    fn get(&self) -> &LazyHash<Library> {
104        match self {
105            Self::Global => &GLOBAL_LIBRARY,
106            Self::Custom(lib) => lib,
107        }
108    }
109}
110
111// =============================================================================
112// SystemWorld
113// =============================================================================
114
115/// A world that provides access to the operating system.
116///
117/// This struct is cheap to create because all expensive resources (fonts,
118/// packages, library, file cache) are globally shared.
119///
120/// # Thread Safety
121///
122/// `SystemWorld` is `Send + Sync` because all mutable state is behind
123/// thread-safe locks in global statics.
124///
125/// # Custom Inputs
126///
127/// By default, `SystemWorld` uses the global shared library (no `sys.inputs`).
128/// To provide custom inputs, use [`SystemWorld::with_inputs`]:
129///
130/// ```ignore
131/// let world = SystemWorld::new(path, root)
132///     .with_inputs([("title", "Hello"), ("author", "Alice")]);
133/// ```
134pub struct SystemWorld {
135    /// The root relative to which absolute paths are resolved.
136    /// This is typically the project directory containing `tola.toml`.
137    root: PathBuf,
138
139    /// The input path (main entry point).
140    /// This is the `FileId` of the file being compiled.
141    main: FileId,
142
143    /// Reference to global fonts (initialized on first use).
144    /// This is a static reference to avoid allocation per compilation.
145    fonts: &'static (Fonts, LazyHash<FontBook>),
146
147    /// Library reference - either global or custom with inputs.
148    library: LibraryRef,
149
150    /// The current datetime if requested.
151    /// Lazily initialized to ensure consistent time throughout compilation.
152    now: LazyNow,
153}
154
155impl SystemWorld {
156    /// Create a new world for compiling a specific file.
157    ///
158    /// This is cheap because fonts/packages/library/file-cache are globally shared.
159    /// No per-instance allocation is needed.
160    ///
161    /// # Arguments
162    ///
163    /// * `entry_file` - Path to the `.typ` file to compile
164    /// * `root_dir` - Project root directory for resolving imports
165    ///
166    /// # Returns
167    ///
168    /// A new `SystemWorld` ready for compilation.
169    pub fn new(entry_file: &Path, root_dir: &Path) -> Self {
170        // Canonicalize root path for consistent path resolution
171        let root = normalize_path(root_dir);
172
173        // Resolve the virtual path of the main file within the project root.
174        // Virtual paths are root-relative and use forward slashes.
175        let entry_abs = normalize_path(entry_file);
176        let virtual_path = VirtualPath::within_root(&entry_abs, &root)
177            .unwrap_or_else(|| VirtualPath::new(entry_file.file_name().unwrap()));
178        let main = FileId::new(None, virtual_path);
179
180        // Get global fonts. Fonts are already initialized via warmup_with_font_dirs().
181        // If not yet initialized, this returns an empty font set.
182        let fonts = get_fonts(&[]);
183
184        Self {
185            root,
186            main,
187            fonts,
188            library: LibraryRef::Global,
189            now: LazyNow(OnceLock::new()),
190        }
191    }
192
193    /// Configure `sys.inputs` for the compilation.
194    ///
195    /// This creates a custom library with the specified inputs accessible
196    /// via `sys.inputs` in Typst documents.
197    ///
198    /// # Arguments
199    ///
200    /// * `inputs` - Key-value pairs to make available as `sys.inputs`
201    ///
202    /// # Example
203    ///
204    /// ```ignore
205    /// use typst_batch::world::SystemWorld;
206    /// use std::path::Path;
207    ///
208    /// let world = SystemWorld::new(Path::new("doc.typ"), Path::new("."))
209    ///     .with_inputs([("title", "Hello"), ("author", "Alice")]);
210    /// ```
211    ///
212    /// In your Typst document:
213    /// ```typst
214    /// #let title = sys.inputs.at("title", default: "Untitled")
215    /// = #title
216    /// ```
217    ///
218    /// # Performance Note
219    ///
220    /// Using this method creates a new library instance, bypassing the global
221    /// shared library. For batch compilation without inputs, use [`SystemWorld::new`].
222    pub fn with_inputs<I, K, V>(mut self, inputs: I) -> Self
223    where
224        I: IntoIterator<Item = (K, V)>,
225        K: Into<typst::foundations::Str>,
226        V: typst::foundations::IntoValue,
227    {
228
229        let dict: Dict = inputs
230            .into_iter()
231            .map(|(k, v)| (k.into(), v.into_value()))
232            .collect();
233
234        self.library = LibraryRef::Custom(create_library_with_inputs(dict));
235        self
236    }
237
238    /// Configure `sys.inputs` from a pre-built `Dict`.
239    ///
240    /// This is useful when you already have a `Dict` of inputs.
241    ///
242    /// # Example
243    ///
244    /// ```ignore
245    /// use typst::foundations::{Dict, IntoValue};
246    ///
247    /// let mut inputs = Dict::new();
248    /// inputs.insert("version".into(), "1.0".into_value());
249    ///
250    /// let world = SystemWorld::new(path, root).with_inputs_dict(inputs);
251    /// ```
252    pub fn with_inputs_dict(mut self, inputs: Dict) -> Self {
253        self.library = LibraryRef::Custom(create_library_with_inputs(inputs));
254        self
255    }
256
257    /// Configure a custom library for the compilation.
258    ///
259    /// Use this for full control over the library configuration.
260    /// Create a custom library with [`crate::library::create_library_with_inputs`].
261    pub fn with_library(mut self, library: LazyHash<Library>) -> Self {
262        self.library = LibraryRef::Custom(library);
263        self
264    }
265
266    /// Get the project root directory.
267    pub fn root(&self) -> &Path {
268        &self.root
269    }
270
271    /// Access the canonical slot for the given file id from global cache.
272    ///
273    /// Creates a new slot if one doesn't exist. The callback receives
274    /// mutable access to the slot for reading source/file data.
275    #[allow(clippy::unused_self)]
276    fn slot<F, T>(&self, id: FileId, f: F) -> T
277    where
278        F: FnOnce(&mut FileSlot) -> T,
279    {
280        let mut cache = GLOBAL_FILE_CACHE.write();
281        f(cache.entry(id).or_insert_with(|| FileSlot::new(id)))
282    }
283
284    /// Access a file slot with virtual file system support.
285    #[allow(clippy::unused_self)]
286    fn slot_virtual<V, F, T>(&self, id: FileId, virtual_fs: &V, f: F) -> T
287    where
288        V: VirtualFileSystem,
289        F: FnOnce(&mut FileSlot, &V) -> T,
290    {
291        let mut cache = GLOBAL_FILE_CACHE.write();
292        let slot = cache.entry(id).or_insert_with(|| FileSlot::new(id));
293        f(slot, virtual_fs)
294    }
295
296    /// Load source with virtual file system support.
297    pub fn source_with_virtual<V: VirtualFileSystem>(
298        &self,
299        id: FileId,
300        virtual_fs: &V,
301    ) -> FileResult<Source> {
302        self.slot_virtual(id, virtual_fs, |slot, vfs| {
303            slot.source_with_virtual(&self.root, vfs)
304        })
305    }
306
307    /// Load file with virtual file system support.
308    pub fn file_with_virtual<V: VirtualFileSystem>(
309        &self,
310        id: FileId,
311        virtual_fs: &V,
312    ) -> FileResult<Bytes> {
313        self.slot_virtual(id, virtual_fs, |slot, vfs| {
314            slot.file_with_virtual(&self.root, vfs)
315        })
316    }
317}
318
319/// Implementation of Typst's `World` trait.
320///
321/// This trait provides the compilation environment:
322/// - Standard library access
323/// - Font discovery
324/// - File system access
325/// - Package management (via file resolution)
326/// - Current date/time
327impl World for SystemWorld {
328    /// Returns the standard library.
329    ///
330    /// Returns either the global shared library or a custom library
331    /// with `sys.inputs` if configured via [`SystemWorld::with_inputs`].
332    fn library(&self) -> &LazyHash<Library> {
333        self.library.get()
334    }
335
336    /// Returns the font book for font lookup.
337    ///
338    /// The font book indexes all available fonts for name-based lookup.
339    fn book(&self) -> &LazyHash<FontBook> {
340        &self.fonts.1
341    }
342
343    /// Returns the main source file ID.
344    ///
345    /// This is the entry point for compilation.
346    fn main(&self) -> FileId {
347        self.main
348    }
349
350    /// Load a source file by ID.
351    ///
352    /// Returns the parsed source code, using the file slot cache
353    /// for incremental compilation.
354    ///
355    /// Uses the global virtual data provider registered via
356    /// [`crate::file::set_virtual_provider`].
357    fn source(&self, id: FileId) -> FileResult<Source> {
358        self.slot(id, |slot| slot.source_with_global_virtual(&self.root))
359    }
360
361    /// Load a file's raw bytes by ID.
362    ///
363    /// Used for binary files (images, etc.) that don't need parsing.
364    ///
365    /// Uses the global virtual data provider registered via
366    /// [`crate::file::set_virtual_provider`].
367    fn file(&self, id: FileId) -> FileResult<Bytes> {
368        self.slot(id, |slot| slot.file_with_global_virtual(&self.root))
369    }
370
371    /// Load a font by index.
372    ///
373    /// Fonts are indexed in the order they were discovered during
374    /// font search. The index comes from font book lookups.
375    fn font(&self, index: usize) -> Option<Font> {
376        self.fonts.0.fonts.get(index)?.get()
377    }
378
379    /// Get the current date.
380    ///
381    /// Returns the date at the time of first access within this compilation.
382    /// The time is captured once and reused for consistency.
383    ///
384    /// # Arguments
385    ///
386    /// * `offset` - Optional UTC offset in hours. If `None`, uses local timezone.
387    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
388        let now = self.now.0.get_or_init(Utc::now);
389
390        // Apply timezone offset
391        let with_offset = match offset {
392            None => now.with_timezone(&Local).fixed_offset(),
393            Some(hours) => {
394                let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?;
395                now.with_timezone(&FixedOffset::east_opt(seconds)?)
396            }
397        };
398
399        // Convert to Typst's Datetime type
400        Datetime::from_ymd(
401            with_offset.year(),
402            with_offset.month().try_into().ok()?,
403            with_offset.day().try_into().ok()?,
404        )
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::fs;
412    use tempfile::TempDir;
413
414    #[test]
415    fn test_create_system_world() {
416        let dir = TempDir::new().unwrap();
417        let file_path = dir.path().join("test.typ");
418        fs::write(&file_path, "= Hello").unwrap();
419
420        let _world = SystemWorld::new(&file_path, dir.path());
421        // If we get here without panicking, the world was created successfully
422    }
423
424    #[test]
425    fn test_world_library_access() {
426        let dir = TempDir::new().unwrap();
427        let file_path = dir.path().join("test.typ");
428        fs::write(&file_path, "= Hello").unwrap();
429
430        let world = SystemWorld::new(&file_path, dir.path());
431        let _lib = world.library();
432        // Should not panic
433    }
434
435    #[test]
436    fn test_world_book_access() {
437        let dir = TempDir::new().unwrap();
438        let file_path = dir.path().join("test.typ");
439        fs::write(&file_path, "= Hello").unwrap();
440
441        let world = SystemWorld::new(&file_path, dir.path());
442        let book = world.book();
443        // Should have some fonts
444        assert!(book.families().count() > 0);
445    }
446
447    #[test]
448    fn test_world_main_file() {
449        let dir = TempDir::new().unwrap();
450        let file_path = dir.path().join("test.typ");
451        fs::write(&file_path, "= Hello").unwrap();
452
453        let world = SystemWorld::new(&file_path, dir.path());
454        let main = world.main();
455        // Main file should have the correct virtual path
456        assert!(main.vpath().as_rootless_path().ends_with("test.typ"));
457    }
458
459    #[test]
460    fn test_world_source_loading() {
461        let dir = TempDir::new().unwrap();
462        let file_path = dir.path().join("test.typ");
463        fs::write(&file_path, "= Hello World").unwrap();
464
465        let world = SystemWorld::new(&file_path, dir.path());
466        let source = world.source(world.main());
467        assert!(source.is_ok());
468        assert!(source.unwrap().text().contains("Hello World"));
469    }
470
471    #[test]
472    fn test_world_today() {
473        let dir = TempDir::new().unwrap();
474        let file_path = dir.path().join("test.typ");
475        fs::write(&file_path, "= Hello").unwrap();
476
477        let world = SystemWorld::new(&file_path, dir.path());
478
479        // Test with local timezone
480        let today = world.today(None);
481        assert!(today.is_some());
482
483        // Test with UTC offset
484        let today_utc = world.today(Some(0));
485        assert!(today_utc.is_some());
486    }
487
488    #[test]
489    fn test_world_font_access() {
490        let dir = TempDir::new().unwrap();
491        let file_path = dir.path().join("test.typ");
492        fs::write(&file_path, "= Hello").unwrap();
493
494        let world = SystemWorld::new(&file_path, dir.path());
495
496        // Should be able to access at least one font
497        let font = world.font(0);
498        // May be None in minimal environments, but shouldn't panic
499        let _ = font;
500    }
501}