Skip to main content

mathtex_engine/
format.rs

1use alloc::format;
2use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4
5use crate::profile::ProfileId;
6use crate::resource::ResourceProvider;
7
8/// Cached initialized macro/package state for an engine profile.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct FormatImage {
11    /// Format identifier string.
12    pub id: String,
13    /// Engine profile this format targets.
14    pub profile: ProfileId,
15    /// Packages incorporated during format initialization.
16    pub packages: Vec<FormatPackage>,
17    /// Opaque initialized state snapshot.
18    pub state: FormatState,
19}
20
21impl FormatImage {
22    /// Create an empty format image for early bootstrapping and tests.
23    #[must_use]
24    pub fn empty(id: impl Into<String>, profile: ProfileId) -> Self {
25        Self {
26            id: id.into(),
27            profile,
28            packages: Vec::new(),
29            state: FormatState::Empty,
30        }
31    }
32
33    /// Create a format image wrapping an already-built structured snapshot.
34    #[must_use]
35    pub fn with_snapshot(
36        id: impl Into<String>,
37        profile: ProfileId,
38        snapshot: FormatSnapshot,
39    ) -> Self {
40        Self {
41            id: id.into(),
42            profile,
43            packages: snapshot.packages.clone(),
44            state: FormatState::Structured(snapshot),
45        }
46    }
47
48    /// Create a [`FormatInitializer`] builder for this format id and profile.
49    #[must_use]
50    pub fn initializer(id: impl Into<String>, profile: ProfileId) -> FormatInitializer {
51        FormatInitializer::new(id, profile)
52    }
53
54    /// Start building the plain TeX format; the provider must supply `plain.tex`.
55    #[must_use]
56    pub fn plain(profile: ProfileId) -> FormatInitializer {
57        Self::initializer("plain", profile).preload_tex_input("plain.tex")
58    }
59
60    /// Start building the LaTeX format; the provider must supply `latex.ltx`.
61    #[must_use]
62    pub fn latex(profile: ProfileId) -> FormatInitializer {
63        Self::initializer("latex", profile).preload_tex_input("latex.ltx")
64    }
65
66    /// Clone the format's initialized state into a fresh mutable `SessionState`.
67    #[must_use]
68    pub fn instantiate_session_state(&self) -> SessionState {
69        match &self.state {
70            FormatState::Empty => SessionState::default(),
71            FormatState::Snapshot(bytes) => SessionState {
72                opaque_engine_state: bytes.clone(),
73                ..SessionState::default()
74            },
75            FormatState::Structured(snapshot) => SessionState {
76                macros: snapshot.macros.clone(),
77                packages: snapshot.packages.clone(),
78                resources: snapshot.resources.clone(),
79                opaque_engine_state: snapshot.opaque_engine_state.clone(),
80                registers: snapshot.registers.clone(),
81            },
82        }
83    }
84}
85
86/// Builder for a cached [`FormatImage`] initialized from package resources.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct FormatInitializer {
89    id: String,
90    profile: ProfileId,
91    tex_inputs: Vec<String>,
92    packages: Vec<String>,
93    macros: Vec<MacroDefinition>,
94    registers: RegisterSnapshot,
95    opaque_engine_state: Vec<u8>,
96}
97
98impl FormatInitializer {
99    /// Create a new empty format initializer for the given id and profile.
100    #[must_use]
101    pub fn new(id: impl Into<String>, profile: ProfileId) -> Self {
102        Self {
103            id: id.into(),
104            profile,
105            tex_inputs: Vec::new(),
106            packages: Vec::new(),
107            macros: Vec::new(),
108            registers: RegisterSnapshot::default(),
109            opaque_engine_state: Vec::new(),
110        }
111    }
112
113    /// Queue a TeX input file to load during format initialization.
114    #[must_use]
115    pub fn preload_tex_input(mut self, input: impl Into<String>) -> Self {
116        self.tex_inputs.push(input.into());
117        self
118    }
119
120    /// Queue a package style file to load during format initialization.
121    #[must_use]
122    pub fn preload_package(mut self, package: impl Into<String>) -> Self {
123        self.packages.push(package.into());
124        self
125    }
126
127    /// Add a macro definition to embed in the format.
128    #[must_use]
129    pub fn macro_definition(
130        mut self,
131        name: impl Into<String>,
132        replacement: impl Into<Vec<u8>>,
133    ) -> Self {
134        self.macros.push(MacroDefinition {
135            name: name.into(),
136            replacement: replacement.into(),
137        });
138        self
139    }
140
141    /// Set the initial register snapshot to embed in the format.
142    #[must_use]
143    pub fn registers(mut self, registers: RegisterSnapshot) -> Self {
144        self.registers = registers;
145        self
146    }
147
148    /// Append opaque engine seed bytes to embed in the format.
149    #[must_use]
150    pub fn opaque_engine_state(mut self, bytes: impl Into<Vec<u8>>) -> Self {
151        self.opaque_engine_state.extend(bytes.into());
152        self
153    }
154
155    /// Build the cached format by resolving preloaded resources once.
156    pub fn build<R>(self, resources: &R) -> Result<FormatImage, FormatInitError>
157    where
158        R: ResourceProvider,
159    {
160        let mut packages = Vec::with_capacity(self.packages.len());
161        let mut format_resources = Vec::with_capacity(self.tex_inputs.len() + self.packages.len());
162        let mut opaque_engine_state = self.opaque_engine_state;
163
164        for input in self.tex_inputs {
165            let resource =
166                resources
167                    .read_tex_input(&input)
168                    .map_err(|error| FormatInitError::TexInput {
169                        name: input.clone(),
170                        message: format!("{error:?}"),
171                    })?;
172            opaque_engine_state.extend(&resource.bytes);
173            format_resources.push(FormatResource {
174                name: resource.canonical_name,
175                kind: FormatResourceKind::TexInput,
176                bytes: resource.bytes,
177            });
178        }
179
180        for package in self.packages {
181            let resource_name = package_resource_name(&package);
182            let resource = resources.read_package(&resource_name).map_err(|error| {
183                FormatInitError::Package {
184                    name: resource_name.clone(),
185                    message: format!("{error:?}"),
186                }
187            })?;
188            let package = FormatPackage {
189                name: package,
190                version: None,
191            };
192            opaque_engine_state.extend(&resource.bytes);
193            format_resources.push(FormatResource {
194                name: resource.canonical_name,
195                kind: FormatResourceKind::Package,
196                bytes: resource.bytes,
197            });
198            packages.push(package);
199        }
200
201        let snapshot = FormatSnapshot {
202            macros: self.macros,
203            packages,
204            resources: format_resources,
205            opaque_engine_state,
206            registers: self.registers,
207        };
208
209        Ok(FormatImage::with_snapshot(self.id, self.profile, snapshot))
210    }
211}
212
213/// Package that contributed to a cached format.
214#[derive(Clone, Debug, PartialEq, Eq)]
215pub struct FormatPackage {
216    /// Package name.
217    pub name: String,
218    /// Package version, if known.
219    pub version: Option<String>,
220}
221
222/// Structured initialized format state that can be cloned into sessions.
223#[derive(Clone, Debug, Default, PartialEq, Eq)]
224pub struct FormatSnapshot {
225    /// Macro definitions or macro state records preloaded into the format.
226    pub macros: Vec<MacroDefinition>,
227    /// Packages incorporated during format initialization.
228    pub packages: Vec<FormatPackage>,
229    /// Resources captured during format initialization.
230    pub resources: Vec<FormatResource>,
231    /// Opaque bytes for generated engine state during bootstrap.
232    pub opaque_engine_state: Vec<u8>,
233    /// Register values captured at format initialization time.
234    pub registers: RegisterSnapshot,
235}
236
237/// Resource captured while initializing a cached format.
238#[derive(Clone, Debug, PartialEq, Eq)]
239pub struct FormatResource {
240    /// Canonical resource name.
241    pub name: String,
242    /// Role of this resource in the format.
243    pub kind: FormatResourceKind,
244    /// Resource bytes captured at format initialization time.
245    pub bytes: Vec<u8>,
246}
247
248/// Resource role captured in a cached format.
249#[derive(Clone, Copy, Debug, PartialEq, Eq)]
250#[non_exhaustive]
251pub enum FormatResourceKind {
252    /// A TeX input file loaded during format initialization.
253    TexInput,
254    /// A package style file loaded during format initialization.
255    Package,
256}
257
258/// Macro definition captured in a cached format.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct MacroDefinition {
261    /// Control sequence name without leading backslash.
262    pub name: String,
263    /// Replacement text or opaque translated representation.
264    pub replacement: Vec<u8>,
265}
266
267/// Register values captured in a cached format.
268#[derive(Clone, Debug, Default, PartialEq, Eq)]
269pub struct RegisterSnapshot {
270    /// Snapshot of TeX count registers.
271    pub counts: Vec<i32>,
272    /// Snapshot of TeX dimension registers in scaled points.
273    pub dimensions: Vec<i32>,
274    /// Snapshot of TeX token list registers as opaque bytes.
275    pub token_lists: Vec<Vec<u8>>,
276}
277
278/// Mutable session state cloned from [`FormatImage`].
279#[derive(Clone, Debug, Default, PartialEq, Eq)]
280pub struct SessionState {
281    /// Macro definitions active in this session.
282    pub macros: Vec<MacroDefinition>,
283    /// Packages loaded into this session.
284    pub packages: Vec<FormatPackage>,
285    /// Resources captured for this session.
286    pub resources: Vec<FormatResource>,
287    /// Opaque engine state bytes for this session.
288    pub opaque_engine_state: Vec<u8>,
289    /// Register values active in this session.
290    pub registers: RegisterSnapshot,
291}
292
293impl SessionState {
294    /// Add or replace a macro in the session state.
295    pub fn define_macro(&mut self, name: impl Into<String>, replacement: impl Into<Vec<u8>>) {
296        let name = name.into();
297        let replacement = replacement.into();
298        if let Some(existing) = self
299            .macros
300            .iter_mut()
301            .find(|macro_def| macro_def.name == name)
302        {
303            existing.replacement = replacement;
304            return;
305        }
306
307        self.macros.push(MacroDefinition { name, replacement });
308    }
309
310    /// Look up a macro definition by control sequence name.
311    #[must_use]
312    pub fn macro_definition(&self, name: &str) -> Option<&MacroDefinition> {
313        self.macros.iter().find(|macro_def| macro_def.name == name)
314    }
315
316    /// Look up a loaded package by name.
317    #[must_use]
318    pub fn package(&self, name: &str) -> Option<&FormatPackage> {
319        self.packages.iter().find(|package| package.name == name)
320    }
321
322    /// Look up a captured resource by canonical name.
323    #[must_use]
324    pub fn resource(&self, name: &str) -> Option<&FormatResource> {
325        self.resources.iter().find(|resource| resource.name == name)
326    }
327}
328
329/// Format initialization failure.
330#[derive(Clone, Debug, PartialEq, Eq)]
331#[non_exhaustive]
332pub enum FormatInitError {
333    /// A requested TeX input could not be loaded.
334    TexInput {
335        /// Name of the TeX input file.
336        name: String,
337        /// Description of the load failure.
338        message: String,
339    },
340    /// A requested package could not be loaded.
341    Package {
342        /// Name of the package resource file.
343        name: String,
344        /// Description of the load failure.
345        message: String,
346    },
347}
348
349fn package_resource_name(name: &str) -> String {
350    if name.ends_with(".sty") {
351        name.to_string()
352    } else {
353        format!("{name}.sty")
354    }
355}
356
357/// Opaque initialized state snapshot.
358#[derive(Clone, Debug, Default, PartialEq, Eq)]
359#[non_exhaustive]
360pub enum FormatState {
361    /// Empty state used for bootstrapping and tests.
362    #[default]
363    Empty,
364    /// Opaque engine bytes; reserved for a future structured snapshot.
365    Snapshot(Vec<u8>),
366    /// Structured snapshot used to initialize each session state.
367    Structured(FormatSnapshot),
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::profile::ProfileId;
374    use crate::{InMemoryResourceProvider, ResourceKind};
375
376    #[test]
377    fn format_image_instantiates_mutable_session_state() {
378        let snapshot = FormatSnapshot {
379            macros: alloc::vec![MacroDefinition {
380                name: "text".into(),
381                replacement: b"text macro".to_vec(),
382            }],
383            packages: alloc::vec![FormatPackage {
384                name: "latex".into(),
385                version: Some("2026".into()),
386            }],
387            resources: alloc::vec![FormatResource {
388                name: "plain.tex".into(),
389                kind: FormatResourceKind::TexInput,
390                bytes: b"plain".to_vec(),
391            }],
392            opaque_engine_state: b"engine".to_vec(),
393            registers: RegisterSnapshot {
394                counts: alloc::vec![1, 2],
395                dimensions: alloc::vec![3],
396                token_lists: alloc::vec![b"tokens".to_vec()],
397            },
398        };
399        let format = FormatImage::with_snapshot("latex", ProfileId("tex"), snapshot);
400
401        let mut session = format.instantiate_session_state();
402        session.define_macro("text", b"changed".to_vec());
403        session.define_macro("frac", b"fraction".to_vec());
404
405        assert_eq!(
406            session
407                .macro_definition("text")
408                .expect("text macro")
409                .replacement,
410            b"changed".to_vec()
411        );
412        assert_eq!(
413            session
414                .macro_definition("frac")
415                .expect("frac macro")
416                .replacement,
417            b"fraction".to_vec()
418        );
419        assert_eq!(
420            session.resource("plain.tex").expect("plain resource").bytes,
421            b"plain".to_vec()
422        );
423        assert_eq!(session.registers.counts, alloc::vec![1, 2]);
424
425        match &format.state {
426            FormatState::Structured(snapshot) => {
427                assert_eq!(snapshot.macros[0].replacement, b"text macro".to_vec());
428                assert_eq!(snapshot.resources[0].bytes, b"plain".to_vec());
429                assert_eq!(snapshot.registers.counts, alloc::vec![1, 2]);
430            }
431            other => panic!("unexpected format state: {other:?}"),
432        }
433    }
434
435    #[test]
436    fn initializer_preloads_packages_through_resource_provider() {
437        let resources = InMemoryResourceProvider::new()
438            .with_resource("latex.ltx.sty", ResourceKind::Package, b"latex")
439            .with_resource("amsmath.sty", ResourceKind::Package, b"ams");
440
441        let format = FormatImage::initializer("latex+amsmath", ProfileId("tex"))
442            .preload_package("latex.ltx.sty")
443            .preload_package("amsmath")
444            .macro_definition("text", b"text macro")
445            .opaque_engine_state(b"seed".to_vec())
446            .build(&resources)
447            .expect("format should initialize");
448
449        assert_eq!(format.packages.len(), 2);
450        assert_eq!(format.packages[1].name, "amsmath");
451        let session = format.instantiate_session_state();
452        assert!(session.package("amsmath").is_some());
453        assert!(session.resource("amsmath.sty").is_some());
454        assert_eq!(
455            session
456                .macro_definition("text")
457                .expect("text macro")
458                .replacement,
459            b"text macro".to_vec()
460        );
461        assert_eq!(session.opaque_engine_state, b"seedlatexams".to_vec());
462    }
463
464    #[test]
465    fn initializer_reports_missing_preloaded_package() {
466        let resources = InMemoryResourceProvider::new();
467
468        let error = FormatImage::initializer("latex+missing", ProfileId("tex"))
469            .preload_package("missing")
470            .build(&resources)
471            .expect_err("missing package should fail initialization");
472
473        match error {
474            FormatInitError::TexInput { .. } => panic!("unexpected TeX input error"),
475            FormatInitError::Package { name, message } => {
476                assert_eq!(name, "missing.sty");
477                assert!(message.contains("NotFound"));
478            }
479        }
480    }
481
482    #[test]
483    fn initializer_preloads_tex_input_resources() {
484        let resources = InMemoryResourceProvider::new().with_resource(
485            "plain.tex",
486            ResourceKind::TexInput,
487            br"\def\plainchar{A}".to_vec(),
488        );
489
490        let format = FormatImage::initializer("plain", ProfileId("tex"))
491            .preload_tex_input("plain.tex")
492            .build(&resources)
493            .expect("format should preload TeX input bytes");
494
495        let snapshot = match format.state {
496            FormatState::Structured(snapshot) => snapshot,
497            other => panic!("unexpected format state: {other:?}"),
498        };
499        assert_eq!(snapshot.resources.len(), 1);
500        assert_eq!(snapshot.resources[0].name, "plain.tex");
501        assert_eq!(snapshot.resources[0].kind, FormatResourceKind::TexInput);
502        assert_eq!(snapshot.resources[0].bytes, br"\def\plainchar{A}".to_vec());
503    }
504
505    #[test]
506    fn real_format_constructors_preload_engine_entrypoints() {
507        let resources = InMemoryResourceProvider::new()
508            .with_resource("plain.tex", ResourceKind::TexInput, br"\dump".to_vec())
509            .with_resource("latex.ltx", ResourceKind::TexInput, br"\dump".to_vec());
510
511        let plain = FormatImage::plain(ProfileId("tex"))
512            .build(&resources)
513            .expect("plain format entrypoint should resolve");
514        let latex = FormatImage::latex(ProfileId("tex"))
515            .build(&resources)
516            .expect("latex format entrypoint should resolve");
517
518        assert_eq!(plain.id, "plain");
519        assert_eq!(latex.id, "latex");
520
521        let plain_snapshot = match plain.state {
522            FormatState::Structured(snapshot) => snapshot,
523            other => panic!("unexpected plain format state: {other:?}"),
524        };
525        let latex_snapshot = match latex.state {
526            FormatState::Structured(snapshot) => snapshot,
527            other => panic!("unexpected latex format state: {other:?}"),
528        };
529
530        assert_eq!(plain_snapshot.resources[0].name, "plain.tex");
531        assert_eq!(latex_snapshot.resources[0].name, "latex.ltx");
532    }
533}