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/// Which callbacks a walk over placed geometry (`crate::fire_hooks` and the
91/// helpers it shares with `page_break_core`) is allowed to invoke.
92///
93/// Upstream fires both halves in ONE pass, from `ops_of_evaled_vert_box_list`
94/// (`handlePdf.ml:336` for `EvVertHookPageBreak`, `:325` for `EvVertFrame`'s
95/// deco), and that pass runs per page INSIDE the page loop — page N's body
96/// callbacks before page N's own `pagepartsf`. That ordering is load-bearing:
97/// `stdjareport`'s `\figure` is a `hook-page-break` that pushes the figure
98/// onto a `let-mutable` list which the page-parts callback drains onto a LATER
99/// page, so a hook that fires after the loop registers into a list nobody
100/// reads again.
101///
102/// This port cannot fire both halves there, because a decoration's rect needs
103/// the frame's per-page top/bottom EXTENT, which is only known once the page's
104/// header and footer are placed and the page is complete. So the walk runs
105/// twice: `page_break_core` drives a [`FirePass::HooksOnly`] pass per column
106/// (upstream's position, `pageBreak.ml:747-748`), and the post-run
107/// `fire_hooks` drives a [`FirePass::DecosOnly`] one. Each callback still
108/// fires exactly once.
109#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
110pub enum FirePass {
111 /// One undivided pass — every hook and every decoration. What a
112 /// hand-built `DocumentValue` handed straight to `fire_hooks` gets.
113 #[default]
114 All,
115 /// `hook-page-break` closures only. Frame decorations are skipped, but
116 /// the walk still descends through frames, tabular cells and graphics
117 /// runs, so a hook nested in one of them fires here and nowhere else.
118 HooksOnly,
119 /// Frame decorations, `GraphicsElem::Destination` markers and everything
120 /// else the walk records — but not one `hook-page-break`, which the
121 /// preceding [`FirePass::HooksOnly`] pass already ran.
122 DecosOnly,
123}
124
125/// Evaluation state threaded through every primitive: font metrics, images,
126/// hooks, cross-references, and the per-trial accumulators below.
127pub struct Interp<'a> {
128 pub metrics: &'a dyn FontMetrics,
129 /// The document-wide image table: `load-image` decodes eagerly and
130 /// pushes here, returning the index as `Value::Image`;
131 /// `use-image-by-width` looks the resource back up by it. `page-break`
132 /// clones this into `DocumentValue::images` (a superset of what actually
133 /// ends up placed on a page — the PDF writer itself filters down to the
134 /// images a placed line actually references).
135 pub images: Vec<ImageResource>,
136 /// The document-wide page-break-hook closure table: `hook-page-break`
137 /// pushes its closure and returns a `HookId` index
138 /// (`PureHorzBox::HookPageBreak`) — the `images`-style seam, but for a
139 /// deferred computation. Reset every trial (see `crossrefs`, the one
140 /// exception); read back by `fire_hooks` once placement is known.
141 pub hooks: Vec<Value>,
142 /// Installed-math-command table (`get-initial-context`/
143 /// `set-math-command` push here; `Context::math_command` holds the
144 /// index) — needed because the backend `Context` cannot hold a lang-side
145 /// `Value`. Read back by `read_inline`'s `EmbedMath` arm.
146 pub math_commands: Vec<Value>,
147 /// The cross-reference table, shared with the compile driver across
148 /// every trial of the fixpoint loop — unlike `hooks`/`images`, this must
149 /// *not* reset per trial, so the driver clones one `Rc<RefCell<
150 /// CrossRefs>>` handle into each trial's fresh `Interp`.
151 pub crossrefs: Rc<RefCell<CrossRefs>>,
152 /// Accumulators: link annotations / named destinations / outline
153 /// entries, plus the per-page deco-graphics overlays. All reset per
154 /// trial; the FINAL trial's contents are moved into
155 /// `DocumentValue::extras` by `compile_document_cst_with_trials`.
156 pub annotations: Vec<rustyfi_backend::Annot>,
157 pub destinations: Vec<rustyfi_backend::NamedDest>,
158 pub outline: Vec<rustyfi_backend::OutlineEntry>,
159 pub page_graphics: Vec<Vec<rustyfi_backend::GraphicsElem>>,
160 /// `register-document-information`'s accumulator — LAST WRITE WINS,
161 /// same reset-per-trial policy as `outline`/`annotations`/`destinations`.
162 pub doc_info: Option<DocInfo>,
163 /// `Some(0-based page)` only while a placed-geometry walk is on that page
164 /// — the port of upstream's `State.during_page_break` + "current page"
165 /// (`annotation.ml:15`, `namedDest.ml`'s `notify_pagebreak`). Both walks
166 /// set it: `page_break_core`'s per-page hook pass and `fire_hooks`.
167 pub current_page: Option<usize>,
168 /// Which half of the placed-geometry walk is running right now. The walk
169 /// happens TWICE per trial and each pass must fire exactly one half of it
170 /// — see [`FirePass`].
171 pub fire_pass: FirePass,
172 /// Set by `page_break_core` once it has finished driving its per-page
173 /// [`FirePass::HooksOnly`] pass, so the `fire_hooks` call that follows the
174 /// page loop knows to run [`FirePass::DecosOnly`] and not fire every
175 /// `hook-page-break` a second time. Stays `false` for a `DocumentValue`
176 /// assembled by hand (unit tests drive `fire_hooks` directly), which then
177 /// runs the undivided [`FirePass::All`].
178 pub page_break_hooks_fired: bool,
179 /// Links/metadata: the `DecoId` of the deco closure currently
180 /// being fired by `fire_hooks`' two `apply_deco` call sites, `None`
181 /// outside any such window. This is the STRUCTURAL link between a
182 /// placed `Annot`/`NamedDest` (page-absolute, known only
183 /// post-page-break) and the `PureHorzBox::Frame`/
184 /// `VertBox::FrameStart`/`FrameEnd` marker that produced it in the
185 /// PRE-page-break `DocumentValue::reflow_source` — both carry the SAME
186 /// `DecoId`, so recording it here (into `link_decos`/`dest_decos`
187 /// below) lets the reflow backend resolve "which Frame is this link"
188 /// exactly, not by geometry/position.
189 pub current_deco_id: Option<rustyfi_backend::DecoId>,
190 /// `Some` only while an `inline-graphics` callback is being applied
191 /// EAGERLY, outside any page-break window (`apply_graphics_callback`):
192 /// `register-destination` appends its `(key, box-local point)` here
193 /// instead of erroring, and the caller turns each into a
194 /// `GraphicsElem::Destination` marker riding in the resulting box. Left
195 /// `None` inside a page-break window, so the direct registration wins
196 /// there.
197 pub pending_dests: Option<Vec<(String, rustyfi_backend::Point)>>,
198 /// One `(DecoId, action)` per `register-link-to-uri`/`-to-location`
199 /// call made while `current_deco_id` was `Some`. Reset per trial,
200 /// drained into `DocumentValue::reflow_links` by `eval_document_trials`
201 /// alongside `extras`.
202 pub link_decos: Vec<(rustyfi_backend::DecoId, rustyfi_backend::AnnotAction)>,
203 /// Same idea as `link_decos`, for `register-destination`
204 /// (`annot.satyh`'s `register-location-frame` idiom): `(DecoId, name)`.
205 /// Drained into `DocumentValue::reflow_dests`.
206 pub dest_decos: Vec<(rustyfi_backend::DecoId, String)>,
207 /// Each block frame's own decoration at its natural size, box-local —
208 /// see `rustyfi_backend::FrameDecoration`. Recorded by `fire_hooks` and
209 /// drained into `DocumentValue::reflow_frame_decos`, so a renderer with
210 /// no page grid can draw the frame the document actually asked for
211 /// instead of nothing at all. Unread by the PDF path.
212 pub frame_decos: Vec<(rustyfi_backend::DecoId, rustyfi_backend::FrameDecoration)>,
213 /// `namedDest.ml`'s key -> "nameddest{N}" sanitizer table: arbitrary
214 /// user keys become stable PDF name strings, shared by
215 /// register-destination / register-link-to-location / register-outline
216 /// within one trial.
217 dest_names: std::collections::HashMap<String, String>,
218 /// Deco-closure table (`DecoId` indexes here) — `hooks`' twin for
219 /// decorations. `Inline` holds one `deco` closure
220 /// (`point -> length -> length -> length -> graphics list`); `Block`
221 /// holds a block frame's four-closure deco-set + the geometry the
222 /// markers can't carry. Reset per trial.
223 pub decos: Vec<DecoEntry>,
224 /// Deferred `inline-graphics-outer` callbacks (`length -> point ->
225 /// graphics list`), indexed by `GraphicsFnId` — the `hooks` pattern.
226 /// Each entry also carries the generation it was registered under, for
227 /// the same reason [`DecoEntry`] does: the callback's RESULT shape
228 /// (`graphics list` vs one `graphics`) is a property of the code that
229 /// wrote it, and `primitives::resolve_outer_graphics_in_contents` runs
230 /// long after, from a line-breaking post-pass with no version context
231 /// of its own.
232 pub outer_graphics: Vec<(Value, RustyfiVersion)>,
233 /// The target language version this evaluation run is checking against
234 /// — consulted only by `read_inline`'s `IText::EmbedMath` FALLBACK arm
235 /// (no installed math command; unit-test contexts only). Default
236 /// `V0_0`; `lib.rs`'s `eval_document_trials` sets this to the real
237 /// target version on every `Interp` it constructs.
238 pub version: RustyfiVersion,
239}
240
241impl<'a> Interp<'a> {
242 pub fn new(metrics: &'a dyn FontMetrics) -> Self {
243 Interp {
244 metrics,
245 images: Vec::new(),
246 hooks: Vec::new(),
247 math_commands: Vec::new(),
248 crossrefs: Rc::new(RefCell::new(CrossRefs::new())),
249 annotations: Vec::new(),
250 destinations: Vec::new(),
251 outline: Vec::new(),
252 page_graphics: Vec::new(),
253 doc_info: None,
254 current_page: None,
255 fire_pass: FirePass::All,
256 page_break_hooks_fired: false,
257 current_deco_id: None,
258 pending_dests: None,
259 link_decos: Vec::new(),
260 dest_decos: Vec::new(),
261 frame_decos: Vec::new(),
262 dest_names: std::collections::HashMap::new(),
263 decos: Vec::new(),
264 outer_graphics: Vec::new(),
265 version: RustyfiVersion::V0_0,
266 }
267 }
268
269 /// Evaluate `ast` by compiling it against `env` and running the result.
270 ///
271 /// A thin shim: ~25 integration tests drive the evaluator through it, and
272 /// it is precisely what their compiled counterpart already does — there
273 /// is exactly one evaluator, since quoted text is compiled eagerly into
274 /// [`crate::quoted`]'s name-free form.
275 ///
276 /// `base` is the COMPILE-time environment `ast`'s free names resolve
277 /// against; the program itself runs in a fresh, empty runtime frame
278 /// chain — `base` is NOT that chain's root, because nothing resolves a
279 /// name at run time.
280 pub fn eval(&mut self, base: &BaseEnv, ast: &Ast) -> Result<Value, EvalError> {
281 crate::compile::compile_program(ast, base).run(&Env::root(), self)
282 }
283
284 /// Intern an installed math command, returning the handle a `Context`
285 /// carries (`Context::math_command`).
286 pub fn register_math_command(&mut self, cmd: Value) -> MathCmdId {
287 self.math_commands.push(cmd);
288 MathCmdId(self.math_commands.len() - 1)
289 }
290
291 /// `namedDest.ml:name_from_hash_table` — the stable PDF name for `key`,
292 /// minting `nameddest{N}` on first sight. Also used by `register-outline`
293 /// (upstream `Outline.make_entry` calls `NamedDest.get`, which mints too).
294 pub fn dest_name(&mut self, key: &str) -> String {
295 if let Some(n) = self.dest_names.get(key) {
296 return n.clone();
297 }
298 let n = format!("nameddest{}", self.dest_names.len());
299 self.dest_names.insert(key.to_string(), n.clone());
300 n
301 }
302
303 pub fn apply(&mut self, func: Value, arg: Value) -> Result<Value, EvalError> {
304 // A plain (0.0.6-shaped) application supplies no optional bundle; a
305 // closure that *does* declare optional params defaults every one to
306 // `None`, faithful to upstream's `reduce_beta_list`.
307 self.apply_with_opts(func, Vec::new(), arg)
308 }
309
310 /// Beta-reduce `func` against a positional argument plus a SATySFi 0.1
311 /// labeled-optional bundle. For a closure, each of the closure's declared
312 /// optional params binds `Some v` when the bundle carries its label, else
313 /// `None`; a supplied label the closure does not declare is ignored
314 /// (upstream `reduce_beta` folds over the *closure's* map — the
315 /// typechecker rejects genuinely-wrong labels first). This
316 /// unknown-label-ignore is only sound because typecheck runs first.
317 pub fn apply_with_opts(
318 &mut self,
319 func: Value,
320 opt_vals: Vec<(String, Value)>,
321 arg: Value,
322 ) -> Result<Value, EvalError> {
323 match func {
324 Value::CompiledClosure {
325 opt_labels,
326 body,
327 env,
328 } => {
329 // Slot order: declared optional binders, then the positional
330 // parameter — what `Ast::LambdaOpt` pushed onto the
331 // compiler's scope stack.
332 let mut slots = Vec::with_capacity(opt_labels.len() + 1);
333 push_opt_slots(&mut slots, &opt_labels, &opt_vals);
334 slots.push(arg);
335 body.run(&env.child(slots), self)
336 }
337 Value::Prim { def, mut applied } => {
338 if !opt_vals.is_empty() {
339 return eval_error(
340 "labeled optional arguments to a primitive are roadmap phase 5",
341 );
342 }
343 applied.push(arg);
344 if applied.len() == def.arity {
345 (def.run)(self, applied)
346 } else {
347 Ok(Value::Prim { def, applied })
348 }
349 }
350 other => eval_error(format!(
351 "cannot apply a value of type {} as a function",
352 other.type_name()
353 )),
354 }
355 }
356}
357
358/// Append one slot per declared SATySFi 0.1 labeled-optional parameter, in
359/// declaration order: `Some v` when `opt_vals` supplies that label, `None`
360/// otherwise (upstream `reduce_beta`'s fold over the closure's own label map).
361/// See `Interp::apply_with_opts` for why unknown labels are ignored.
362fn push_opt_slots(slots: &mut Vec<Value>, opt_labels: &[String], opt_vals: &[(String, Value)]) {
363 for label in opt_labels {
364 slots.push(match opt_vals.iter().find(|(l, _)| l == label) {
365 Some((_, v)) => Value::Ctor("Some".to_string(), Some(Box::new(v.clone()))),
366 None => Value::Ctor("None".to_string(), None),
367 });
368 }
369}
370
371/// Structural pattern matching against an already-evaluated scrutinee.
372/// Returns `true` (and appends every bound value, POSITIONALLY, in the order
373/// they were encountered) on a structural match; returns `false` (leaving
374/// `bindings` for this attempt unusable — callers must use a fresh `Vec` per
375/// arm) otherwise.
376///
377/// The push order here is the same left-to-right traversal
378/// `compile::pattern_vars` uses to collect the arm's names, so position `i`
379/// in `bindings` is slot `i` of the frame the arm runs in — keep the two in
380/// step. A pattern/value shape mismatch is simply "no match", never an
381/// error: this untyped evaluator relies on the separate exhaustiveness/type
382/// checker to rule out ill-typed matches ahead of time.
383pub fn match_pattern(pat: &Pattern, value: &Value, bindings: &mut Vec<Value>) -> bool {
384 match pat {
385 Pattern::Wild => true,
386 Pattern::Var(_) => {
387 bindings.push(value.clone());
388 true
389 }
390 Pattern::As(inner_pat, _) => {
391 if match_pattern(inner_pat, value, bindings) {
392 bindings.push(value.clone());
393 true
394 } else {
395 false
396 }
397 }
398 Pattern::Unit => matches!(value, Value::Unit),
399 Pattern::Bool(b) => matches!(value, Value::Bool(v) if v == b),
400 Pattern::Int(n) => matches!(value, Value::Int(v) if v == n),
401 Pattern::Str(s) => matches!(value, Value::Str(v) if v == s),
402 Pattern::Tuple(ps) => match value {
403 Value::Tuple(vs) if ps.len() == vs.len() => ps
404 .iter()
405 .zip(vs.iter())
406 .all(|(p, v)| match_pattern(p, v, bindings)),
407 _ => false,
408 },
409 Pattern::EmptyList => matches!(value, Value::List(vs) if vs.is_empty()),
410 Pattern::Cons(head_pat, tail_pat) => match value {
411 Value::List(vs) if !vs.is_empty() => {
412 if !match_pattern(head_pat, &vs[0], bindings) {
413 return false;
414 }
415 let tail = Value::List(vs[1..].to_vec());
416 match_pattern(tail_pat, &tail, bindings)
417 }
418 _ => false,
419 },
420 Pattern::Ctor(name, parg) => match value {
421 Value::Ctor(vname, vpayload) if name == vname => match (parg, vpayload) {
422 (None, None) => true,
423 (Some(p), Some(v)) => match_pattern(p, v, bindings),
424 _ => false,
425 },
426 _ => false,
427 },
428 }
429}