Skip to main content

rustyfi_lang/
eval.rs

1//! Interpreter state and beta-reduction.
2//!
3//! Expression evaluation lives entirely in `crate::compile`; what remains
4//! here is genuinely runtime: the [`Interp`] state every primitive threads
5//! (images, hooks, cross-references, decorations, …), function application,
6//! and pattern matching. Follows `evaluator.cppo.ml`'s naive interpreter, not
7//! its bytecode VM, which was deliberately not ported.
8
9use crate::ast::{Ast, Pattern};
10use crate::crossref::CrossRefs;
11use crate::value::{BaseEnv, Env, Value};
12use rustyfi_backend::{DocInfo, FontMetrics, ImageResource, MathCmdId};
13use rustyfi_syntax::{RustyfiVersion, Span};
14use std::cell::RefCell;
15use std::rc::Rc;
16
17/// See [`Interp::decos`].
18///
19/// Each entry records the `interp.version` active when the deco closure was
20/// CAPTURED ([`DecoEntry::version`]). Reading `interp.version` at FIRE time
21/// instead is wrong: the consumer (`primitives::apply_deco`, called only from
22/// `lib.rs`'s post-page-break hook-firing pass) always runs outside every
23/// `VersionScope`'s save/restore window, so in a cross-version program the
24/// flag there is the ENTRY's generation, never the deco author's — concretely,
25/// `uline`, `enumitem` and `figbox` are ordinary 0.0.6 packages with their own
26/// 0.0.6 `graphics list` decos; they register while `interp.version` is
27/// `V0_0` and get fired while it is `V0_1`, so `coerce_graphics_result`
28/// demanded a single `graphics` and got a list.
29#[derive(Clone, Debug)]
30pub enum DecoEntry {
31    Inline {
32        deco: Value,
33        version: RustyfiVersion,
34    },
35    Block {
36        pads: rustyfi_backend::Paddings,
37        /// The frame's OUTER width (the wrapping context's paragraph_width).
38        width: rustyfi_backend::Length,
39        /// `(decoS, decoH, decoM, decoT)` — evalUtil.ml:169 `get_decoset`.
40        decoset: [Value; 4],
41        version: RustyfiVersion,
42    },
43    /// `inline-frame-breakable`'s deco set, behind a
44    /// `PureHorzBox::InlineFrameMarker` pair. The inline twin of `Block`
45    /// above: the frame may split across LINE breaks rather than page breaks,
46    /// so `fire_hooks` picks `decoS`/`decoH`/`decoM`/`decoT` per line
47    /// fragment the same way. `pads` is kept for the vertical half only —
48    /// `paddingL`/`paddingR` are already spliced into the box stream as
49    /// `FixedEmpty` (upstream `append_horz_padding`), so only `t`/`b` are
50    /// read back here, to size each fragment's rect.
51    InlineBreakable {
52        pads: rustyfi_backend::Paddings,
53        decoset: [Value; 4],
54        version: RustyfiVersion,
55    },
56}
57
58impl DecoEntry {
59    pub fn version(&self) -> RustyfiVersion {
60        match self {
61            DecoEntry::Inline { version, .. }
62            | DecoEntry::Block { version, .. }
63            | DecoEntry::InlineBreakable { version, .. } => *version,
64        }
65    }
66}
67
68#[derive(Debug, thiserror::Error)]
69#[error("{}{msg}", .span.map(|s| format!("{s}: ")).unwrap_or_default())]
70pub struct EvalError {
71    pub span: Option<Span>,
72    pub msg: String,
73}
74
75pub(crate) fn eval_error<T>(msg: impl Into<String>) -> Result<T, EvalError> {
76    Err(EvalError {
77        span: None,
78        msg: msg.into(),
79    })
80}
81
82/// Comma-separated, sorted field names of a record — the "(available fields:
83/// …)" hint shared by the field-access and field-update error messages.
84pub(crate) fn available_fields(map: &std::collections::BTreeMap<String, Value>) -> String {
85    let mut keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect();
86    keys.sort();
87    keys.join(", ")
88}
89
90/// Evaluation state threaded through every primitive: font metrics, images,
91/// hooks, cross-references, and the per-trial accumulators below.
92pub struct Interp<'a> {
93    pub metrics: &'a dyn FontMetrics,
94    /// The document-wide image table: `load-image` decodes eagerly and
95    /// pushes here, returning the index as `Value::Image`;
96    /// `use-image-by-width` looks the resource back up by it. `page-break`
97    /// clones this into `DocumentValue::images` (a superset of what actually
98    /// ends up placed on a page — the PDF writer itself filters down to the
99    /// images a placed line actually references).
100    pub images: Vec<ImageResource>,
101    /// The document-wide page-break-hook closure table: `hook-page-break`
102    /// pushes its closure and returns a `HookId` index
103    /// (`PureHorzBox::HookPageBreak`) — the `images`-style seam, but for a
104    /// deferred computation. Reset every trial (see `crossrefs`, the one
105    /// exception); read back by `fire_hooks` once placement is known.
106    pub hooks: Vec<Value>,
107    /// Installed-math-command table (`get-initial-context`/
108    /// `set-math-command` push here; `Context::math_command` holds the
109    /// index) — needed because the backend `Context` cannot hold a lang-side
110    /// `Value`. Read back by `read_inline`'s `EmbedMath` arm.
111    pub math_commands: Vec<Value>,
112    /// The cross-reference table, shared with the compile driver across
113    /// every trial of the fixpoint loop — unlike `hooks`/`images`, this must
114    /// *not* reset per trial, so the driver clones one `Rc<RefCell<
115    /// CrossRefs>>` handle into each trial's fresh `Interp`.
116    pub crossrefs: Rc<RefCell<CrossRefs>>,
117    /// Accumulators: link annotations / named destinations / outline
118    /// entries, plus the per-page deco-graphics overlays. All reset per
119    /// trial; the FINAL trial's contents are moved into
120    /// `DocumentValue::extras` by `compile_document_cst_with_trials`.
121    pub annotations: Vec<rustyfi_backend::Annot>,
122    pub destinations: Vec<rustyfi_backend::NamedDest>,
123    pub outline: Vec<rustyfi_backend::OutlineEntry>,
124    pub page_graphics: Vec<Vec<rustyfi_backend::GraphicsElem>>,
125    /// `register-document-information`'s accumulator — LAST WRITE WINS,
126    /// same reset-per-trial policy as `outline`/`annotations`/`destinations`.
127    pub doc_info: Option<DocInfo>,
128    /// `Some(0-based page)` only while `fire_hooks` is walking that page —
129    /// the port of upstream's `State.during_page_break` + "current page"
130    /// (`annotation.ml:15`, `namedDest.ml`'s `notify_pagebreak`).
131    pub current_page: Option<usize>,
132    /// Links/metadata: the `DecoId` of the deco closure currently
133    /// being fired by `fire_hooks`' two `apply_deco` call sites, `None`
134    /// outside any such window. This is the STRUCTURAL link between a
135    /// placed `Annot`/`NamedDest` (page-absolute, known only
136    /// post-page-break) and the `PureHorzBox::Frame`/
137    /// `VertBox::FrameStart`/`FrameEnd` marker that produced it in the
138    /// PRE-page-break `DocumentValue::reflow_source` — both carry the SAME
139    /// `DecoId`, so recording it here (into `link_decos`/`dest_decos`
140    /// below) lets the reflow backend resolve "which Frame is this link"
141    /// exactly, not by geometry/position.
142    pub current_deco_id: Option<rustyfi_backend::DecoId>,
143    /// One `(DecoId, action)` per `register-link-to-uri`/`-to-location`
144    /// call made while `current_deco_id` was `Some`. Reset per trial,
145    /// drained into `DocumentValue::reflow_links` by `eval_document_trials`
146    /// alongside `extras`.
147    pub link_decos: Vec<(rustyfi_backend::DecoId, rustyfi_backend::AnnotAction)>,
148    /// Same idea as `link_decos`, for `register-destination`
149    /// (`annot.satyh`'s `register-location-frame` idiom): `(DecoId, name)`.
150    /// Drained into `DocumentValue::reflow_dests`.
151    pub dest_decos: Vec<(rustyfi_backend::DecoId, String)>,
152    /// `namedDest.ml`'s key -> "nameddest{N}" sanitizer table: arbitrary
153    /// user keys become stable PDF name strings, shared by
154    /// register-destination / register-link-to-location / register-outline
155    /// within one trial.
156    dest_names: std::collections::HashMap<String, String>,
157    /// Deco-closure table (`DecoId` indexes here) — `hooks`' twin for
158    /// decorations. `Inline` holds one `deco` closure
159    /// (`point -> length -> length -> length -> graphics list`); `Block`
160    /// holds a block frame's four-closure deco-set + the geometry the
161    /// markers can't carry. Reset per trial.
162    pub decos: Vec<DecoEntry>,
163    /// Deferred `inline-graphics-outer` callbacks (`length -> point ->
164    /// graphics list`), indexed by `GraphicsFnId` — the `hooks` pattern.
165    /// Each entry also carries the generation it was registered under, for
166    /// the same reason [`DecoEntry`] does: the callback's RESULT shape
167    /// (`graphics list` vs one `graphics`) is a property of the code that
168    /// wrote it, and `primitives::resolve_outer_graphics_in_contents` runs
169    /// long after, from a line-breaking post-pass with no version context
170    /// of its own.
171    pub outer_graphics: Vec<(Value, RustyfiVersion)>,
172    /// The target language version this evaluation run is checking against
173    /// — consulted only by `read_inline`'s `IText::EmbedMath` FALLBACK arm
174    /// (no installed math command; unit-test contexts only). Default
175    /// `V0_0`; `lib.rs`'s `eval_document_trials` sets this to the real
176    /// target version on every `Interp` it constructs.
177    pub version: RustyfiVersion,
178}
179
180impl<'a> Interp<'a> {
181    pub fn new(metrics: &'a dyn FontMetrics) -> Self {
182        Interp {
183            metrics,
184            images: Vec::new(),
185            hooks: Vec::new(),
186            math_commands: Vec::new(),
187            crossrefs: Rc::new(RefCell::new(CrossRefs::new())),
188            annotations: Vec::new(),
189            destinations: Vec::new(),
190            outline: Vec::new(),
191            page_graphics: Vec::new(),
192            doc_info: None,
193            current_page: None,
194            current_deco_id: None,
195            link_decos: Vec::new(),
196            dest_decos: Vec::new(),
197            dest_names: std::collections::HashMap::new(),
198            decos: Vec::new(),
199            outer_graphics: Vec::new(),
200            version: RustyfiVersion::V0_0,
201        }
202    }
203
204    /// Evaluate `ast` by compiling it against `env` and running the result.
205    ///
206    /// A thin shim: ~25 integration tests drive the evaluator through it, and
207    /// it is precisely what their compiled counterpart already does — there
208    /// is exactly one evaluator, since quoted text is compiled eagerly into
209    /// [`crate::quoted`]'s name-free form.
210    ///
211    /// `base` is the COMPILE-time environment `ast`'s free names resolve
212    /// against; the program itself runs in a fresh, empty runtime frame
213    /// chain — `base` is NOT that chain's root, because nothing resolves a
214    /// name at run time.
215    pub fn eval(&mut self, base: &BaseEnv, ast: &Ast) -> Result<Value, EvalError> {
216        crate::compile::compile_program(ast, base).run(&Env::root(), self)
217    }
218
219    /// Intern an installed math command, returning the handle a `Context`
220    /// carries (`Context::math_command`).
221    pub fn register_math_command(&mut self, cmd: Value) -> MathCmdId {
222        self.math_commands.push(cmd);
223        MathCmdId(self.math_commands.len() - 1)
224    }
225
226    /// `namedDest.ml:name_from_hash_table` — the stable PDF name for `key`,
227    /// minting `nameddest{N}` on first sight. Also used by `register-outline`
228    /// (upstream `Outline.make_entry` calls `NamedDest.get`, which mints too).
229    pub fn dest_name(&mut self, key: &str) -> String {
230        if let Some(n) = self.dest_names.get(key) {
231            return n.clone();
232        }
233        let n = format!("nameddest{}", self.dest_names.len());
234        self.dest_names.insert(key.to_string(), n.clone());
235        n
236    }
237
238    pub fn apply(&mut self, func: Value, arg: Value) -> Result<Value, EvalError> {
239        // A plain (0.0.6-shaped) application supplies no optional bundle; a
240        // closure that *does* declare optional params defaults every one to
241        // `None`, faithful to upstream's `reduce_beta_list`.
242        self.apply_with_opts(func, Vec::new(), arg)
243    }
244
245    /// Beta-reduce `func` against a positional argument plus a SATySFi 0.1
246    /// labeled-optional bundle. For a closure, each of the closure's declared
247    /// optional params binds `Some v` when the bundle carries its label, else
248    /// `None`; a supplied label the closure does not declare is ignored
249    /// (upstream `reduce_beta` folds over the *closure's* map — the
250    /// typechecker rejects genuinely-wrong labels first). This
251    /// unknown-label-ignore is only sound because typecheck runs first.
252    pub fn apply_with_opts(
253        &mut self,
254        func: Value,
255        opt_vals: Vec<(String, Value)>,
256        arg: Value,
257    ) -> Result<Value, EvalError> {
258        match func {
259            Value::CompiledClosure {
260                opt_labels,
261                body,
262                env,
263            } => {
264                // Slot order: declared optional binders, then the positional
265                // parameter — what `Ast::LambdaOpt` pushed onto the
266                // compiler's scope stack.
267                let mut slots = Vec::with_capacity(opt_labels.len() + 1);
268                push_opt_slots(&mut slots, &opt_labels, &opt_vals);
269                slots.push(arg);
270                body.run(&env.child(slots), self)
271            }
272            Value::Prim { def, mut applied } => {
273                if !opt_vals.is_empty() {
274                    return eval_error(
275                        "labeled optional arguments to a primitive are roadmap phase 5",
276                    );
277                }
278                applied.push(arg);
279                if applied.len() == def.arity {
280                    (def.run)(self, applied)
281                } else {
282                    Ok(Value::Prim { def, applied })
283                }
284            }
285            other => eval_error(format!(
286                "cannot apply a value of type {} as a function",
287                other.type_name()
288            )),
289        }
290    }
291}
292
293/// Append one slot per declared SATySFi 0.1 labeled-optional parameter, in
294/// declaration order: `Some v` when `opt_vals` supplies that label, `None`
295/// otherwise (upstream `reduce_beta`'s fold over the closure's own label map).
296/// See `Interp::apply_with_opts` for why unknown labels are ignored.
297fn push_opt_slots(slots: &mut Vec<Value>, opt_labels: &[String], opt_vals: &[(String, Value)]) {
298    for label in opt_labels {
299        slots.push(match opt_vals.iter().find(|(l, _)| l == label) {
300            Some((_, v)) => Value::Ctor("Some".to_string(), Some(Box::new(v.clone()))),
301            None => Value::Ctor("None".to_string(), None),
302        });
303    }
304}
305
306/// Structural pattern matching against an already-evaluated scrutinee.
307/// Returns `true` (and appends every bound value, POSITIONALLY, in the order
308/// they were encountered) on a structural match; returns `false` (leaving
309/// `bindings` for this attempt unusable — callers must use a fresh `Vec` per
310/// arm) otherwise.
311///
312/// The push order here is the same left-to-right traversal
313/// `compile::pattern_vars` uses to collect the arm's names, so position `i`
314/// in `bindings` is slot `i` of the frame the arm runs in — keep the two in
315/// step. A pattern/value shape mismatch is simply "no match", never an
316/// error: this untyped evaluator relies on the separate exhaustiveness/type
317/// checker to rule out ill-typed matches ahead of time.
318pub fn match_pattern(pat: &Pattern, value: &Value, bindings: &mut Vec<Value>) -> bool {
319    match pat {
320        Pattern::Wild => true,
321        Pattern::Var(_) => {
322            bindings.push(value.clone());
323            true
324        }
325        Pattern::As(inner_pat, _) => {
326            if match_pattern(inner_pat, value, bindings) {
327                bindings.push(value.clone());
328                true
329            } else {
330                false
331            }
332        }
333        Pattern::Unit => matches!(value, Value::Unit),
334        Pattern::Bool(b) => matches!(value, Value::Bool(v) if v == b),
335        Pattern::Int(n) => matches!(value, Value::Int(v) if v == n),
336        Pattern::Str(s) => matches!(value, Value::Str(v) if v == s),
337        Pattern::Tuple(ps) => match value {
338            Value::Tuple(vs) if ps.len() == vs.len() => ps
339                .iter()
340                .zip(vs.iter())
341                .all(|(p, v)| match_pattern(p, v, bindings)),
342            _ => false,
343        },
344        Pattern::EmptyList => matches!(value, Value::List(vs) if vs.is_empty()),
345        Pattern::Cons(head_pat, tail_pat) => match value {
346            Value::List(vs) if !vs.is_empty() => {
347                if !match_pattern(head_pat, &vs[0], bindings) {
348                    return false;
349                }
350                let tail = Value::List(vs[1..].to_vec());
351                match_pattern(tail_pat, &tail, bindings)
352            }
353            _ => false,
354        },
355        Pattern::Ctor(name, parg) => match value {
356            Value::Ctor(vname, vpayload) if name == vname => match (parg, vpayload) {
357                (None, None) => true,
358                (Some(p), Some(v)) => match_pattern(p, v, bindings),
359                _ => false,
360            },
361            _ => false,
362        },
363    }
364}