Skip to main content

typst_batch/process/
batch.rs

1//! Batch compilation with shared snapshot.
2//!
3//! Use `Batcher` for:
4//! - **Batch compile**: Parallel compilation with shared file snapshot
5//! - **Scan + Compile**: Avoid per-file Scanner overhead; Eval cache from `batch_scan`
6//!   is reused by `batch_compile`, so Layout is the only extra cost
7//!
8//! Use `BatchScanner` for:
9//! - **Batch scan only**: Lightweight parallel scanning without font loading
10//!   (e.g., query metadata, validate links)
11//!
12//! # Example
13//!
14//! ```ignore
15//! // Scan + Compile workflow
16//! let batcher = Batcher::new(root).with_snapshot_from(&files)?;
17//! let scans = batcher.batch_scan(&files)?;
18//! let non_drafts = filter_non_drafts(&files, &scans);
19//! let results = batcher.batch_compile(&non_drafts)?;
20//!
21//! // Scan-only workflow (no fonts, faster)
22//! let scanner = BatchScanner::new(root).with_snapshot_from(&files)?;
23//! let scans = scanner.batch_scan(&files)?;
24//! ```
25
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use typst::foundations::Dict;
30
31use crate::codegen::json_to_simple_value;
32use crate::diagnostic::CompileError;
33use crate::world::{FileSnapshot, SnapshotConfig, TypstWorld};
34
35use super::compile::{compile_with_world, CompileResult};
36use super::inputs::WithInputs;
37#[cfg(feature = "scan")]
38use super::scan::{scan_impl, ScanResult};
39
40
41/// Batch compiler with shared file snapshot.
42///
43/// Provides `batch_scan()` and `batch_compile()` for parallel processing.
44/// When used together, Eval cache from scan is reused during compile.
45pub struct Batcher<'a> {
46    root: &'a Path,
47    inputs: Option<Dict>,
48    pub(crate) preludes: Vec<String>,
49    pub(crate) postludes: Vec<String>,
50    snapshot: Option<Arc<FileSnapshot>>,
51}
52
53impl<'a> WithInputs for Batcher<'a> {
54    fn inputs_mut(&mut self) -> &mut Option<Dict> {
55        &mut self.inputs
56    }
57}
58
59impl<'a> Batcher<'a> {
60    /// Create a new batcher with the given root directory.
61    pub fn new(root: &'a Path) -> Self {
62        Self {
63            root,
64            inputs: None,
65            preludes: Vec::new(),
66            postludes: Vec::new(),
67            snapshot: None,
68        }
69    }
70
71    /// Create a lightweight scanner for scan-only workflows.
72    ///
73    /// Returns a [`BatchScanner`] which only exposes `batch_scan()` (no `batch_compile()`).
74    /// Uses no fonts and is optimized for query/validate scenarios.
75    pub fn for_scan(root: &'a Path) -> BatchScanner<'a> {
76        BatchScanner::new(root)
77    }
78
79    /// Add prelude code to inject at the beginning of each main file.
80    pub fn with_prelude(mut self, prelude: impl Into<String>) -> Self {
81        self.preludes.push(prelude.into());
82        self
83    }
84
85    /// Add postlude code to inject at the end of each main file.
86    pub fn with_postlude(mut self, postlude: impl Into<String>) -> Self {
87        self.postludes.push(postlude.into());
88        self
89    }
90
91    /// Pre-build a snapshot from files for efficient multi-phase compilation.
92    ///
93    /// The snapshot caches all files and their imports, enabling lock-free
94    /// parallel access. Call `batch_scan()` and `batch_compile()` to reuse it.
95    ///
96    /// Files not in the snapshot will fall back to thread-local cache.
97    ///
98    /// Note: Prelude/postlude must be set before calling this method for them
99    /// to be injected into the snapshot.
100    pub fn with_snapshot_from<P: AsRef<Path>>(self, paths: &[P]) -> Result<Self, CompileError> {
101        self.with_snapshot_from_each(paths, |_| {})
102    }
103
104    /// Pre-build a snapshot from files with a callback for each file loaded.
105    ///
106    /// Like `with_snapshot_from`, but invokes the callback once per content file
107    /// during snapshot construction. Useful for progress tracking.
108    ///
109    /// Note: Prelude/postlude must be set before calling this method for them
110    /// to be injected into the snapshot.
111    pub fn with_snapshot_from_each<P, F>(mut self, paths: &[P], on_each: F) -> Result<Self, CompileError>
112    where
113        P: AsRef<Path>,
114        F: Fn(&Path) + Sync,
115    {
116        if paths.is_empty() {
117            return Ok(self);
118        }
119
120        let path_bufs: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
121
122        // Build snapshot with prelude/postlude injection
123        let config = SnapshotConfig {
124            prelude: self.build_prelude_opt(),
125            postlude: self.build_postlude_opt(),
126        };
127
128        let snapshot = Arc::new(FileSnapshot::build_with_config(&path_bufs, self.root, &config, on_each)?);
129        self.snapshot = Some(snapshot);
130
131        Ok(self)
132    }
133
134    /// Use an existing snapshot for compilation.
135    ///
136    /// This allows sharing a snapshot between multiple `Batcher` instances.
137    pub fn with_snapshot(mut self, snapshot: Arc<FileSnapshot>) -> Self {
138        self.snapshot = Some(snapshot);
139        self
140    }
141
142    /// Get the current snapshot, if any.
143    ///
144    /// Returns `None` if `with_snapshot_from()` or `with_snapshot()` hasn't been called.
145    pub fn snapshot(&self) -> Option<Arc<FileSnapshot>> {
146        self.snapshot.clone()
147    }
148
149    /// Scan multiple files in parallel (Eval-only, skips Layout).
150    ///
151    /// Uses the same snapshot as `batch_compile`, enabling comemo cache reuse.
152    /// Call this before `batch_compile` to filter files (e.g., skip drafts)
153    /// without paying the Layout cost twice.
154    ///
155    /// # Example
156    ///
157    /// ```ignore
158    /// let batcher = Batcher::new(root).with_snapshot_from(&files)?;
159    ///
160    /// // Phase 1: Scan (Eval cached by comemo)
161    /// let scans = batcher.batch_scan(&files)?;
162    /// let non_drafts: Vec<_> = files.iter()
163    ///     .zip(&scans)
164    ///     .filter(|(_, r)| !is_draft(r))
165    ///     .map(|(p, _)| p)
166    ///     .collect();
167    ///
168    /// // Phase 2: Compile (Eval cache hit, only Layout runs)
169    /// let results = batcher.batch_compile(&non_drafts)?;
170    /// ```
171    #[cfg(feature = "scan")]
172    pub fn batch_scan<P: AsRef<Path> + Sync>(
173        &self,
174        paths: &[P],
175    ) -> Result<Vec<Result<ScanResult, CompileError>>, CompileError> {
176        use rayon::prelude::*;
177
178        if paths.is_empty() {
179            return Ok(vec![]);
180        }
181
182        let snapshot = self.get_or_build_snapshot(paths)?;
183
184        // Scan in parallel with lock-free snapshot access
185        let results: Vec<_> = paths
186            .par_iter()
187            .map(|path| {
188                let path = path.as_ref();
189                let world = self.build_world(path, &snapshot);
190                scan_impl(&world)
191            })
192            .collect();
193
194        Ok(results)
195    }
196
197    /// Compile multiple files in parallel.
198    ///
199    /// If `with_snapshot_from()` was called, reuses the pre-built snapshot.
200    /// Otherwise, builds a new snapshot from the provided paths.
201    ///
202    /// Returns results in the same order as input paths.
203    pub fn batch_compile<P: AsRef<Path> + Sync>(
204        &self,
205        paths: &[P],
206    ) -> Result<Vec<Result<CompileResult, CompileError>>, CompileError> {
207        self.batch_compile_each(paths, |_| {})
208    }
209
210    /// Compile multiple files in parallel with callback for each file.
211    ///
212    /// Like `batch_compile`, but invokes the callback once per file compiled.
213    /// Useful for progress tracking.
214    pub fn batch_compile_each<P, F>(
215        &self,
216        paths: &[P],
217        on_each: F,
218    ) -> Result<Vec<Result<CompileResult, CompileError>>, CompileError>
219    where
220        P: AsRef<Path> + Sync,
221        F: Fn(&Path) + Sync,
222    {
223        use rayon::prelude::*;
224
225        if paths.is_empty() {
226            return Ok(vec![]);
227        }
228
229        let snapshot = self.get_or_build_snapshot(paths)?;
230
231        // Compile in parallel with lock-free snapshot access
232        let results: Vec<_> = paths
233            .par_iter()
234            .map(|path| {
235                let path = path.as_ref();
236                let world = self.build_world(path, &snapshot);
237                let result = compile_with_world(&world);
238                on_each(path);
239                result
240            })
241            .collect();
242
243        Ok(results)
244    }
245
246    /// Compile multiple files in parallel with per-file context.
247    ///
248    /// For each file, `context_fn` is called to generate additional inputs
249    /// that are merged with the base inputs. This enables injecting per-file
250    /// data (e.g., navigation context, related pages) into `sys.inputs`.
251    ///
252    /// # Arguments
253    ///
254    /// * `paths` - Files to compile
255    /// * `context_fn` - Function that returns per-file context as JSON.
256    ///   The JSON object's keys are merged into `sys.inputs`.
257    ///
258    /// # Example
259    ///
260    /// ```ignore
261    /// use serde_json::json;
262    ///
263    /// let batcher = Batcher::new(root)
264    ///     .with_inputs_obj(global_inputs)
265    ///     .with_snapshot_from(&files)?;
266    ///
267    /// let results = batcher.batch_compile_with_context(&files, |path| {
268    ///     json!({
269    ///         "current_file": path.to_string_lossy(),
270    ///         "custom_data": get_data_for(path),
271    ///     })
272    /// })?;
273    /// ```
274    pub fn batch_compile_with_context<P, F>(
275        &self,
276        paths: &[P],
277        context_fn: F,
278    ) -> Result<Vec<Result<CompileResult, CompileError>>, CompileError>
279    where
280        P: AsRef<Path> + Sync,
281        F: Fn(&Path) -> serde_json::Value + Sync,
282    {
283        use rayon::prelude::*;
284
285        if paths.is_empty() {
286            return Ok(vec![]);
287        }
288
289        let snapshot = self.get_or_build_snapshot(paths)?;
290
291        // Compile in parallel with per-file context
292        let results: Vec<_> = paths
293            .par_iter()
294            .map(|path| {
295                let path = path.as_ref();
296                let context_json = context_fn(path);
297                let world = self.build_world_with_context(path, &snapshot, &context_json);
298                compile_with_world(&world)
299            })
300            .collect();
301
302        Ok(results)
303    }
304
305    fn build_world(&self, path: &Path, snapshot: &Arc<FileSnapshot>) -> TypstWorld {
306        let mut builder = TypstWorld::builder(path, self.root)
307            .with_snapshot(snapshot.clone())
308            .with_fonts();
309
310        if let Some(inputs) = &self.inputs {
311            builder = builder.with_inputs_dict(inputs.clone());
312        }
313
314        // Pass prelude for line offset calculation in diagnostics
315        // (content is already injected into snapshot, but TypstWorld needs it for prelude_line_count)
316        if let Some(prelude) = self.build_prelude_opt() {
317            builder = builder.with_prelude(&prelude);
318        }
319
320        builder.build()
321    }
322
323    fn build_prelude_opt(&self) -> Option<String> {
324        if self.preludes.is_empty() {
325            None
326        } else {
327            Some(self.preludes.join("\n"))
328        }
329    }
330
331    fn build_postlude_opt(&self) -> Option<String> {
332        if self.postludes.is_empty() {
333            None
334        } else {
335            Some(self.postludes.join("\n"))
336        }
337    }
338
339    fn get_or_build_snapshot<P: AsRef<Path>>(
340        &self,
341        paths: &[P],
342    ) -> Result<Arc<FileSnapshot>, CompileError> {
343        match &self.snapshot {
344            Some(s) => Ok(s.clone()),
345            None => {
346                let path_bufs: Vec<PathBuf> =
347                    paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
348                let config = SnapshotConfig {
349                    prelude: self.build_prelude_opt(),
350                    postlude: self.build_postlude_opt(),
351                };
352                Ok(Arc::new(FileSnapshot::build_with_config(&path_bufs, self.root, &config, |_| {})?))
353            }
354        }
355    }
356
357    fn build_world_with_context(
358        &self,
359        path: &Path,
360        snapshot: &Arc<FileSnapshot>,
361        context_json: &serde_json::Value,
362    ) -> TypstWorld {
363        // Start with base inputs or empty
364        let mut merged = self.inputs.clone().unwrap_or_default();
365
366        // Merge context JSON into inputs
367        if let Some(obj) = context_json.as_object() {
368            for (key, value) in obj {
369                if let Ok(typst_value) = json_to_simple_value(value) {
370                    merged.insert(key.as_str().into(), typst_value);
371                }
372            }
373        }
374
375        // Pass prelude for line offset calculation in diagnostics
376        let mut builder = TypstWorld::builder(path, self.root)
377            .with_snapshot(snapshot.clone())
378            .with_fonts()
379            .with_inputs_dict(merged);
380
381        if let Some(prelude) = self.build_prelude_opt() {
382            builder = builder.with_prelude(&prelude);
383        }
384
385        builder.build()
386    }
387}
388
389
390
391/// Lightweight batch scanner without font loading.
392///
393/// Use this for scan-only workflows (query, validate) where Layout is not needed.
394/// Does not expose `batch_compile()` - use [`Batcher`] if you need compilation.
395pub struct BatchScanner<'a> {
396    root: &'a Path,
397    inputs: Option<Dict>,
398    snapshot: Option<Arc<FileSnapshot>>,
399    prelude: Option<String>,
400}
401
402impl<'a> WithInputs for BatchScanner<'a> {
403    fn inputs_mut(&mut self) -> &mut Option<Dict> {
404        &mut self.inputs
405    }
406}
407
408impl<'a> BatchScanner<'a> {
409    /// Create a new batch scanner with the given root directory.
410    pub fn new(root: &'a Path) -> Self {
411        Self {
412            root,
413            inputs: None,
414            snapshot: None,
415            prelude: None,
416        }
417    }
418
419    /// Add prelude code to inject at the beginning of each main file.
420    pub fn with_prelude(mut self, prelude: impl Into<String>) -> Self {
421        self.prelude = Some(prelude.into());
422        self
423    }
424
425    /// Pre-build a snapshot from files for efficient batch scanning.
426    pub fn with_snapshot_from<P: AsRef<Path>>(mut self, paths: &[P]) -> Result<Self, CompileError> {
427        if paths.is_empty() {
428            return Ok(self);
429        }
430
431        let path_bufs: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
432        let config = SnapshotConfig {
433            prelude: self.prelude.clone(),
434            postlude: None,
435        };
436        let snapshot = Arc::new(FileSnapshot::build_with_config(&path_bufs, self.root, &config, |_| {})?);
437        self.snapshot = Some(snapshot);
438
439        Ok(self)
440    }
441
442    /// Scan multiple files in parallel (Eval-only, no fonts).
443    #[cfg(feature = "scan")]
444    pub fn batch_scan<P: AsRef<Path> + Sync>(
445        &self,
446        paths: &[P],
447    ) -> Result<Vec<Result<ScanResult, CompileError>>, CompileError> {
448        use rayon::prelude::*;
449
450        if paths.is_empty() {
451            return Ok(vec![]);
452        }
453
454        // Use existing snapshot or build a new one
455        let snapshot = match &self.snapshot {
456            Some(s) => s.clone(),
457            None => {
458                let path_bufs: Vec<PathBuf> =
459                    paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
460                let config = SnapshotConfig {
461                    prelude: self.prelude.clone(),
462                    postlude: None,
463                };
464                Arc::new(FileSnapshot::build_with_config(&path_bufs, self.root, &config, |_| {})?)
465            }
466        };
467
468        // Scan in parallel with lightweight world (no fonts)
469        let results: Vec<_> = paths
470            .par_iter()
471            .map(|path| {
472                let path = path.as_ref();
473                let world = self.build_world(path, &snapshot);
474                scan_impl(&world)
475            })
476            .collect();
477
478        Ok(results)
479    }
480
481    fn build_world(&self, path: &Path, snapshot: &Arc<FileSnapshot>) -> TypstWorld {
482        let mut builder = TypstWorld::builder(path, self.root)
483            .with_snapshot(snapshot.clone())
484            .no_fonts();
485
486        if let Some(inputs) = &self.inputs {
487            builder = builder.with_inputs_dict(inputs.clone());
488        }
489
490        // Pass prelude for line offset calculation in diagnostics
491        if let Some(prelude) = &self.prelude {
492            builder = builder.with_prelude(prelude);
493        }
494
495        builder.build()
496    }
497}