Skip to main content

stet_engine/
eval.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Core execution engine — the eval loop that drives PostScript execution.
6
7use stet_core::context::Context;
8use stet_core::dict::DictKey;
9use stet_core::error::PsError;
10use stet_core::object::{EntityId, ObjFlags, PsObject, PsValue};
11use stet_core::tokenizer::{Token, Tokenizer, stream_next_token};
12
13/// Main eval loop: pop objects from the execution stack and execute them.
14///
15/// All PostScript errors are routed through `dispatch_error` → errordict
16/// so they produce standard PS error output. Only `Quit` and un-caught
17/// `Stop` propagate to the caller.
18pub fn eval(ctx: &mut Context) -> Result<(), PsError> {
19    while let Some(mut obj) = ctx.e_stack.try_pop() {
20        if let Some(ref flag) = ctx.interrupt_flag
21            && flag.load(std::sync::atomic::Ordering::Relaxed)
22        {
23            return Err(PsError::Quit);
24        }
25        ctx.check_deadline()?;
26        // Deferred objects (nested procs from exec_procedure) → push to operand stack
27        // Clear the deferred flag so the executable flag is preserved.
28        if obj.flags.is_deferred() {
29            obj.flags.clear_deferred();
30            if let Err(e) = ctx.o_stack.push(obj) {
31                dispatch_error(ctx, &e)?;
32            }
33            continue;
34        }
35
36        // Literal objects (except internal markers) → push to operand stack
37        if obj.flags.is_literal()
38            && !matches!(
39                obj.value,
40                PsValue::Stopped
41                    | PsValue::Loop(_)
42                    | PsValue::HardReturn
43                    | PsValue::DictEnd(_)
44                    | PsValue::ExecArray { .. }
45            )
46        {
47            if let Err(e) = ctx.o_stack.push(obj) {
48                dispatch_error(ctx, &e)?;
49            }
50            continue;
51        }
52
53        match eval_one(ctx, obj) {
54            Ok(()) => {}
55            Err(PsError::Quit) => return Ok(()),
56            Err(PsError::Stop) => {
57                if unwind_to_stopped(ctx).is_ok() {
58                    if let Err(e) = ctx.o_stack.push(PsObject::bool(true)) {
59                        dispatch_error(ctx, &e)?;
60                    }
61                } else {
62                    return Err(PsError::Stop);
63                }
64            }
65            Err(PsError::Exit) => {
66                if let Err(e) = unwind_to_loop(ctx) {
67                    dispatch_error(ctx, &e)?;
68                }
69            }
70            Err(e) => {
71                dispatch_error(ctx, &e)?;
72            }
73        }
74    }
75    Ok(())
76}
77
78/// Synchronously execute a PostScript procedure and return.
79///
80/// Pushes the procedure onto the e_stack and runs the eval loop until the
81/// e_stack returns to its original depth. Used by operators that need to
82/// call PS procedures as callbacks (filter/image data sources, tint
83/// transforms, BuildChar, etc.).
84pub fn exec_sync(ctx: &mut Context, proc_obj: PsObject) -> Result<(), PsError> {
85    // Each nesting level is a native stack frame, and several of the ~47 call
86    // sites are re-entrant from PostScript — a `/Separation` tint transform
87    // that sets its own colour space is roughly 200 bytes and aborts the
88    // process on a stack overflow. `PsError::ExecStackOverflow` is the PLRM
89    // error for exhausting the execution stack, which is what this is.
90    if ctx.exec_sync_depth >= MAX_EXEC_SYNC_DEPTH {
91        return Err(PsError::ExecStackOverflow);
92    }
93    ctx.exec_sync_depth += 1;
94    let result = exec_sync_inner(ctx, proc_obj);
95    // Decrement through every exit, including the error paths inside.
96    ctx.exec_sync_depth -= 1;
97    result
98}
99
100/// Maximum re-entrancy depth for [`exec_sync`].
101///
102/// Mirrors `MAX_BOS_DEPTH` in `stet_core::binary_token`. Legitimate nesting is
103/// shallow: a tint transform inside a pattern inside a form is three levels,
104/// and Type 3 glyphs add one each. 100 is far past that while keeping the
105/// worst-case native stack well inside a default thread.
106const MAX_EXEC_SYNC_DEPTH: u32 = 100;
107
108fn exec_sync_inner(ctx: &mut Context, proc_obj: PsObject) -> Result<(), PsError> {
109    let base_depth = ctx.e_stack.len();
110    ctx.e_stack.push(proc_obj)?;
111
112    while ctx.e_stack.len() > base_depth {
113        if let Some(ref flag) = ctx.interrupt_flag
114            && flag.load(std::sync::atomic::Ordering::Relaxed)
115        {
116            return Err(PsError::Quit);
117        }
118        ctx.check_deadline()?;
119        let Some(mut obj) = ctx.e_stack.try_pop() else {
120            break;
121        };
122
123        if obj.flags.is_deferred() {
124            obj.flags.clear_deferred();
125            ctx.o_stack.push(obj)?;
126            continue;
127        }
128
129        if obj.flags.is_literal()
130            && !matches!(
131                obj.value,
132                PsValue::Stopped
133                    | PsValue::Loop(_)
134                    | PsValue::HardReturn
135                    | PsValue::DictEnd(_)
136                    | PsValue::ExecArray { .. }
137            )
138        {
139            ctx.o_stack.push(obj)?;
140            continue;
141        }
142
143        match eval_one(ctx, obj) {
144            Ok(()) => {}
145            Err(PsError::Quit) => return Ok(()),
146            Err(PsError::Stop) => {
147                // Only unwind within our scope
148                if unwind_to_stopped_bounded(ctx, base_depth).is_ok() {
149                    ctx.o_stack.push(PsObject::bool(true))?;
150                } else {
151                    return Err(PsError::Stop);
152                }
153            }
154            Err(PsError::Exit) => {
155                if unwind_to_loop_bounded(ctx, base_depth).is_err() {
156                    return Err(PsError::Exit);
157                }
158            }
159            Err(e) => {
160                dispatch_error(ctx, &e)?;
161            }
162        }
163    }
164
165    Ok(())
166}
167
168/// Unwind to nearest `Stopped` marker, but don't go below `min_depth`.
169fn unwind_to_stopped_bounded(ctx: &mut Context, min_depth: usize) -> Result<(), PsError> {
170    while ctx.e_stack.len() > min_depth {
171        if let Some(obj) = ctx.e_stack.try_pop() {
172            match obj.value {
173                PsValue::Stopped => return Ok(()),
174                PsValue::DictEnd(expected) => {
175                    pop_dict_end(ctx, expected);
176                }
177                _ => {}
178            }
179        }
180    }
181    Err(PsError::Stop)
182}
183
184/// Unwind to nearest `Loop` marker, but don't go below `min_depth`.
185fn unwind_to_loop_bounded(ctx: &mut Context, min_depth: usize) -> Result<(), PsError> {
186    while ctx.e_stack.len() > min_depth {
187        if let Some(obj) = ctx.e_stack.try_pop() {
188            match obj.value {
189                PsValue::Loop(_) => return Ok(()),
190                PsValue::Stopped => {
191                    ctx.e_stack.push(obj)?;
192                    return Err(PsError::InvalidExit);
193                }
194                PsValue::DictEnd(expected) => {
195                    pop_dict_end(ctx, expected);
196                }
197                _ => {}
198            }
199        }
200    }
201    Err(PsError::InvalidExit)
202}
203
204/// Process a single object from the execution stack.
205///
206/// Returns errors that the caller routes through `dispatch_error`.
207fn eval_one(ctx: &mut Context, obj: PsObject) -> Result<(), PsError> {
208    match obj.value {
209        // Simple types always push (even if executable flag is set)
210        PsValue::Int(_)
211        | PsValue::Real(_)
212        | PsValue::Bool(_)
213        | PsValue::Null
214        | PsValue::Mark
215        | PsValue::DictMark => {
216            ctx.o_stack.push(obj)?;
217        }
218
219        // Operators → dispatch (current_operator set lazily on error)
220        PsValue::Operator(opcode) => {
221            let func = ctx.operators[opcode.0 as usize].func;
222            if let Err(e) = func(ctx) {
223                ctx.current_operator = Some(ctx.operators[opcode.0 as usize].name);
224                return Err(e);
225            }
226        }
227
228        // Executable names → dictionary lookup
229        PsValue::Name(name_id) => {
230            let key = DictKey::Name(name_id);
231            match ctx.dict_load(&key) {
232                Some(val) => {
233                    if val.flags.is_executable() {
234                        ctx.e_stack.push(val)?;
235                    } else {
236                        ctx.o_stack.push(val)?;
237                    }
238                }
239                None => {
240                    ctx.current_operator = Some(name_id);
241                    return Err(PsError::Undefined);
242                }
243            }
244        }
245
246        // Executable arrays (procedures) → push elements in reverse
247        PsValue::Array { entity, start, len } => {
248            exec_procedure(ctx, entity, start, len)?;
249        }
250
251        // Executable strings → tokenize one token, advance start/len in place.
252        // SAFETY: String store data is not modified or reallocated during
253        // tokenization — we use a raw pointer to break the borrow conflict
254        // with &mut Context needed by scan_token_from_bytes.
255        PsValue::String { entity, start, len } => {
256            let bytes = ctx.strings.get(entity, start, len);
257            let (ptr, byte_len) = (bytes.as_ptr(), bytes.len());
258            let bytes = unsafe { std::slice::from_raw_parts(ptr, byte_len) };
259            if let Some((tok_obj, consumed, is_immediate, auto_exec)) =
260                scan_token_from_bytes(ctx, bytes)?
261            {
262                let newlines = count_newlines(&bytes[..consumed]);
263                ctx.current_source_line += newlines;
264
265                // Push continuation with advanced start and reduced len
266                let remaining = len - consumed as u32;
267                if remaining > 0 {
268                    ctx.e_stack.push(PsObject {
269                        value: PsValue::String {
270                            entity,
271                            start: start + consumed as u32,
272                            len: remaining,
273                        },
274                        flags: ObjFlags::executable_composite(),
275                    })?;
276                }
277
278                if auto_exec {
279                    ctx.e_stack.push(tok_obj)?;
280                } else {
281                    dispatch_scanned_token(ctx, tok_obj, is_immediate)?;
282                }
283            }
284        }
285
286        // Procedure cursor → tight inner loop over consecutive elements.
287        //
288        // Instead of processing one element per eval loop iteration (which
289        // requires pushing/popping the cursor on e_stack each time), we loop
290        // through elements inline. We only yield back to the eval loop when
291        // an operator pushes to e_stack (control flow like `if`, `exec`,
292        // loops) or when we encounter a non-operator executable value.
293        PsValue::ExecArray {
294            entity,
295            start,
296            len,
297            pos,
298        } => {
299            let mut cur_pos = pos;
300            let ea_flags = obj.flags;
301
302            // Dispatch an operator inline. Returns true if the eval loop
303            // should take over (operator pushed to e_stack). On error,
304            // pushes continuation cursor so error recovery can resume.
305            //
306            // current_operator is set lazily — only on error — to avoid
307            // 2 writes per operator call in the hot path.
308            macro_rules! dispatch_op {
309                ($opcode:expr) => {{
310                    let e_depth = ctx.e_stack.len();
311                    let func = ctx.operators[$opcode.0 as usize].func;
312                    let result = func(ctx);
313                    match result {
314                        Ok(()) => {
315                            if ctx.e_stack.len() > e_depth {
316                                // Operator pushed to e_stack (if/exec/loop/etc).
317                                // Insert our continuation below what was pushed.
318                                if cur_pos < len {
319                                    ctx.e_stack.insert_at(
320                                        e_depth,
321                                        PsObject {
322                                            value: PsValue::ExecArray {
323                                                entity,
324                                                start,
325                                                len,
326                                                pos: cur_pos,
327                                            },
328                                            flags: ea_flags,
329                                        },
330                                    )?;
331                                }
332                                true // yield to eval loop
333                            } else {
334                                false // continue inner loop
335                            }
336                        }
337                        Err(e) => {
338                            // Set current_operator lazily on error
339                            ctx.current_operator = Some(ctx.operators[$opcode.0 as usize].name);
340                            if cur_pos < len {
341                                ctx.e_stack.push(PsObject {
342                                    value: PsValue::ExecArray {
343                                        entity,
344                                        start,
345                                        len,
346                                        pos: cur_pos,
347                                    },
348                                    flags: ea_flags,
349                                })?;
350                            }
351                            return Err(e);
352                        }
353                    }
354                }};
355            }
356
357            'ea_loop: loop {
358                let elem = ctx.arrays.get_element(entity, start + cur_pos);
359                cur_pos += 1;
360
361                match elem.value {
362                    PsValue::Operator(opcode) => {
363                        if dispatch_op!(opcode) {
364                            break 'ea_loop;
365                        }
366                    }
367
368                    PsValue::Name(name_id) if elem.flags.is_executable() => {
369                        // Inline name cache check — avoids DictKey construction
370                        // and dict_load function call overhead on cache hits.
371                        let idx = name_id.0 as usize;
372                        let val = if idx < ctx.name_resolve_cache.len() {
373                            let (ver, cached) = ctx.name_resolve_cache[idx];
374                            if ver == ctx.dict_version {
375                                cached
376                            } else {
377                                match ctx.dict_load(&DictKey::Name(name_id)) {
378                                    Some(v) => v,
379                                    None => {
380                                        ctx.current_operator = Some(name_id);
381                                        if cur_pos < len {
382                                            ctx.e_stack.push(PsObject {
383                                                value: PsValue::ExecArray {
384                                                    entity,
385                                                    start,
386                                                    len,
387                                                    pos: cur_pos,
388                                                },
389                                                flags: ea_flags,
390                                            })?;
391                                        }
392                                        return Err(PsError::Undefined);
393                                    }
394                                }
395                            }
396                        } else {
397                            match ctx.dict_load(&DictKey::Name(name_id)) {
398                                Some(v) => v,
399                                None => {
400                                    ctx.current_operator = Some(name_id);
401                                    if cur_pos < len {
402                                        ctx.e_stack.push(PsObject {
403                                            value: PsValue::ExecArray {
404                                                entity,
405                                                start,
406                                                len,
407                                                pos: cur_pos,
408                                            },
409                                            flags: ea_flags,
410                                        })?;
411                                    }
412                                    return Err(PsError::Undefined);
413                                }
414                            }
415                        };
416
417                        match val.value {
418                            PsValue::Operator(opcode) => {
419                                if dispatch_op!(opcode) {
420                                    break 'ea_loop;
421                                }
422                            }
423                            _ => {
424                                // Non-operator: push cursor and dispatch value
425                                if cur_pos < len {
426                                    ctx.e_stack.push(PsObject {
427                                        value: PsValue::ExecArray {
428                                            entity,
429                                            start,
430                                            len,
431                                            pos: cur_pos,
432                                        },
433                                        flags: ea_flags,
434                                    })?;
435                                }
436                                if val.flags.is_executable() {
437                                    ctx.e_stack.push(val)?;
438                                } else {
439                                    ctx.o_stack.push(val)?;
440                                }
441                                break 'ea_loop;
442                            }
443                        }
444                    }
445
446                    _ => {
447                        if elem.is_array_type() && elem.flags.is_executable() {
448                            // Nested procedure body → o_stack (for if/exec to pick up)
449                            ctx.o_stack.push(elem)?;
450                        } else if matches!(
451                            elem.value,
452                            PsValue::Int(_)
453                                | PsValue::Real(_)
454                                | PsValue::Bool(_)
455                                | PsValue::Null
456                                | PsValue::Mark
457                                | PsValue::DictMark
458                        ) || elem.flags.is_literal()
459                        {
460                            // Literals and simple values → o_stack directly
461                            ctx.o_stack.push(elem)?;
462                        } else {
463                            // Rare: executable non-name/non-operator → e_stack, yield
464                            if cur_pos < len {
465                                ctx.e_stack.push(PsObject {
466                                    value: PsValue::ExecArray {
467                                        entity,
468                                        start,
469                                        len,
470                                        pos: cur_pos,
471                                    },
472                                    flags: ea_flags,
473                                })?;
474                            }
475                            ctx.e_stack.push(elem)?;
476                            break 'ea_loop;
477                        }
478                    }
479                }
480
481                if cur_pos >= len {
482                    break;
483                }
484            }
485        }
486
487        // Executable file → tokenize one token from FileStore
488        PsValue::File(file_entity) => {
489            // Flush deferred newlines from the previous token so `line`
490            // reports the line this token is on, not the previous one.
491            ctx.files.flush_pending_newlines(file_entity);
492
493            // Fast path: StringSource files have all data in memory.
494            // We grab a raw pointer to avoid copying the remaining bytes on
495            // every token read (which was an O(n^2) bottleneck for large files).
496            // SAFETY: The StringSource data Vec is not modified or reallocated
497            // during tokenization — only `pos` is advanced afterward.
498            let remaining = ctx.files.get_remaining_bytes(file_entity);
499            let (ptr, len) = (remaining.as_ptr(), remaining.len());
500            if len > 0 {
501                let remaining = unsafe { std::slice::from_raw_parts(ptr, len) };
502                if let Some((tok_obj, consumed, is_immediate, auto_exec)) =
503                    scan_token_from_bytes(ctx, remaining)?
504                {
505                    let newlines = count_newlines(&remaining[..consumed]);
506                    ctx.current_source_line += newlines;
507                    ctx.files.add_pending_newlines(file_entity, newlines);
508                    ctx.files.advance_position(file_entity, consumed);
509                    if consumed < remaining.len() {
510                        ctx.e_stack.push(obj)?;
511                    }
512                    if auto_exec {
513                        ctx.e_stack.push(tok_obj)?;
514                    } else {
515                        dispatch_scanned_token(ctx, tok_obj, is_immediate)?;
516                    }
517                }
518            } else if ctx.files.is_readable(file_entity) {
519                // Streaming path: filter/real files — byte-at-a-time tokenization.
520                // The tokenizer only gets the FileStore, so a filter whose data
521                // source is a procedure has to be run before it starts.
522                ctx.pump_proc_sources(file_entity)?;
523                if let Some((token, newlines)) = stream_next_token(&mut ctx.files, file_entity)? {
524                    ctx.current_source_line += newlines;
525                    ctx.files.add_pending_newlines(file_entity, newlines);
526                    let is_immediate = matches!(token, Token::ImmediateName(_));
527                    let (tok_obj, auto_exec) = if let Token::BinaryTokenByte(tag) = token {
528                        let result =
529                            stet_core::binary_token::parse_from_stream(ctx, tag, file_entity)?;
530                        match result {
531                            stet_core::binary_token::BinaryTokenResult::Single(o) => (o, false),
532                            stet_core::binary_token::BinaryTokenResult::Sequence(o) => (o, true),
533                        }
534                    } else if matches!(token, Token::ProcBegin) {
535                        (stream_parse_procedure(ctx, file_entity)?, false)
536                    } else {
537                        (token_to_object(ctx, token)?, false)
538                    };
539                    if ctx.files.is_readable(file_entity) {
540                        ctx.e_stack.push(obj)?;
541                    }
542                    if auto_exec {
543                        ctx.e_stack.push(tok_obj)?;
544                    } else {
545                        dispatch_scanned_token(ctx, tok_obj, is_immediate)?;
546                    }
547                }
548            }
549        }
550
551        // Stopped marker → push false (normal completion, no stop)
552        PsValue::Stopped => {
553            ctx.o_stack.push(PsObject::bool(false))?;
554        }
555
556        // Loop state → advance loop
557        PsValue::Loop(loop_entity) => {
558            advance_loop(ctx, loop_entity)?;
559        }
560
561        // HardReturn → just consume (exit current procedure level)
562        PsValue::HardReturn => {}
563
564        // DictEnd → conditionally pop the dict stack (resource operator cleanup)
565        PsValue::DictEnd(expected) => {
566            pop_dict_end(ctx, expected);
567        }
568
569        // Everything else → push to operand stack
570        _ => {
571            ctx.o_stack.push(obj)?;
572        }
573    }
574    Ok(())
575}
576
577/// Push a procedure cursor onto the execution stack.
578///
579/// Instead of expanding all procedure elements onto the e_stack, we push a
580/// single `ExecArray` cursor that the eval loop advances one element at a time.
581/// Each procedure occupies exactly one exec-stack item.
582fn exec_procedure(
583    ctx: &mut Context,
584    entity: EntityId,
585    start: u32,
586    len: u32,
587) -> Result<(), PsError> {
588    if len == 0 {
589        return Ok(());
590    }
591    ctx.e_stack.push(PsObject {
592        value: PsValue::ExecArray {
593            entity,
594            start,
595            len,
596            pos: 0,
597        },
598        flags: ObjFlags::executable_composite(),
599    })?;
600    Ok(())
601}
602
603/// Scan one token from a byte slice, handling procedures and immediate names.
604///
605/// Returns `(token_object, bytes_consumed, is_immediate, auto_exec)` or `None` at EOF.
606/// `auto_exec` is true for BOS sequences which should be pushed to e_stack.
607fn scan_token_from_bytes(
608    ctx: &mut Context,
609    bytes: &[u8],
610) -> Result<Option<(PsObject, usize, bool, bool)>, PsError> {
611    let mut tokenizer = Tokenizer::new(bytes);
612    match tokenizer.next_token()? {
613        Some(Token::BinaryTokenByte(tag)) => {
614            let pos = tokenizer.position();
615            let (result, consumed) =
616                stet_core::binary_token::parse_from_slice(ctx, tag, &bytes[pos..])?;
617            let total = pos + consumed;
618            match result {
619                stet_core::binary_token::BinaryTokenResult::Single(obj) => {
620                    Ok(Some((obj, total, false, false)))
621                }
622                stet_core::binary_token::BinaryTokenResult::Sequence(obj) => {
623                    Ok(Some((obj, total, false, true)))
624                }
625            }
626        }
627        Some(token) => {
628            let is_immediate = matches!(token, Token::ImmediateName(_));
629            // Numbers and executable names: consume one trailing whitespace (PLRM)
630            let eats_whitespace =
631                matches!(token, Token::Int(_) | Token::Real(_) | Token::Name(_, _));
632            let tok_obj = if matches!(token, Token::ProcBegin) {
633                parse_procedure(ctx, &mut tokenizer)?
634            } else {
635                token_to_object(ctx, token)?
636            };
637            let mut consumed = tokenizer.position();
638            if eats_whitespace && consumed < bytes.len() && is_ps_whitespace(bytes[consumed]) {
639                consumed += 1;
640            }
641            Ok(Some((tok_obj, consumed, is_immediate, false)))
642        }
643        None => Ok(None),
644    }
645}
646
647/// PostScript whitespace check for trailing-whitespace consumption.
648fn is_ps_whitespace(b: u8) -> bool {
649    matches!(b, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
650}
651
652/// Dispatch a freshly-scanned token to the appropriate stack.
653///
654/// Executable names → e_stack (for dict lookup), everything else → o_stack.
655/// Immediate names (`//name`) skip execution since they were already resolved.
656fn dispatch_scanned_token(
657    ctx: &mut Context,
658    tok_obj: PsObject,
659    is_immediate: bool,
660) -> Result<(), PsError> {
661    if matches!(tok_obj.value, PsValue::Name(_)) && tok_obj.flags.is_executable() && !is_immediate {
662        ctx.e_stack.push(tok_obj)?;
663    } else {
664        ctx.o_stack.push(tok_obj)?;
665    }
666    Ok(())
667}
668
669/// Parse a `{ ... }` procedure body from a streaming file source.
670///
671/// Called when the streaming tokenizer returns `ProcBegin`. Reads tokens
672/// from the file byte-by-byte until the matching `}`.
673fn stream_parse_procedure(ctx: &mut Context, file_entity: EntityId) -> Result<PsObject, PsError> {
674    stream_parse_procedure_at(ctx, file_entity, 0)
675}
676
677/// [`stream_parse_procedure`], carrying the `{`-nesting depth.
678///
679/// The streaming reader is a separate recursive descent from
680/// [`parse_procedure`] and needs the same cap; it is the path taken when
681/// PostScript is read from a file rather than an in-memory buffer, which is
682/// the ordinary case for the CLI.
683fn stream_parse_procedure_at(
684    ctx: &mut Context,
685    file_entity: EntityId,
686    depth: u32,
687) -> Result<PsObject, PsError> {
688    if depth >= MAX_PROC_DEPTH {
689        return Err(PsError::LimitCheck);
690    }
691    let mut elements = Vec::new();
692
693    loop {
694        ctx.pump_proc_sources(file_entity)?;
695        match stream_next_token(&mut ctx.files, file_entity)? {
696            None => return Err(PsError::SyntaxError), // unterminated
697            Some((Token::ProcEnd, _)) => break,
698            Some((Token::ProcBegin, _)) => {
699                let nested = stream_parse_procedure_at(ctx, file_entity, depth + 1)?;
700                elements.push(nested);
701            }
702            Some((Token::BinaryTokenByte(tag), _)) => {
703                let result = stet_core::binary_token::parse_from_stream(ctx, tag, file_entity)?;
704                let obj = match result {
705                    stet_core::binary_token::BinaryTokenResult::Single(o) => o,
706                    stet_core::binary_token::BinaryTokenResult::Sequence(o) => o,
707                };
708                elements.push(obj);
709            }
710            Some((token, _)) => {
711                let obj = token_to_object(ctx, token)?;
712                elements.push(obj);
713            }
714        }
715    }
716
717    let len = elements.len();
718    let save_level = ctx.save_stack.current_level();
719    let global = ctx.vm_alloc_mode;
720    let created = ctx.save_stack.last_save_id();
721    let entity = ctx.arrays.allocate_with(len, save_level, global, created);
722    let dest = ctx.arrays.get_mut(entity, 0, len as u32);
723    dest.copy_from_slice(&elements);
724
725    let mut obj = PsObject::procedure(entity, len as u32);
726    if global {
727        obj.flags = ObjFlags::new(ObjFlags::ACCESS_UNLIMITED, true, true, true);
728    }
729    Ok(obj)
730}
731
732/// Count newline characters in a byte slice.
733/// Handles CR, LF, and CR-LF (treated as one newline).
734fn count_newlines(bytes: &[u8]) -> u32 {
735    let mut count = 0u32;
736    let mut i = 0;
737    while i < bytes.len() {
738        match bytes[i] {
739            b'\r' => {
740                count += 1;
741                // CR-LF counts as one newline
742                if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
743                    i += 1;
744                }
745            }
746            b'\n' | b'\x0c' => {
747                count += 1;
748            }
749            _ => {}
750        }
751        i += 1;
752    }
753    count
754}
755
756/// Advance a loop iteration (for, repeat, loop, forall).
757fn advance_loop(ctx: &mut Context, loop_entity: EntityId) -> Result<(), PsError> {
758    use stet_core::context::LoopType;
759
760    let loop_state = ctx.get_loop(loop_entity);
761    let loop_type = match loop_state.loop_type {
762        LoopType::For => 0,
763        LoopType::Repeat => 1,
764        LoopType::Loop => 2,
765        LoopType::Forall => 3,
766        LoopType::PathForall => 4,
767    };
768    let proc_entity = loop_state.proc_entity;
769    let proc_start = loop_state.proc_start;
770    let proc_len = loop_state.proc_len;
771
772    match loop_type {
773        0 => {
774            // For loop
775            let counter = ctx.get_loop(loop_entity).counter;
776            let increment = ctx.get_loop(loop_entity).increment;
777            let limit = ctx.get_loop(loop_entity).limit;
778            let use_int = ctx.get_loop(loop_entity).use_int;
779
780            let done = if increment > 0.0 {
781                counter > limit
782            } else {
783                counter < limit
784            };
785
786            if done {
787                return Ok(());
788            }
789
790            // Push counter value
791            if use_int {
792                ctx.o_stack.push(PsObject::int(counter as i32))?;
793            } else {
794                ctx.o_stack.push(PsObject::real(counter))?;
795            }
796
797            // Update counter for next iteration
798            let new_counter = counter + increment;
799            ctx.get_loop_mut(loop_entity).counter = new_counter;
800
801            // Push loop marker back, then procedure
802            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
803            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
804        }
805        1 => {
806            // Repeat loop
807            let counter = ctx.get_loop(loop_entity).counter;
808            if counter <= 0.0 {
809                return Ok(());
810            }
811            ctx.get_loop_mut(loop_entity).counter = counter - 1.0;
812            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
813            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
814        }
815        2 => {
816            // Infinite loop
817            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
818            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
819        }
820        3 => {
821            // Forall
822            advance_forall(ctx, loop_entity, proc_entity, proc_start, proc_len)?;
823        }
824        4 => {
825            // PathForall
826            advance_pathforall(ctx, loop_entity)?;
827        }
828        _ => unreachable!(),
829    }
830
831    Ok(())
832}
833
834/// Advance a forall iteration.
835fn advance_forall(
836    ctx: &mut Context,
837    loop_entity: EntityId,
838    proc_entity: EntityId,
839    proc_start: u32,
840    proc_len: u32,
841) -> Result<(), PsError> {
842    let source = ctx.get_loop(loop_entity).source;
843    let index = ctx.get_loop(loop_entity).index;
844
845    match source.value {
846        PsValue::Array { entity, start, len } | PsValue::PackedArray { entity, start, len } => {
847            if index >= len {
848                return Ok(());
849            }
850            let elem = ctx.arrays.get_element(entity, start + index);
851            ctx.o_stack.push(elem)?;
852            ctx.get_loop_mut(loop_entity).index = index + 1;
853            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
854            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
855        }
856        PsValue::String { entity, start, len } => {
857            if index >= len {
858                return Ok(());
859            }
860            let byte = ctx.strings.get_byte(entity, start + index);
861            ctx.o_stack.push(PsObject::int(byte as i32))?;
862            ctx.get_loop_mut(loop_entity).index = index + 1;
863            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
864            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
865        }
866        PsValue::Dict(dict_entity) => {
867            // Keys were snapshotted at forall creation time.
868            // SAFETY: dict_keys is always Some for dict forall loops.
869            let keys = ctx.get_loop(loop_entity).dict_keys.as_ref().unwrap();
870            if (index as usize) >= keys.len() {
871                return Ok(());
872            }
873            let key = keys[index as usize].clone();
874            let val = ctx.dicts.get(dict_entity, &key).unwrap_or(PsObject::null());
875
876            // Push key and value
877            let key_obj = dict_key_to_object(ctx, &key);
878            ctx.o_stack.push(key_obj)?;
879            ctx.o_stack.push(val)?;
880
881            ctx.get_loop_mut(loop_entity).index = index + 1;
882            ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
883            exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
884        }
885        _ => return Err(PsError::TypeCheck),
886    }
887
888    Ok(())
889}
890
891/// Advance a pathforall iteration: process one path segment per call.
892fn advance_pathforall(ctx: &mut Context, loop_entity: EntityId) -> Result<(), PsError> {
893    use stet_core::geometry::PathSegment;
894
895    let index = ctx.get_loop(loop_entity).index as usize;
896
897    // Check if we've exhausted all segments
898    let seg_len = ctx
899        .get_loop(loop_entity)
900        .path_segments
901        .as_ref()
902        .map_or(0, |s| s.len());
903    if index >= seg_len {
904        return Ok(());
905    }
906
907    // Extract segment data, ictm, and proc for this iteration
908    let loop_state = ctx.get_loop(loop_entity);
909    let seg = loop_state.path_segments.as_ref().unwrap()[index].clone();
910    let ictm = loop_state.path_ictm.unwrap();
911    let procs = loop_state.path_procs.unwrap();
912
913    // Determine which proc to call and push the arguments
914    let proc = match seg {
915        PathSegment::MoveTo(dx, dy) => {
916            let (ux, uy) = ictm.transform_point(dx, dy);
917            ctx.o_stack.push(PsObject::real(ux))?;
918            ctx.o_stack.push(PsObject::real(uy))?;
919            procs[0] // move_proc
920        }
921        PathSegment::LineTo(dx, dy) => {
922            let (ux, uy) = ictm.transform_point(dx, dy);
923            ctx.o_stack.push(PsObject::real(ux))?;
924            ctx.o_stack.push(PsObject::real(uy))?;
925            procs[1] // line_proc
926        }
927        PathSegment::CurveTo {
928            x1,
929            y1,
930            x2,
931            y2,
932            x3,
933            y3,
934        } => {
935            let (ux1, uy1) = ictm.transform_point(x1, y1);
936            let (ux2, uy2) = ictm.transform_point(x2, y2);
937            let (ux3, uy3) = ictm.transform_point(x3, y3);
938            ctx.o_stack.push(PsObject::real(ux1))?;
939            ctx.o_stack.push(PsObject::real(uy1))?;
940            ctx.o_stack.push(PsObject::real(ux2))?;
941            ctx.o_stack.push(PsObject::real(uy2))?;
942            ctx.o_stack.push(PsObject::real(ux3))?;
943            ctx.o_stack.push(PsObject::real(uy3))?;
944            procs[2] // curve_proc
945        }
946        PathSegment::ClosePath => {
947            procs[3] // close_proc
948        }
949    };
950
951    // Advance index for next iteration
952    ctx.get_loop_mut(loop_entity).index = (index + 1) as u32;
953
954    // Push loop marker back, then the callback procedure
955    ctx.e_stack.push(PsObject::loop_mark(loop_entity))?;
956    let (proc_entity, proc_start, proc_len) = match proc.value {
957        PsValue::Array { entity, start, len } => (entity, start, len),
958        _ => return Err(PsError::TypeCheck),
959    };
960    exec_procedure(ctx, proc_entity, proc_start, proc_len)?;
961
962    Ok(())
963}
964
965/// Convert a DictKey back to a PsObject for forall iteration.
966fn dict_key_to_object(ctx: &mut Context, key: &DictKey) -> PsObject {
967    match key {
968        DictKey::Name(id) => PsObject::name_lit(*id),
969        DictKey::Int(v) => PsObject::int(*v),
970        DictKey::Real(bits) => PsObject::real(f64::from_bits(*bits)),
971        DictKey::Bool(v) => PsObject::bool(*v),
972        DictKey::String(bytes) => {
973            let entity = stet_ops::vm_ops::alloc_string(ctx, bytes);
974            PsObject::string(entity, bytes.len() as u32)
975        }
976        DictKey::Operator(op) => {
977            use stet_core::object::OpCode;
978            PsObject::operator(OpCode(*op))
979        }
980        DictKey::Identity(eid, _start, len) => {
981            // Return as array (best approximation for identity keys)
982            PsObject::array(EntityId(*eid), *len)
983        }
984    }
985}
986
987/// Dispatch a PostScript error via errordict.
988///
989/// PLRM error dispatch:
990/// 1. Look up the error name in errordict → get handler procedure
991/// 2. Handler populates `$error` dict and calls `stop`
992/// 3. If no handler found, or if already in error handler, fall back to eprintln
993fn dispatch_error(ctx: &mut Context, error: &PsError) -> Result<(), PsError> {
994    // Guard against infinite recursion
995    if ctx.in_error_handler {
996        use std::io::Write;
997        let _ = writeln!(ctx.stdout, "Error (in handler): {}", error);
998        return Ok(());
999    }
1000
1001    let error_name = error.to_string();
1002    let error_name_id = ctx.names.intern(error_name.as_bytes());
1003    let error_key = DictKey::Name(error_name_id);
1004
1005    // Look up error handler in errordict
1006    if let Some(handler) = ctx.dicts.get(ctx.errordict, &error_key)
1007        && handler.flags.is_executable()
1008    {
1009        ctx.in_error_handler = true;
1010
1011        // Push offending command name on operand stack — the errordict handler
1012        // pushes the error name, then `.error` expects `command errorname` on
1013        // the stack. Use the current operator if available, otherwise the error
1014        // name itself.
1015        let cmd_name = ctx.current_operator.unwrap_or(error_name_id);
1016        ctx.current_operator = None;
1017        ctx.o_stack.push(PsObject::name_lit(cmd_name))?;
1018
1019        // Push handler on e_stack for natural execution by the eval loop.
1020        // The handler (e.g. `{ /undefined //.error exec }`) will populate
1021        // $error and call `stop`, which propagates naturally to be caught by
1022        // any enclosing `stopped` context.
1023        ctx.e_stack.push(handler)?;
1024
1025        ctx.in_error_handler = false;
1026        return Ok(());
1027    }
1028
1029    // Fallback: no handler found, print to stdout (like stderr)
1030    use std::io::Write;
1031    let _ = writeln!(ctx.stdout, "Error: {}", error);
1032    Ok(())
1033}
1034
1035/// Unwind execution stack to the nearest `Stopped` marker.
1036/// DictEnd markers encountered during unwinding conditionally pop the dict
1037/// stack (only if the expected dict is still on top).
1038fn unwind_to_stopped(ctx: &mut Context) -> Result<(), PsError> {
1039    while let Some(obj) = ctx.e_stack.try_pop() {
1040        match obj.value {
1041            PsValue::Stopped => return Ok(()),
1042            PsValue::DictEnd(expected) => {
1043                pop_dict_end(ctx, expected);
1044            }
1045            _ => {}
1046        }
1047    }
1048    // No stopped marker found — propagate
1049    Err(PsError::Stop)
1050}
1051
1052/// Unwind execution stack to the nearest `Loop` marker.
1053fn unwind_to_loop(ctx: &mut Context) -> Result<(), PsError> {
1054    while let Some(obj) = ctx.e_stack.try_pop() {
1055        match obj.value {
1056            PsValue::Loop(_) => return Ok(()),
1057            PsValue::Stopped => {
1058                // Don't pop past stopped — push it back and dispatch error
1059                ctx.e_stack.push(obj)?;
1060                return Err(PsError::InvalidExit);
1061            }
1062            PsValue::DictEnd(expected) => {
1063                pop_dict_end(ctx, expected);
1064            }
1065            _ => {}
1066        }
1067    }
1068    Err(PsError::InvalidExit)
1069}
1070
1071/// Conditionally pop the dict stack for a DictEnd marker.
1072/// Only pops if the top of d_stack is still the expected entity — the PS
1073/// procedure may have already called `end` to clean up (e.g., on error paths).
1074fn pop_dict_end(ctx: &mut Context, expected: EntityId) {
1075    if ctx.d_stack.last() == Some(&expected) {
1076        ctx.d_stack.pop();
1077        ctx.invalidate_name_cache();
1078    }
1079}
1080
1081/// Convert a token to a PsObject. Delegates to `Context::token_to_object`.
1082pub fn token_to_object(ctx: &mut Context, token: Token) -> Result<PsObject, PsError> {
1083    ctx.token_to_object(token)
1084}
1085
1086/// Tokenize a byte stream and execute it.
1087///
1088/// Creates a file-backed source in the FileStore and pushes it as an
1089/// executable `File` on the exec stack. The eval loop tokenizes and
1090/// executes one token at a time, ensuring `//name` immediate lookups
1091/// see earlier definitions and ALL errors route through errordict.
1092///
1093/// Using a File (not an executable string) means `currentfile` can find
1094/// the source — this is correct for top-level execution of PS files.
1095pub fn parse_and_exec(ctx: &mut Context, source: &[u8]) -> Result<(), PsError> {
1096    let file_entity = ctx.files.create_string_source(source.to_vec());
1097    ctx.e_stack.push(PsObject {
1098        value: PsValue::File(file_entity),
1099        flags: ObjFlags::executable_composite(),
1100    })?;
1101    eval(ctx)
1102}
1103
1104/// Execute PostScript source loaded from a named file.
1105///
1106/// Like `parse_and_exec`, but records the file path on the StringSource
1107/// entity so that `resolve_filename` can find the file's parent directory
1108/// when resolving relative paths in nested `run`/`file` calls.
1109pub fn parse_and_exec_file(ctx: &mut Context, source: &[u8], path: &str) -> Result<(), PsError> {
1110    let file_entity = ctx.files.create_string_source(source.to_vec());
1111    // Record the canonical path so resolve_filename can extract its directory.
1112    let canonical = std::path::Path::new(path)
1113        .canonicalize()
1114        .unwrap_or_else(|_| std::path::PathBuf::from(path));
1115    ctx.files
1116        .set_name(file_entity, canonical.to_string_lossy().to_string());
1117    ctx.e_stack.push(PsObject {
1118        value: PsValue::File(file_entity),
1119        flags: ObjFlags::executable_composite(),
1120    })?;
1121    eval(ctx)
1122}
1123
1124/// Parse a `{ ... }` procedure body (recursive for nested procedures).
1125fn parse_procedure(ctx: &mut Context, tokenizer: &mut Tokenizer) -> Result<PsObject, PsError> {
1126    parse_procedure_at(ctx, tokenizer, 0)
1127}
1128
1129/// Maximum `{`-nesting depth when parsing a procedure.
1130///
1131/// `parse_procedure` is recursive descent, so each `{` is a native stack
1132/// frame; 200000 of them is 400 KB of input and a stack-overflow abort. Named
1133/// to match `MAX_BOS_DEPTH` in `stet_core::binary_token`, which caps the
1134/// equivalent nesting in the binary-object-sequence decoder, and set to the
1135/// same value: real PostScript nests procedures a handful of levels deep.
1136const MAX_PROC_DEPTH: u32 = 100;
1137
1138fn parse_procedure_at(
1139    ctx: &mut Context,
1140    tokenizer: &mut Tokenizer,
1141    depth: u32,
1142) -> Result<PsObject, PsError> {
1143    if depth >= MAX_PROC_DEPTH {
1144        return Err(PsError::LimitCheck);
1145    }
1146    let mut elements = Vec::new();
1147
1148    loop {
1149        match tokenizer.next_token()? {
1150            Some(Token::ProcEnd) => break,
1151            Some(Token::ProcBegin) => {
1152                let nested = parse_procedure_at(ctx, tokenizer, depth + 1)?;
1153                elements.push(nested);
1154            }
1155            Some(Token::BinaryTokenByte(tag)) => {
1156                let pos = tokenizer.position();
1157                // SAFETY: tokenizer borrows from a stable slice; we access the
1158                // underlying input via position.
1159                let input_bytes = tokenizer.remaining_from(pos);
1160                let (result, consumed) =
1161                    stet_core::binary_token::parse_from_slice(ctx, tag, input_bytes)?;
1162                tokenizer.advance(consumed);
1163                let obj = match result {
1164                    stet_core::binary_token::BinaryTokenResult::Single(o) => o,
1165                    stet_core::binary_token::BinaryTokenResult::Sequence(o) => o,
1166                };
1167                elements.push(obj);
1168            }
1169            Some(token) => {
1170                let obj = token_to_object(ctx, token)?;
1171                elements.push(obj);
1172            }
1173            None => return Err(PsError::SyntaxError), // unterminated procedure
1174        }
1175    }
1176
1177    let len = elements.len();
1178    let save_level = ctx.save_stack.current_level();
1179    let global = ctx.vm_alloc_mode;
1180    let created = ctx.save_stack.last_save_id();
1181    let entity = ctx.arrays.allocate_with(len, save_level, global, created);
1182    let dest = ctx.arrays.get_mut(entity, 0, len as u32);
1183    dest.copy_from_slice(&elements);
1184
1185    let mut obj = PsObject::procedure(entity, len as u32);
1186    if global {
1187        obj.flags = ObjFlags::new(ObjFlags::ACCESS_UNLIMITED, true, true, true);
1188    }
1189    Ok(obj)
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195    use std::io::Write;
1196
1197    #[test]
1198    fn test_parse_procedure() {
1199        let mut ctx = Context::new();
1200        let mut tokenizer = Tokenizer::new(b"1 2 add }");
1201        let proc_obj = parse_procedure(&mut ctx, &mut tokenizer).unwrap();
1202        assert!(proc_obj.flags.is_executable());
1203        assert!(proc_obj.is_array_type());
1204        match proc_obj.value {
1205            PsValue::Array { len, .. } => assert_eq!(len, 3),
1206            _ => panic!("Expected array"),
1207        }
1208    }
1209
1210    #[test]
1211    fn test_nested_procedure() {
1212        let mut ctx = Context::new();
1213        let mut tokenizer = Tokenizer::new(b"{ 1 add } exec }");
1214        let proc_obj = parse_procedure(&mut ctx, &mut tokenizer).unwrap();
1215        match proc_obj.value {
1216            PsValue::Array { len, .. } => assert_eq!(len, 2), // { 1 add } and exec
1217            _ => panic!("Expected array"),
1218        }
1219    }
1220
1221    // --- Phase 2 integration tests (done-when criteria) ---
1222
1223    fn setup_ctx() -> (Context, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
1224        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1225        let writer = buf.clone();
1226
1227        struct ArcWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
1228        impl Write for ArcWriter {
1229            fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
1230                self.0.lock().unwrap().extend_from_slice(data);
1231                Ok(data.len())
1232            }
1233            fn flush(&mut self) -> std::io::Result<()> {
1234                Ok(())
1235            }
1236        }
1237
1238        let mut ctx = Context::new_with_output(Box::new(ArcWriter(writer)));
1239        stet_ops::build_system_dict(&mut ctx);
1240        (ctx, buf)
1241    }
1242
1243    fn run_ps(source: &[u8]) -> String {
1244        let (mut ctx, buf) = setup_ctx();
1245        parse_and_exec(&mut ctx, source).ok();
1246        String::from_utf8(buf.lock().unwrap().clone()).unwrap()
1247    }
1248
1249    /// Done-when #2: save/restore reverts definitions.
1250    /// `save /s exch def /x 1 def s restore x =` — x should be undefined after restore
1251    #[test]
1252    fn test_save_restore_reverts_def() {
1253        let (mut ctx, buf) = setup_ctx();
1254        // Define x before save for baseline
1255        let result = parse_and_exec(&mut ctx, b"save /s exch def /x 1 def s restore");
1256        assert!(result.is_ok());
1257        // x should be undefined now — trying to use it should trigger an error
1258        let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1259        // Verify x is not defined by checking that a lookup would fail
1260        let x_id = ctx.names.intern(b"x");
1261        let key = DictKey::Name(x_id);
1262        assert!(
1263            ctx.dict_load(&key).is_none(),
1264            "x should be undefined after restore"
1265        );
1266        drop(output);
1267    }
1268
1269    /// Done-when #3: save/restore reverts array mutations.
1270    /// Define array before save, mutate after save, restore reverts mutation.
1271    #[test]
1272    fn test_save_restore_reverts_array() {
1273        let output = run_ps(b"[1 2 3] /a exch def save /s exch def a 1 99 put s restore a 1 get =");
1274        assert_eq!(output.trim(), "2");
1275    }
1276
1277    /// Done-when #4: File round-trip
1278    #[test]
1279    fn test_file_round_trip() {
1280        // Use forward slashes even on Windows — PostScript string-literal
1281        // syntax treats `\` as an escape character, so a native Windows
1282        // path like `C:\Users\...` would be mangled inside `(...)`.
1283        // Rust's fs APIs on Windows accept `/` just fine.
1284        let path = std::env::temp_dir()
1285            .join("stet_phase2_file_test.txt")
1286            .to_string_lossy()
1287            .replace('\\', "/");
1288        let source = format!(
1289            "({}) (w) file /f exch def f (hello world) writestring f closefile \
1290             ({}) (r) file /f exch def f 11 string readstring pop print f closefile",
1291            path, path
1292        );
1293        let output = run_ps(source.as_bytes());
1294        assert_eq!(output, "hello world");
1295        std::fs::remove_file(&path).ok();
1296    }
1297
1298    /// Done-when #5: `{ 1 0 div } stopped { (caught\n) print } if` → prints "caught"
1299    #[test]
1300    fn test_stopped_catches_error() {
1301        let output = run_ps(b"{ 1 0 div } stopped { (caught\n) print } if");
1302        assert_eq!(output.trim(), "caught");
1303    }
1304
1305    /// Done-when #6: `true setglobal 3 array gcheck` → returns true
1306    #[test]
1307    fn test_setglobal_gcheck() {
1308        let output = run_ps(b"true setglobal 3 array gcheck =");
1309        assert_eq!(output.trim(), "true");
1310    }
1311
1312    /// Done-when #7: `vmstatus` returns three integers
1313    #[test]
1314    fn test_vmstatus() {
1315        let output = run_ps(b"vmstatus = = =");
1316        let lines: Vec<&str> = output.trim().lines().collect();
1317        assert_eq!(lines.len(), 3, "vmstatus should push 3 values");
1318        // All should be parseable as integers
1319        for line in &lines {
1320            assert!(
1321                line.trim().parse::<i32>().is_ok(),
1322                "Expected integer: {}",
1323                line
1324            );
1325        }
1326    }
1327
1328    /// Test that error dispatch populates $error and calls stop
1329    #[test]
1330    fn test_error_dispatch_stop() {
1331        let output = run_ps(b"{ 1 0 div } stopped { (error caught\n) print } if");
1332        assert!(output.contains("error caught"));
1333    }
1334
1335    /// Test nested save/restore
1336    #[test]
1337    fn test_nested_save_restore() {
1338        let output = run_ps(
1339            b"/x 10 def \
1340              save /s1 exch def \
1341              /x 20 def \
1342              save /s2 exch def \
1343              /x 30 def \
1344              x = \
1345              s2 restore \
1346              x = \
1347              s1 restore \
1348              x =",
1349        );
1350        let lines: Vec<&str> = output.trim().lines().collect();
1351        assert_eq!(lines, vec!["30", "20", "10"]);
1352    }
1353
1354    /// Test that global entities are not affected by restore.
1355    /// The array is allocated in global mode and defined before save,
1356    /// so the mutation survives restore.
1357    #[test]
1358    fn test_global_survives_restore() {
1359        let output = run_ps(
1360            b"true setglobal \
1361              3 array /ga exch def \
1362              false setglobal \
1363              save /s exch def \
1364              ga 0 42 put \
1365              s restore \
1366              ga 0 get =",
1367        );
1368        assert_eq!(output.trim(), "42");
1369    }
1370}