Skip to main content

latexml_core/common/
local_assignments.rs

1//! Perl-style local assignments (dynamic scope)
2//!
3//! This module provides stack-based dynamic scoping for global state,
4//! matching Perl's `local` mechanism. Each "localized" field uses a `Vec<T>`
5//! as a stack: push to shadow, pop to restore.
6//!
7//! ## RAII Guards
8//!
9//! Each localized variable has a corresponding `Guard` type that pushes on
10//! creation and pops on `Drop`. This prevents leaked state on early returns
11//! or panics:
12//!
13//! ```rust,no_run
14//! # use latexml_core::common::local_assignments::*;
15//! # use latexml_core::token::Token;
16//! # fn example(token: Token) {
17//! let _guard = local_current_token_guard(token);
18//! // ... do work ...
19//! // guard auto-pops when _guard goes out of scope
20//! # }
21//! ```
22//!
23//! The explicit `set_*/expire_*` pairs still exist for cases where
24//! RAII doesn't fit (e.g., cross-function push/pop with different lifetimes).
25
26use std::{cell::RefCell, rc::Rc};
27
28use once_cell::sync::Lazy;
29
30use crate::{
31  Digested, alignment::template::Template, definition::conditional::IfFrame, token::Token,
32};
33
34/// These are fields realized via Perl's "local" mechanism in LaTeXML,
35/// but (for now) require explicit "expire" calls in Rust.
36/// Ideally their ergonomics gets improved, or we gradually phase them out.
37#[derive(Debug, Default)]
38pub struct Localized {
39  dual_branch:       Vec<&'static str>,
40  if_frames:         Vec<Option<Rc<RefCell<IfFrame>>>>,
41  current_token:     Vec<Token>,
42  align_group_count: Vec<i32>, // was $LaTeXML::ALIGN_STATE
43  reading_alignment: Vec<Digested>,
44  build_template:    Vec<Template>,
45  unlocked:          Vec<bool>,
46}
47
48macro_rules! locals {
49  () => {
50    (*LOCALIZED_VARS).borrow()
51  };
52}
53macro_rules! locals_mut {
54  () => {
55    (*LOCALIZED_VARS).borrow_mut()
56  };
57}
58
59#[thread_local]
60static LOCALIZED_VARS: Lazy<RefCell<Localized>> = Lazy::new(|| RefCell::new(Localized::default()));
61
62/// Reset all localized variables to their default state.
63/// Must be called between conversion runs to prevent state pollution.
64pub fn initialize_localized() { *locals_mut!() = Localized::default(); }
65
66/// sets a (originally Perl-local) `IfFrame` that needs to be manually expired.
67pub fn set_ifframe(if_frame: Option<Rc<RefCell<IfFrame>>>) {
68  locals_mut!().if_frames.push(if_frame);
69}
70
71/// retrieves the most recent (originally Perl-local) `IfFrame`
72pub fn get_ifframe() -> Option<Rc<RefCell<IfFrame>>> {
73  match locals!().if_frames.last() {
74    Some(Some(frame)) => Some(Rc::clone(frame)),
75    _ => None,
76  }
77}
78/// expires the most recent (originally Perl-local) `IfFrame`
79pub fn expire_ifframe() { locals_mut!().if_frames.pop(); }
80/// Number of currently open conditional frames (`\if…` whose `\fi` has not
81/// been digested yet). Zero at any clean point between top-level constructs —
82/// the fragment-yield seam predicate requires exactly that.
83pub fn open_conditional_count() -> usize { locals!().if_frames.len() }
84/// localizes a new current token. see `Stomach::invoke_token`
85pub fn local_current_token(token: Token) { locals_mut!().current_token.push(token); }
86/// expires the most recent (localized) current token.
87pub fn expire_current_token() { locals_mut!().current_token.pop(); }
88/// gets the (localized) current token
89pub fn get_current_token() -> Option<Token> { locals!().current_token.last().cloned() }
90
91/// sets the (localized) flag for "dual branch"
92pub fn set_dual_branch(mode: &'static str) { locals_mut!().dual_branch.push(mode); }
93/// expire (localized) flag for "dual branch"
94pub fn expire_dual_branch() { locals_mut!().dual_branch.pop(); }
95/// get the current value for "dual branch"
96pub fn get_dual_branch() -> Option<&'static str> { locals!().dual_branch.last().cloned() }
97
98pub fn increment_align_group_count() {
99  let mut locals = locals_mut!();
100  match locals.align_group_count.last_mut() {
101    Some(v) => *v += 1,
102    None => locals.align_group_count.push(1),
103  }
104}
105pub fn decrement_align_group_count() {
106  let mut locals = locals_mut!();
107  match locals.align_group_count.last_mut() {
108    Some(v) => *v -= 1,
109    None => locals.align_group_count.push(-1),
110  }
111}
112
113pub fn state_is_unlocked() -> bool { locals!().unlocked.last().copied().unwrap_or(false) }
114pub fn local_state_unlocked(v: bool) { locals_mut!().unlocked.push(v); }
115pub fn expire_state_unlocked() { locals_mut!().unlocked.pop(); }
116
117pub fn align_group_count() -> i32 {
118  locals!()
119    .align_group_count
120    .last()
121    .copied()
122    .unwrap_or_default()
123}
124pub fn set_align_group_count(v: i32) {
125  match locals_mut!().align_group_count.last_mut() {
126    Some(gc) => {
127      *gc = v;
128    },
129    _ => {
130      locals_mut!().align_group_count.push(v);
131    },
132  }
133}
134pub fn local_align_group_count(v: i32) { locals_mut!().align_group_count.push(v); }
135pub fn expire_align_group_count() -> Option<i32> { locals_mut!().align_group_count.pop() }
136
137/// Disables tab marks for the lifetime of the guard, restoring the previous
138/// count on drop — `tex.web` §394 `macro_call`:
139///
140/// ```text
141/// align_state:=1000000; {disable tab marks, etc.}
142/// ```
143///
144/// TeX suppresses `&` and `\cr` while it scans a macro's parameters, so a tab
145/// mark inside an argument is an ordinary token. Without that, a `&` in a
146/// **delimiter-fenced** argument reaches the alignment as a cell break: for
147/// `\mqty( b_0 &0 \\ 0 &b_1 )` (physics.sty, witness 2605.05903) the row splits
148/// mid-argument and the alignment then cannot close its own group —
149/// `Error:unexpected:\lx@begin@alignment Attempt to close a group that switched
150/// to mode restricted_horizontal` — truncating the rest of the document. The
151/// brace form `\mqty{…}` was always safe because cell scanning skips balanced
152/// groups; `(…)` is not a group. Perl raises the identical error (11 of them on
153/// the 14-line repro), so this is beyond-Perl and `pdflatex`, which renders the
154/// repro silently, is the ground truth.
155///
156/// Only armed inside an alignment: outside one there are no tab marks to
157/// suppress, which keeps this off the hot path for every ordinary macro call.
158/// 28 papers of the 2026-07-29 bibliography-absence residual truncate here.
159pub struct SuppressedTabMarks(Option<i32>);
160
161impl SuppressedTabMarks {
162  pub fn for_argument_scan() -> Self {
163    if has_reading_alignment() {
164      let saved = align_group_count();
165      set_align_group_count(1_000_000);
166      Self(Some(saved))
167    } else {
168      Self(None)
169    }
170  }
171}
172
173impl Drop for SuppressedTabMarks {
174  fn drop(&mut self) {
175    if let Some(saved) = self.0 {
176      set_align_group_count(saved);
177    }
178  }
179}
180
181pub fn get_reading_alignment() -> Option<Digested> { locals!().reading_alignment.last().cloned() }
182pub fn has_reading_alignment() -> bool { !locals!().reading_alignment.is_empty() }
183pub fn local_reading_alignment(alignment: &Digested) {
184  locals_mut!().reading_alignment.push(alignment.clone());
185}
186pub fn expire_reading_alignment() -> Option<Digested> { locals_mut!().reading_alignment.pop() }
187
188pub fn local_build_template(template: Template) { locals_mut!().build_template.push(template); }
189pub fn set_build_template(template: Template) {
190  *locals_mut!()
191    .build_template
192    .last_mut()
193    .expect("set_build_template should not be called before the first local_build_template") =
194    template;
195}
196pub fn take_build_template() -> Option<Template> { locals_mut!().build_template.pop() }
197
198pub fn with_current_build_template<R, FnR>(caller: FnR) -> R
199where FnR: FnOnce(Option<&mut Template>) -> R {
200  caller(locals_mut!().build_template.last_mut())
201}
202
203// ============================================================
204// RAII Guards — auto-restore on Drop
205// ============================================================
206
207/// Generic RAII guard that calls an expire function on drop.
208/// Zero-cost when the expire function is a simple fn pointer.
209pub struct LocalGuard {
210  expire: fn(),
211}
212impl Drop for LocalGuard {
213  fn drop(&mut self) { (self.expire)(); }
214}
215
216/// Push a localized current token; returns guard that pops on drop.
217pub fn local_current_token_guard(token: Token) -> LocalGuard {
218  local_current_token(token);
219  LocalGuard { expire: expire_current_token }
220}
221
222/// Push a localized if-frame; returns guard that pops on drop.
223pub fn local_ifframe_guard(frame: Option<Rc<RefCell<IfFrame>>>) -> LocalGuard {
224  set_ifframe(frame);
225  LocalGuard { expire: expire_ifframe }
226}
227
228/// Push a localized dual branch; returns guard that pops on drop.
229pub fn local_dual_branch_guard(mode: &'static str) -> LocalGuard {
230  set_dual_branch(mode);
231  LocalGuard { expire: expire_dual_branch }
232}
233
234/// Push a localized align group count; returns guard that pops on drop.
235pub fn local_align_group_count_guard(v: i32) -> LocalGuard {
236  local_align_group_count(v);
237  LocalGuard {
238    expire: || {
239      expire_align_group_count();
240    },
241  }
242}
243
244/// Push a localized state unlocked flag; returns guard that pops on drop.
245pub fn local_state_unlocked_guard(v: bool) -> LocalGuard {
246  local_state_unlocked(v);
247  LocalGuard { expire: expire_state_unlocked }
248}
249
250/// Push a localized reading alignment; returns guard that pops on drop.
251pub fn local_reading_alignment_guard(alignment: &Digested) -> LocalGuard {
252  local_reading_alignment(alignment);
253  LocalGuard {
254    expire: || {
255      expire_reading_alignment();
256    },
257  }
258}
259
260// ============================================================
261// Unit tests
262// ============================================================
263
264#[cfg(test)]
265mod tests {
266  use super::*;
267
268  #[test]
269  fn test_dual_branch_push_pop() {
270    initialize_localized();
271    assert_eq!(get_dual_branch(), None);
272    set_dual_branch("true");
273    assert_eq!(get_dual_branch(), Some("true"));
274    set_dual_branch("false");
275    assert_eq!(get_dual_branch(), Some("false"));
276    expire_dual_branch();
277    assert_eq!(get_dual_branch(), Some("true"));
278    expire_dual_branch();
279    assert_eq!(get_dual_branch(), None);
280  }
281
282  #[test]
283  fn test_dual_branch_guard_auto_restore() {
284    initialize_localized();
285    assert_eq!(get_dual_branch(), None);
286    {
287      let _guard = local_dual_branch_guard("guarded");
288      assert_eq!(get_dual_branch(), Some("guarded"));
289    }
290    // Guard dropped — value restored
291    assert_eq!(get_dual_branch(), None);
292  }
293
294  #[test]
295  fn test_align_group_count() {
296    initialize_localized();
297    assert_eq!(align_group_count(), 0);
298    local_align_group_count(10);
299    assert_eq!(align_group_count(), 10);
300    increment_align_group_count();
301    assert_eq!(align_group_count(), 11);
302    decrement_align_group_count();
303    assert_eq!(align_group_count(), 10);
304    expire_align_group_count();
305    assert_eq!(align_group_count(), 0);
306  }
307
308  #[test]
309  fn test_align_group_count_guard() {
310    initialize_localized();
311    assert_eq!(align_group_count(), 0);
312    {
313      let _guard = local_align_group_count_guard(42);
314      assert_eq!(align_group_count(), 42);
315      increment_align_group_count();
316      assert_eq!(align_group_count(), 43);
317    }
318    // Guard dropped — outer value restored
319    assert_eq!(align_group_count(), 0);
320  }
321
322  #[test]
323  fn test_state_unlocked() {
324    initialize_localized();
325    assert!(!state_is_unlocked());
326    local_state_unlocked(true);
327    assert!(state_is_unlocked());
328    {
329      let _guard = local_state_unlocked_guard(false);
330      assert!(!state_is_unlocked());
331    }
332    assert!(state_is_unlocked());
333    expire_state_unlocked();
334    assert!(!state_is_unlocked());
335  }
336}