Skip to main content

rpic_core/
parser.rs

1//! Recursive-descent parser for the pic drawing core.
2//!
3//! Follows dpic's `grammar.txt`. Implemented: pictures (`.PS … .PE`), primitives
4//! with the full attribute set, positions (pairs, places, corners, ordinals,
5//! `between`, `± shifts`), expressions with proper precedence, `[ … ]` blocks,
6//! `{ … }` groups, labels, assignments, macros, includes, conditionals, loops,
7//! `print` and `exec`.
8
9use crate::ast::*;
10use crate::diagnostic::{Diagnostic, Span};
11use crate::lexer::{LexError, Spanned, lex, lex_named};
12use crate::token::*;
13
14/// A parse error with source location. `file` (via [`ParseError::span`]) is
15/// `None` for the user's own input, or the `copy` include / library name the
16/// position is relative to.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ParseError {
19    pub msg: String,
20    pub line: u32,
21    pub col: u32,
22    pub end_col: u32,
23    file: Option<std::sync::Arc<str>>,
24    detail: Box<ParseErrorDetail>,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28struct ParseErrorDetail {
29    kind: String,
30    found: Option<String>,
31    expected: Option<String>,
32    hint: Option<String>,
33}
34
35impl std::fmt::Display for ParseError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match &self.file {
38            Some(file) => write!(f, "{}:{}:{}: {}", file, self.line, self.col, self.msg),
39            None => write!(f, "{}:{}: {}", self.line, self.col, self.msg),
40        }
41    }
42}
43
44impl ParseError {
45    fn new(msg: impl Into<String>, span: Span) -> Self {
46        Self {
47            msg: msg.into(),
48            line: span.line,
49            col: span.col,
50            end_col: span.end_col,
51            file: span.file,
52            detail: Box::new(ParseErrorDetail {
53                kind: "parse".into(),
54                found: None,
55                expected: None,
56                hint: None,
57            }),
58        }
59    }
60
61    fn expected(expected: impl Into<String>, found: impl Into<String>, span: Span) -> Self {
62        let expected = expected.into();
63        let found = found.into();
64        Self {
65            msg: format!("expected {expected}, found {found}"),
66            line: span.line,
67            col: span.col,
68            end_col: span.end_col,
69            file: span.file,
70            detail: Box::new(ParseErrorDetail {
71                kind: "expected_token".into(),
72                found: Some(found),
73                expected: Some(expected),
74                hint: None,
75            }),
76        }
77    }
78
79    /// Override the diagnostic kind (crate-internal builder).
80    pub(crate) fn with_kind(mut self, kind: impl Into<String>) -> Self {
81        self.detail.kind = kind.into();
82        self
83    }
84
85    /// The error's source span, including which source it refers to.
86    pub fn span(&self) -> Span {
87        Span::new(self.line, self.col, self.end_col).in_file(self.file.clone())
88    }
89
90    pub fn diagnostic(&self) -> Diagnostic {
91        let mut d = Diagnostic::new(self.detail.kind.clone(), self.msg.clone()).at(self.span());
92        d.found = self.detail.found.clone();
93        d.expected = self.detail.expected.clone();
94        d.hint = self.detail.hint.clone();
95        d
96    }
97}
98
99impl From<LexError> for ParseError {
100    fn from(e: LexError) -> Self {
101        ParseError {
102            msg: e.msg,
103            line: e.line,
104            col: e.col,
105            end_col: e.end_col,
106            file: e.file,
107            detail: Box::new(ParseErrorDetail {
108                kind: e.kind,
109                found: None,
110                expected: None,
111                hint: None,
112            }),
113        }
114    }
115}
116
117/// Parse a full source string into a [`Picture`] with no filesystem context.
118/// `copy "file"` includes are unavailable (they require a base directory).
119pub fn parse(src: &str) -> Result<Picture, ParseError> {
120    parse_in_dir(src, None)
121}
122
123/// Parse pic source, resolving `copy "file"` includes relative to `base`
124/// with the default (unrestricted) include policy.
125pub fn parse_in_dir(src: &str, base: Option<&Path>) -> Result<Picture, ParseError> {
126    parse_with_prelude(
127        src,
128        IncludeCtx::unrestricted(base.map(|p| p.to_path_buf())),
129        false,
130        false,
131    )
132}
133
134/// Parse pic source with optional preludes: `circuits` loads the embedded
135/// circuit-element library and `texlabels` injects `texlabels = 1` — the
136/// library equivalents of the CLI `-c` / `-t` flags. Each prelude is lexed
137/// as its own named source unit (not text glued in front of `src`), so every
138/// diagnostic position stays relative to the source it belongs to: the user's
139/// own input reports user lines, and library problems name the library.
140pub fn parse_with_prelude(
141    src: &str,
142    includes: IncludeCtx,
143    circuits: bool,
144    texlabels: bool,
145) -> Result<Picture, ParseError> {
146    let mut toks: Vec<Spanned> = Vec::new();
147    if circuits {
148        splice_unit(&mut toks, lex_named(crate::CIRCUITS, "circuits")?);
149    }
150    if texlabels {
151        // Initializer only — the source stays sovereign (`texlabels = 0` wins).
152        splice_unit(&mut toks, lex_named("texlabels = 1\n", "<texlabels>")?);
153    }
154    let src = strip_backend_preamble(src);
155    toks.extend(lex(&src)?);
156    let (toks, macros) = preprocess(toks, &includes)?;
157    let mut pic = Parser::new(toks).parse_picture()?;
158    pic.macros = macros;
159    pic.includes = includes;
160    Ok(pic)
161}
162
163/// Append a lexed prelude unit ahead of the tokens that follow: drop its
164/// `Eof` and guarantee a trailing `Newline` so the units stay statement-
165/// separated (equivalent to the `\n` the old string-prepending inserted).
166fn splice_unit(out: &mut Vec<Spanned>, mut unit: Vec<Spanned>) {
167    if matches!(unit.last().map(|s| &s.tok), Some(Token::Eof)) {
168        unit.pop();
169    }
170    if !matches!(unit.last().map(|s| &s.tok), Some(Token::Newline)) {
171        let (line, file) = unit
172            .last()
173            .map(|s| (s.line, s.file.clone()))
174            .unwrap_or((1, None));
175        unit.push(Spanned::new(Token::Newline, line, 1).with_file(file));
176    }
177    out.extend(unit);
178}
179
180/// Parse a deferred body (the raw tokens of an `if`/`for` block) with the macro
181/// table in scope, expanding macro calls (and `copy` includes) along this
182/// executed path. Used by the evaluator so dead branches and recursive macros
183/// are never parsed.
184pub fn parse_body_tokens(
185    toks: &[Spanned],
186    macros: &mut Macros,
187    includes: &IncludeCtx,
188) -> Result<Vec<Stmt>, ParseError> {
189    let before = body_macro_frame(toks).unwrap_or_else(|| macros.clone());
190    let mut m = before.clone();
191    let mut input = toks.to_vec();
192    input.push(Spanned::new(Token::Eof, 0, 0));
193    let expanded = expand(&input, &mut m, 0, includes)?;
194    propagate_macro_changes(macros, &before, &m);
195    let mut p = Parser::new(expanded);
196    p.parse_elementlist(&[])
197}
198
199fn body_macro_frame(toks: &[Spanned]) -> Option<Macros> {
200    toks.iter()
201        .find_map(|s| s.macro_frame.as_ref().map(|m| m.as_ref().clone()))
202}
203
204fn propagate_macro_changes(macros: &mut Macros, before: &Macros, after: &Macros) {
205    for name in before.keys() {
206        if !after.contains_key(name) {
207            macros.remove(name);
208        }
209    }
210    for (name, body) in after {
211        if before.get(name) != Some(body) {
212            macros.insert(name.clone(), body.clone());
213        }
214    }
215}
216
217/// Parse pic source produced by `exec`, applying the caller's macro argument
218/// frame before normal macro expansion.
219pub(crate) fn parse_exec_source(
220    src: &str,
221    macros: &mut Macros,
222    includes: &IncludeCtx,
223    arg_frame: Option<&[Vec<Spanned>]>,
224) -> Result<Vec<Stmt>, ParseError> {
225    let mut toks = lex(src)?;
226    if let Some(args) = arg_frame {
227        toks = substitute(&toks, args);
228    }
229    // Expand against the caller's live macro table: a `define` inside the
230    // exec'd text must persist after it, like dpic's — that's how the dpic
231    // suite's `DefineRGBColor` registers colour macros through `case`/`exec`.
232    let expanded = expand(&toks, macros, 0, includes)?;
233    let mut p = Parser::new(expanded);
234    p.parse_elementlist(&[])
235}
236
237pub(crate) fn parse_stringexpr_tokens(
238    toks: &[Spanned],
239    macros: &mut Macros,
240    includes: &IncludeCtx,
241) -> Result<StringExpr, ParseError> {
242    let mut input = toks.to_vec();
243    input.push(Spanned::new(Token::Eof, 0, 0));
244    let expanded = expand(&input, macros, 0, includes)?;
245    let mut p = Parser::new(expanded);
246    p.skip_newlines();
247    let expr = p.parse_stringexpr()?;
248    p.skip_newlines();
249    if !p.at(&Token::Eof) {
250        return p.err(format!("unexpected {:?} after string expression", p.cur()));
251    }
252    Ok(expr)
253}
254
255// ---- backend preamble filter ----------------------------------------------
256
257/// Drop non-SVG backend snippets commonly embedded in dpic examples.
258///
259/// These TeX/PSTricks preambles are meaningful to other backends, but for rpic's
260/// SVG output they should be tolerated as no-ops. Replacing ignored lines with
261/// empty lines keeps subsequent diagnostics on the original line numbers.
262fn strip_backend_preamble(src: &str) -> String {
263    let mut out = String::with_capacity(src.len());
264    let mut in_verbatimtex = false;
265    let mut in_string = false;
266    let mut in_raw_sh = false;
267
268    for line in src.lines() {
269        let trimmed = line.trim_start();
270        if in_verbatimtex {
271            out.push('\n');
272            if starts_word(trimmed, "etex") {
273                in_verbatimtex = false;
274            }
275            continue;
276        }
277
278        if in_raw_sh {
279            out.push_str(line);
280            out.push('\n');
281            in_raw_sh = line_continues(line);
282            continue;
283        }
284
285        if !in_string && starts_word(trimmed, "verbatimtex") {
286            in_verbatimtex = true;
287            out.push('\n');
288        } else if !in_string && is_ignored_backend_line(trimmed) {
289            out.push('\n');
290        } else {
291            out.push_str(line);
292            out.push('\n');
293            if starts_word(trimmed, "sh") {
294                in_raw_sh = line_continues(line);
295            } else {
296                in_string = update_string_state(line, in_string);
297            }
298        }
299    }
300    out
301}
302
303fn line_continues(line: &str) -> bool {
304    line.trim_end_matches([' ', '\t', '\r']).ends_with('\\')
305}
306
307fn update_string_state(line: &str, mut in_string: bool) -> bool {
308    let mut slashes = 0usize;
309    for c in line.chars() {
310        if c == '\\' {
311            slashes += 1;
312            continue;
313        }
314        if c == '"' && slashes.is_multiple_of(2) {
315            in_string = !in_string;
316        }
317        slashes = 0;
318    }
319    in_string
320}
321
322fn is_ignored_backend_line(trimmed: &str) -> bool {
323    trimmed.starts_with("\\global") || trimmed.starts_with("\\psset")
324}
325
326fn starts_word(s: &str, word: &str) -> bool {
327    let Some(rest) = s.strip_prefix(word) else {
328        return false;
329    };
330    !rest
331        .chars()
332        .next()
333        .is_some_and(|c| c.is_alphanumeric() || c == '_')
334}
335
336// ---- macro preprocessor ----------------------------------------------------
337//
338// Handles `define name { body }` (brace-delimited) with `$1..$9` argument
339// substitution at the token level, before parsing. Invocations `name(a, b)` (or
340// bare `name`) are replaced by the body with arguments spliced in; the result is
341// re-expanded so macros may call macros. `undef name` removes a definition.
342
343use std::collections::HashMap;
344use std::path::Path;
345
346fn preprocess(
347    input: Vec<Spanned>,
348    includes: &IncludeCtx,
349) -> Result<(Vec<Spanned>, Macros), ParseError> {
350    let mut macros: Macros = builtin_unit_macros();
351    let out = expand(&input, &mut macros, 0, includes)?;
352    Ok((out, macros))
353}
354
355/// dpic's absolute-unit suffix macros (`11bp__` → `11*(scale/72)`), predefined so
356/// examples that use them without `copy`ing dpictools still work. A user
357/// `define` of the same name overrides these.
358fn builtin_unit_macros() -> Macros {
359    let defs = [
360        ("bp__", "*(scale/72)"),       // Adobe big point
361        ("pt__", "*(scale/72.27)"),    // TeX point
362        ("pc__", "*(12*scale/72.27)"), // pica
363        ("in__", "*scale"),            // inch
364        ("cm__", "*(scale/2.54)"),     // centimetre
365        ("mm__", "*(scale/25.4)"),     // millimetre
366        ("px__", "*(scale/96)"),       // pixel (96 dpi)
367    ];
368    let mut m = Macros::new();
369    for (name, body) in defs {
370        if let Ok(toks) = lex(body) {
371            let body_toks: Vec<Spanned> =
372                toks.into_iter().filter(|s| s.tok != Token::Eof).collect();
373            m.insert(name.to_string(), body_toks);
374        }
375    }
376    m
377}
378
379fn loc(toks: &[Spanned], i: usize) -> Span {
380    toks.get(i)
381        .map(|s| s.span())
382        .unwrap_or_else(|| Span::new(0, 0, 0))
383}
384
385fn expand(
386    toks: &[Spanned],
387    macros: &mut HashMap<String, Vec<Spanned>>,
388    depth: usize,
389    includes: &IncludeCtx,
390) -> Result<Vec<Spanned>, ParseError> {
391    if depth > 64 {
392        return Err(ParseError::new(
393            "macro expansion too deep (recursive define?)",
394            Span::new(0, 0, 0),
395        ));
396    }
397    let mut out = Vec::new();
398    let mut i = 0;
399    while i < toks.len() {
400        match &toks[i].tok {
401            Token::Kw(Kw::Define) => {
402                let span = loc(toks, i);
403                i += 1;
404                let name = match toks.get(i).map(|s| &s.tok) {
405                    Some(Token::Name(n)) | Some(Token::Label(n)) => n.clone(),
406                    _ => {
407                        return Err(ParseError::new(
408                            "define: expected a macro name",
409                            span.clone(),
410                        ));
411                    }
412                };
413                i += 1;
414                // the macro body delimiter may begin on a following line
415                while toks.get(i).map(|s| &s.tok) == Some(&Token::Newline) {
416                    i += 1;
417                }
418                let Some(delim) = toks.get(i).map(|s| s.tok.clone()) else {
419                    return Err(ParseError::new(
420                        "define: expected a body delimiter",
421                        span.clone(),
422                    ));
423                };
424                let body = if delim == Token::LeftBrace {
425                    i += 1; // past `{`
426                    let start = i;
427                    let mut bd = 1;
428                    while i < toks.len() && bd > 0 {
429                        match &toks[i].tok {
430                            Token::LeftBrace => bd += 1,
431                            Token::RightBrace => {
432                                bd -= 1;
433                                if bd == 0 {
434                                    break;
435                                }
436                            }
437                            _ => {}
438                        }
439                        i += 1;
440                    }
441                    if bd != 0 {
442                        return Err(ParseError::new(
443                            "define: unterminated `{` body",
444                            span.clone(),
445                        ));
446                    }
447                    let body = trim_edge_newlines(&toks[start..i]);
448                    i += 1; // past `}`
449                    body
450                } else {
451                    if matches!(delim, Token::Eof | Token::Newline) {
452                        let span = loc(toks, i);
453                        return Err(ParseError::new(
454                            "define: expected a body delimiter",
455                            span.clone(),
456                        ));
457                    }
458                    i += 1; // past delimiter
459                    let start = i;
460                    while i < toks.len() && toks[i].tok != delim {
461                        i += 1;
462                    }
463                    if i >= toks.len() {
464                        return Err(ParseError::new(
465                            "define: unterminated delimited body",
466                            span.clone(),
467                        ));
468                    }
469                    let body = trim_edge_newlines(&toks[start..i]);
470                    i += 1; // past closing delimiter
471                    body
472                };
473                macros.insert(name, body);
474            }
475            Token::Kw(Kw::Undef) => {
476                i += 1;
477                if let Some(Token::Name(n)) | Some(Token::Label(n)) = toks.get(i).map(|s| &s.tok) {
478                    macros.remove(n);
479                }
480                i += 1;
481            }
482            // `if`/`for` bodies are copied verbatim (macro calls inside are not
483            // expanded here): they are expanded lazily, by the evaluator, only
484            // along the branch/iteration that actually runs. The condition/range
485            // is still expanded so macros there work.
486            Token::Kw(Kw::If) => {
487                let if_tok = toks[i].clone();
488                out.push(toks[i].clone());
489                i += 1;
490                let Some(te) = find_kw_depth0(toks, i, Kw::Then) else {
491                    continue; // malformed; let the parser report it
492                };
493                let cond = expand(&toks[i..te], macros, depth + 1, includes)?;
494                if let Some((then_body, after_then)) = read_braced_body(toks, te + 1)? {
495                    let mut j = after_then;
496                    while matches!(toks.get(j).map(|s| &s.tok), Some(Token::Newline)) {
497                        j += 1;
498                    }
499                    let else_body =
500                        if matches!(toks.get(j).map(|s| &s.tok), Some(Token::Kw(Kw::Else))) {
501                            read_braced_body(toks, j + 1)?
502                        } else {
503                            None
504                        };
505                    if let Some(take_then) = static_truth(&cond) {
506                        let after_static = else_body
507                            .as_ref()
508                            .map(|(_, after)| *after)
509                            .unwrap_or(after_then);
510                        out.pop(); // discard the speculative `if`
511                        if take_then {
512                            out.extend(expand(&then_body, macros, depth + 1, includes)?);
513                            i = after_static;
514                        } else if let Some((body, after)) = else_body {
515                            out.extend(expand(&body, macros, depth + 1, includes)?);
516                            i = after;
517                        } else {
518                            i = after_then;
519                        }
520                        continue;
521                    }
522                }
523                *out.last_mut().unwrap() = if_tok;
524                out.extend(cond);
525                out.push(toks[te].clone()); // `then`
526                i = copy_braced(toks, te + 1, &mut out, macros)?;
527                // optional `else { … }` (possibly across newlines)
528                let mut j = i;
529                while matches!(toks.get(j).map(|s| &s.tok), Some(Token::Newline)) {
530                    j += 1;
531                }
532                if matches!(toks.get(j).map(|s| &s.tok), Some(Token::Kw(Kw::Else))) {
533                    out.push(toks[j].clone());
534                    i = copy_braced(toks, j + 1, &mut out, macros)?;
535                }
536            }
537            Token::Kw(Kw::For) => {
538                out.push(toks[i].clone());
539                i += 1;
540                let Some(de) = find_kw_depth0(toks, i, Kw::Do) else {
541                    continue;
542                };
543                out.extend(expand(&toks[i..de], macros, depth + 1, includes)?);
544                out.push(toks[de].clone()); // `do`
545                i = copy_braced(toks, de + 1, &mut out, macros)?;
546            }
547            Token::Name(n) | Token::Label(n) if macros.contains_key(n) => {
548                let body = macros.get(n).unwrap().clone();
549                i += 1;
550                let args = if toks.get(i).map(|s| &s.tok) == Some(&Token::Lparen) {
551                    i += 1;
552                    let (a, ni) = read_args(toks, i)?;
553                    i = ni;
554                    a
555                } else {
556                    Vec::new()
557                };
558                let sub = substitute(&body, &args);
559                let expanded = expand(&sub, macros, depth + 1, includes)?;
560                out.extend(expanded);
561            }
562            // `copy "file"` splices another pic file's (expanded) tokens inline.
563            Token::Kw(Kw::Copy) => {
564                let span = loc(toks, i);
565                i += 1;
566                let Some(Token::Str(fname)) = toks.get(i).map(|s| &s.tok) else {
567                    return Err(ParseError::new(
568                        "copy: expected a quoted file name (only `copy \"file\"` is supported)",
569                        span.clone(),
570                    ));
571                };
572                let fname = fname.clone();
573                i += 1;
574                let inc = include_file(includes, &fname, macros, depth, span)?;
575                out.extend(inc);
576            }
577            _ => {
578                out.push(toks[i].clone());
579                i += 1;
580            }
581        }
582    }
583    Ok(out)
584}
585
586/// Read comma-separated argument token-lists after a `(` (index `i` is just past
587/// it), returning the args and the index after the matching `)`.
588fn read_args(toks: &[Spanned], mut i: usize) -> Result<(Vec<Vec<Spanned>>, usize), ParseError> {
589    let mut args: Vec<Vec<Spanned>> = Vec::new();
590    let mut cur: Vec<Spanned> = Vec::new();
591    let mut depth = 0i32;
592    loop {
593        let Some(s) = toks.get(i) else {
594            return Err(ParseError::new(
595                "unterminated macro arguments",
596                Span::new(0, 0, 0),
597            ));
598        };
599        match &s.tok {
600            Token::Lparen | Token::LeftBrack | Token::LeftBrace => {
601                depth += 1;
602                cur.push(s.clone());
603                i += 1;
604            }
605            Token::Rparen if depth == 0 => {
606                i += 1;
607                trim_trailing_newlines(&mut cur);
608                if !cur.is_empty() || !args.is_empty() {
609                    args.push(cur);
610                }
611                break;
612            }
613            Token::Rparen | Token::RightBrack | Token::RightBrace => {
614                depth -= 1;
615                cur.push(s.clone());
616                i += 1;
617            }
618            Token::Comma if depth == 0 => {
619                trim_trailing_newlines(&mut cur);
620                args.push(std::mem::take(&mut cur));
621                i += 1;
622            }
623            Token::Newline if depth == 0 && cur.is_empty() => {
624                i += 1;
625            }
626            _ => {
627                cur.push(s.clone());
628                i += 1;
629            }
630        }
631    }
632    Ok((args, i))
633}
634
635fn trim_trailing_newlines(toks: &mut Vec<Spanned>) {
636    while matches!(toks.last().map(|s| &s.tok), Some(Token::Newline)) {
637        toks.pop();
638    }
639}
640
641/// Drop leading and trailing `Newline` tokens (used for macro bodies, where the
642/// newlines around a multi-line `{ … }` are formatting, not structure).
643fn trim_edge_newlines(toks: &[Spanned]) -> Vec<Spanned> {
644    let mut start = 0;
645    let mut end = toks.len();
646    while start < end && toks[start].tok == Token::Newline {
647        start += 1;
648    }
649    while end > start && toks[end - 1].tok == Token::Newline {
650        end -= 1;
651    }
652    toks[start..end].to_vec()
653}
654
655/// Replace `$k` argument tokens in a macro body with the k-th argument's tokens.
656fn substitute(body: &[Spanned], args: &[Vec<Spanned>]) -> Vec<Spanned> {
657    // One shared frame per substitution: tagging every token with its own
658    // deep copy of `args` is exponential under recursive macros (#373).
659    let frame = std::sync::Arc::new(args.to_vec());
660    let mut out = Vec::new();
661    let mut i = 0;
662    while i < body.len() {
663        if matches!(body[i].tok, Token::Kw(Kw::Define))
664            && let Some((define, next)) = copy_define_verbatim(body, i)
665        {
666            out.extend(define);
667            i = next;
668            continue;
669        }
670
671        if let Some((pasted, next)) = paste_adjacent_args(body, args, i) {
672            out.push(pasted.with_arg_frame(&frame));
673            i = next;
674            continue;
675        }
676
677        let s = &body[i];
678        match &s.tok {
679            Token::Arg(k) => {
680                if let Some(a) = args.get((*k as usize).wrapping_sub(1)) {
681                    out.extend(a.iter().cloned().map(|s| s.with_arg_frame(&frame)));
682                }
683            }
684            // `$+` is the number of arguments passed to this macro
685            Token::ArgCount => out.push(
686                Spanned::new(Token::Float(args.len() as f64), s.line, s.col).with_arg_frame(&frame),
687            ),
688            // `$n` is also substituted inside string literals (the `"$1"==""`
689            // default-argument idiom, sprintf templates like `"$2%g"`, …).
690            Token::Str(text) if text.contains('$') => {
691                out.push(
692                    Spanned::new(Token::Str(subst_in_string(text, args)), s.line, s.col)
693                        .with_arg_frame(&frame),
694                );
695            }
696            _ => out.push(s.clone().with_arg_frame(&frame)),
697        }
698        i += 1;
699    }
700    out
701}
702
703fn copy_define_verbatim(body: &[Spanned], start: usize) -> Option<(Vec<Spanned>, usize)> {
704    let mut name_idx = start + 1;
705    while matches!(body.get(name_idx).map(|s| &s.tok), Some(Token::Newline)) {
706        name_idx += 1;
707    }
708    if !matches!(
709        body.get(name_idx).map(|s| &s.tok),
710        Some(Token::Name(_)) | Some(Token::Label(_))
711    ) {
712        return None;
713    }
714
715    let mut out = Vec::new();
716    let mut i = start;
717    while let Some(s) = body.get(i) {
718        out.push(s.clone());
719        i += 1;
720        if matches!(s.tok, Token::LeftBrace) {
721            break;
722        }
723    }
724
725    if !matches!(out.last().map(|s| &s.tok), Some(Token::LeftBrace)) {
726        return None;
727    }
728
729    let mut depth = 1i32;
730    while let Some(s) = body.get(i) {
731        match &s.tok {
732            Token::LeftBrace => depth += 1,
733            Token::RightBrace => depth -= 1,
734            _ => {}
735        }
736        out.push(s.clone());
737        i += 1;
738        if depth == 0 {
739            return Some((out, i));
740        }
741    }
742    None
743}
744
745fn paste_adjacent_args(
746    body: &[Spanned],
747    args: &[Vec<Spanned>],
748    start: usize,
749) -> Option<(Spanned, usize)> {
750    let first = body.get(start)?;
751    let Token::Arg(k) = &first.tok else {
752        return None;
753    };
754
755    let mut text = arg_text(*k, args);
756    let mut count = 1usize;
757    let mut end = start + 1;
758    let mut prev = first;
759    while let Some(next) = body.get(end) {
760        let Token::Arg(k) = &next.tok else {
761            break;
762        };
763        if !adjacent_arg_tokens(prev, next) {
764            break;
765        }
766        text.push_str(&arg_text(*k, args));
767        count += 1;
768        prev = next;
769        end += 1;
770    }
771
772    if count < 2 || text.is_empty() {
773        return None;
774    }
775
776    Some((tokenize_pasted_arg_text(&text, first.line, first.col), end))
777}
778
779fn arg_text(k: u32, args: &[Vec<Spanned>]) -> String {
780    args.get((k as usize).wrapping_sub(1))
781        .map(|a| tokens_to_text(a))
782        .unwrap_or_default()
783}
784
785fn adjacent_arg_tokens(left: &Spanned, right: &Spanned) -> bool {
786    left.line == right.line && arg_end_col(left) == Some(right.col)
787}
788
789fn arg_end_col(s: &Spanned) -> Option<u32> {
790    let Token::Arg(k) = &s.tok else {
791        return None;
792    };
793    Some(s.col + 1 + k.to_string().len() as u32)
794}
795
796fn tokenize_pasted_arg_text(text: &str, line: u32, col: u32) -> Spanned {
797    if let Ok(toks) = lex(text)
798        && toks.len() == 2
799        && matches!(toks[1].tok, Token::Eof)
800    {
801        return Spanned::new(toks[0].tok.clone(), line, col);
802    }
803
804    let tok = if text.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
805        Token::Label(text.to_string())
806    } else {
807        Token::Name(text.to_string())
808    };
809    Spanned::new(tok, line, col)
810}
811
812/// Replace `$n` references inside a string literal with the textual form of the
813/// n-th macro argument (empty if missing).
814fn subst_in_string(text: &str, args: &[Vec<Spanned>]) -> String {
815    let chars: Vec<char> = text.chars().collect();
816    let mut out = String::new();
817    let mut i = 0;
818    while i < chars.len() {
819        if chars[i] == '$' && chars.get(i + 1) == Some(&'+') {
820            out.push_str(&args.len().to_string());
821            i += 2;
822        } else if chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
823            let mut j = i + 1;
824            let mut num = String::new();
825            while j < chars.len() && chars[j].is_ascii_digit() {
826                num.push(chars[j]);
827                j += 1;
828            }
829            if let Ok(k) = num.parse::<usize>()
830                && k >= 1
831                && let Some(a) = args.get(k - 1)
832            {
833                out.push_str(&tokens_to_text(a));
834            }
835            i = j;
836        } else {
837            out.push(chars[i]);
838            i += 1;
839        }
840    }
841    out
842}
843
844/// Best-effort textual rendering of an argument token list, for `$n` splicing
845/// inside string literals. dpic treats macro arguments as raw source text, so
846/// the splice must reproduce it: a single string keeps splicing as bare
847/// content (the `box "$1"` label idiom), but a multi-token argument — exec'd
848/// statements like `box shaded "#00ff00"` — keeps its inner quotes (bare `#…`
849/// would start a comment) and the source's own spacing, where it used to glue
850/// everything into `boxshaded#00ff00` (#280). Spacing comes from the token
851/// spans: a gap in the source becomes a space, and source-adjacent tokens
852/// (`2L` = `2`+`L`) stay glued — re-gluing what the lexer split apart is safe
853/// by construction. The word-boundary heuristic only decides for tokens of
854/// mixed provenance (nested expansions), where spans aren't comparable.
855fn tokens_to_text(toks: &[Spanned]) -> String {
856    let material: Vec<&Spanned> = toks
857        .iter()
858        .filter(|s| !matches!(s.tok, Token::Eof))
859        .collect();
860    if let [one] = material.as_slice()
861        && let Token::Str(t) = &one.tok
862    {
863        // a lone string argument splices as its content, quote-free — the
864        // classic quote-at-use-site idiom (`box "$1"` with a quoted label)
865        return t.clone();
866    }
867    let mut s = String::new();
868    let mut prev: Option<(u32, u32)> = None; // previous token's source line, end_col
869    for t in material {
870        let piece = arg_token_text(&t.tok);
871        if piece.is_empty() {
872            continue;
873        }
874        if let Some((pline, pend)) = prev {
875            // Spacing comes from the SOURCE spans, not the rendered lengths:
876            // a token that renders shorter/longer than its source (a
877            // normalized float `1.50`→`1.5`, an escaped string) must not skew
878            // the gap test (#282 follow-up).
879            let sep = if t.line != pline {
880                true
881            } else if t.col >= pend {
882                t.col > pend
883            } else {
884                // spans not comparable (mixed provenance): separate only
885                // where gluing could merge word-like tokens
886                matches!((s.chars().last(), piece.chars().next()),
887                    (Some(a), Some(b)) if wordish(a) && wordish(b))
888            };
889            if sep {
890                s.push(' ');
891            }
892        }
893        s.push_str(&piece);
894        prev = Some((t.line, t.end_col));
895    }
896    s
897}
898
899/// Would these two characters merge into (or corrupt) a token if adjacent?
900fn wordish(c: char) -> bool {
901    c.is_ascii_alphanumeric() || matches!(c, '_' | '"' | '$')
902}
903
904/// The source text of one token, for argument splicing.
905fn arg_token_text(tok: &Token) -> String {
906    match tok {
907        Token::Float(v) => format!("{v}"),
908        // inner quotes escaped so the splice survives an exec round-trip
909        // (`unescape_exec_source` folds them back before re-lexing)
910        Token::Str(t) => format!("\"{}\"", t.replace('"', "\\\"")),
911        Token::Name(n) | Token::Label(n) => n.clone(),
912        Token::Arg(k) => format!("${k}"),
913        Token::ArgCount => "$+".into(),
914        Token::Dollar => "$".into(),
915        Token::Backslash => "\\".into(),
916        Token::Newline => ";".into(),
917        Token::DotPS => ".PS".into(),
918        Token::DotPE => ".PE".into(),
919        Token::Eof => String::new(),
920        Token::Lt => "<".into(),
921        Token::Gt => ">".into(),
922        Token::Le => "<=".into(),
923        Token::Ge => ">=".into(),
924        Token::EqEq => "==".into(),
925        Token::Neq => "!=".into(),
926        Token::Eq => "=".into(),
927        Token::ColonEq => ":=".into(),
928        Token::PlusEq => "+=".into(),
929        Token::MinusEq => "-=".into(),
930        Token::MultEq => "*=".into(),
931        Token::DivEq => "/=".into(),
932        Token::RemEq => "%=".into(),
933        Token::Not => "!".into(),
934        Token::AndAnd => "&&".into(),
935        Token::OrOr => "||".into(),
936        Token::Ampersand => "&".into(),
937        Token::Caret => "^".into(),
938        Token::Lparen => "(".into(),
939        Token::Rparen => ")".into(),
940        Token::LeftBrack => "[".into(),
941        Token::RightBrack => "]".into(),
942        Token::LeftBrace => "{".into(),
943        Token::RightBrace => "}".into(),
944        Token::Block => "[]".into(),
945        Token::LeftQuote => "`".into(),
946        Token::RightQuote => "'".into(),
947        Token::Comma => ",".into(),
948        Token::Colon => ":".into(),
949        Token::Dot => ".".into(),
950        Token::Plus => "+".into(),
951        Token::Minus => "-".into(),
952        Token::Mult => "*".into(),
953        Token::Div => "/".into(),
954        Token::Percent => "%".into(),
955        Token::DotX => ".x".into(),
956        Token::DotY => ".y".into(),
957        Token::Corner(c) => corner_text(*c).into(),
958        Token::Param(p) => param_text(*p).into(),
959        Token::LineType(l) => line_type_text(*l).into(),
960        Token::TextPos(p) => text_pos_text(*p).into(),
961        Token::Arrow(a) => arrow_text(*a).into(),
962        Token::Dir(d) => dir_text(*d).into(),
963        Token::Prim(p) => prim_text(*p).into(),
964        Token::Color(c) => color_text(*c).into(),
965        Token::Kw(k) => kw_text(*k).into(),
966        Token::Func1(f) => format!("{f:?}").to_ascii_lowercase(),
967        Token::Func2(f) => format!("{f:?}").to_ascii_lowercase(),
968        Token::EnvVar(e) => format!("{e:?}").to_ascii_lowercase(),
969    }
970}
971
972fn corner_text(c: Corner) -> &'static str {
973    match c {
974        Corner::N => ".n",
975        Corner::S => ".s",
976        Corner::E => ".e",
977        Corner::W => ".w",
978        Corner::Ne => ".ne",
979        Corner::Se => ".se",
980        Corner::Nw => ".nw",
981        Corner::Sw => ".sw",
982        Corner::Start => ".start",
983        Corner::End => ".end",
984        Corner::Center => ".c",
985    }
986}
987
988fn param_text(p: Param) -> &'static str {
989    match p {
990        Param::Height => ".ht",
991        Param::Width => ".wid",
992        Param::Radius => ".rad",
993        Param::Diameter => ".diam",
994        Param::Thickness => ".thick",
995        Param::Length => ".len",
996    }
997}
998
999fn line_type_text(l: LineType) -> &'static str {
1000    match l {
1001        LineType::Solid => "solid",
1002        LineType::Dotted => "dotted",
1003        LineType::Dashed => "dashed",
1004        LineType::Invis => "invis",
1005    }
1006}
1007
1008fn text_pos_text(p: TextPos) -> &'static str {
1009    match p {
1010        TextPos::Center => "center",
1011        TextPos::Ljust => "ljust",
1012        TextPos::Rjust => "rjust",
1013        TextPos::Above => "above",
1014        TextPos::Below => "below",
1015    }
1016}
1017
1018fn arrow_text(a: Arrow) -> &'static str {
1019    match a {
1020        Arrow::Left => "<-",
1021        Arrow::Right => "->",
1022        Arrow::Double => "<->",
1023    }
1024}
1025
1026fn dir_text(d: Dir) -> &'static str {
1027    match d {
1028        Dir::Up => "up",
1029        Dir::Down => "down",
1030        Dir::Right => "right",
1031        Dir::Left => "left",
1032    }
1033}
1034
1035fn prim_text(p: Prim) -> &'static str {
1036    match p {
1037        Prim::Box => "box",
1038        Prim::Circle => "circle",
1039        Prim::Ellipse => "ellipse",
1040        Prim::Arc => "arc",
1041        Prim::Line => "line",
1042        Prim::Arrow => "arrow",
1043        Prim::Move => "move",
1044        Prim::Spline => "spline",
1045    }
1046}
1047
1048fn color_text(c: Color) -> &'static str {
1049    match c {
1050        Color::Colored => "color",
1051        Color::Outlined => "outlined",
1052        Color::Shaded => "shaded",
1053    }
1054}
1055
1056fn kw_text(k: Kw) -> &'static str {
1057    match k {
1058        Kw::Ht => "ht",
1059        Kw::Wid => "wid",
1060        Kw::Rad => "rad",
1061        Kw::Diam => "diam",
1062        Kw::Thick => "thick",
1063        Kw::Thin => "thin",
1064        Kw::Scaled => "scaled",
1065        Kw::From => "from",
1066        Kw::To => "to",
1067        Kw::At => "at",
1068        Kw::With => "with",
1069        Kw::By => "by",
1070        Kw::Then => "then",
1071        Kw::Continue => "continue",
1072        Kw::Chop => "chop",
1073        Kw::Same => "same",
1074        Kw::Cw => "cw",
1075        Kw::Ccw => "ccw",
1076        Kw::Of => "of",
1077        Kw::The => "the",
1078        Kw::Way => "way",
1079        Kw::Between => "between",
1080        Kw::And => "and",
1081        Kw::Here => "Here",
1082        Kw::Last => "last",
1083        Kw::Fill => "fill",
1084        Kw::Nth => "ordinal suffix",
1085        Kw::Print => "print",
1086        Kw::Copy => "copy",
1087        Kw::Reset => "reset",
1088        Kw::Exec => "exec",
1089        Kw::Sh => "sh",
1090        Kw::Command => "command",
1091        Kw::Define => "define",
1092        Kw::Undef => "undef",
1093        Kw::Rand => "rand",
1094        Kw::If => "if",
1095        Kw::Else => "else",
1096        Kw::For => "for",
1097        Kw::Do => "do",
1098        Kw::Sprintf => "sprintf",
1099        Kw::Animate => "animate",
1100        Kw::After => "after",
1101        Kw::Delay => "delay",
1102        Kw::Repeat => "repeat",
1103        Kw::Yoyo => "yoyo",
1104        Kw::Ease => "ease",
1105        Kw::Along => "along",
1106        Kw::Stagger => "stagger",
1107        Kw::Out => "out",
1108        Kw::Scroll => "scroll",
1109        Kw::Into => "into",
1110    }
1111}
1112
1113fn token_text(t: &Token) -> String {
1114    match t {
1115        Token::Float(v) => fmt_float(*v),
1116        Token::Str(s) => format!("\"{s}\""),
1117        Token::Name(s) | Token::Label(s) => format!("`{s}`"),
1118        Token::Arg(n) => format!("${n}"),
1119        Token::ArgCount => "$+".into(),
1120        Token::Dollar => "$".into(),
1121        Token::Backslash => "\\".into(),
1122        Token::Newline => "end of line".into(),
1123        Token::DotPS => ".PS".into(),
1124        Token::DotPE => ".PE".into(),
1125        Token::Eof => "end of input".into(),
1126        Token::Lt => "<".into(),
1127        Token::Lparen => "(".into(),
1128        Token::Rparen => ")".into(),
1129        Token::Mult => "*".into(),
1130        Token::Plus => "+".into(),
1131        Token::Minus => "-".into(),
1132        Token::Div => "/".into(),
1133        Token::Percent => "%".into(),
1134        Token::Caret => "^".into(),
1135        Token::Not => "!".into(),
1136        Token::AndAnd => "&&".into(),
1137        Token::OrOr => "||".into(),
1138        Token::Ampersand => "&".into(),
1139        Token::Comma => ",".into(),
1140        Token::Colon => ":".into(),
1141        Token::LeftBrack => "[".into(),
1142        Token::RightBrack => "]".into(),
1143        Token::LeftBrace => "{".into(),
1144        Token::RightBrace => "}".into(),
1145        Token::Dot => ".".into(),
1146        Token::Block => "[]".into(),
1147        Token::LeftQuote => "`".into(),
1148        Token::RightQuote => "'".into(),
1149        Token::Eq => "=".into(),
1150        Token::ColonEq => ":=".into(),
1151        Token::PlusEq => "+=".into(),
1152        Token::MinusEq => "-=".into(),
1153        Token::MultEq => "*=".into(),
1154        Token::DivEq => "/=".into(),
1155        Token::RemEq => "%=".into(),
1156        Token::EqEq => "==".into(),
1157        Token::Neq => "!=".into(),
1158        Token::Ge => ">=".into(),
1159        Token::Le => "<=".into(),
1160        Token::Gt => ">".into(),
1161        Token::DotX => ".x".into(),
1162        Token::DotY => ".y".into(),
1163        Token::Kw(k) => kw_text(*k).into(),
1164        Token::Corner(c) => corner_text(*c).into(),
1165        Token::Param(p) => format!(".{}", param_text(*p)),
1166        Token::Func1(f) => format!("{f:?}").to_ascii_lowercase(),
1167        Token::Func2(f) => format!("{f:?}").to_ascii_lowercase(),
1168        Token::LineType(l) => line_type_text(*l).into(),
1169        Token::TextPos(p) => text_pos_text(*p).into(),
1170        Token::Arrow(a) => arrow_text(*a).into(),
1171        Token::Dir(d) => dir_text(*d).into(),
1172        Token::Prim(p) => prim_text(*p).into(),
1173        Token::Color(c) => color_text(*c).into(),
1174        Token::EnvVar(e) => format!("{e:?}").to_ascii_lowercase(),
1175    }
1176}
1177
1178fn fmt_float(v: f64) -> String {
1179    let mut s = v.to_string();
1180    if s.ends_with(".0") {
1181        s.truncate(s.len() - 2);
1182    }
1183    s
1184}
1185
1186fn suggest_object(word: &str) -> Option<&'static str> {
1187    const WORDS: &[&str] = &[
1188        "arc", "arrow", "box", "brace", "circle", "dot", "ellipse", "line", "move", "spline",
1189    ];
1190    crate::diagnostic::closest(word, WORDS)
1191}
1192
1193/// Read and tokenize a `copy "file"` include, returning its expanded tokens
1194/// (with the trailing `Eof` removed so it splices cleanly mid-stream). The
1195/// included file resolves nested `copy`s relative to its own directory.
1196fn include_file(
1197    includes: &IncludeCtx,
1198    fname: &str,
1199    macros: &mut HashMap<String, Vec<Spanned>>,
1200    depth: usize,
1201    span: Span,
1202) -> Result<Vec<Spanned>, ParseError> {
1203    let mkerr = |msg: String| ParseError::new(msg, span.clone());
1204    let denied = |why: &str| {
1205        ParseError::new(format!("copy \"{fname}\": {why}"), span.clone())
1206            .with_kind("include_denied")
1207    };
1208    // `copy "circuits"` is a reserved target: it loads the embedded native
1209    // circuit-element library — the in-source spelling of `-c`, usable even
1210    // where file includes are not (wasm, compile_json with no base dir). It
1211    // shadows any real file literally named `circuits`. Skipped when the
1212    // library is already loaded (`-c` plus an explicit copy): `__resistor`
1213    // is one of its own defines.
1214    if fname == "circuits" {
1215        if macros.contains_key("__resistor") {
1216            return Ok(Vec::new());
1217        }
1218        let toks = lex_named(crate::CIRCUITS, "circuits")?;
1219        let mut expanded = expand(&toks, macros, depth + 1, includes)?;
1220        if matches!(expanded.last().map(|s| &s.tok), Some(Token::Eof)) {
1221            expanded.pop();
1222        }
1223        return Ok(expanded);
1224    }
1225    if includes.policy == IncludePolicy::Deny {
1226        return Err(denied(
1227            "filesystem includes are disabled by the include policy",
1228        ));
1229    }
1230    let p = Path::new(fname);
1231    if p.is_absolute() && includes.policy == IncludePolicy::SandboxedToBase {
1232        return Err(denied(
1233            "absolute paths are not allowed by the include policy",
1234        ));
1235    }
1236    let path = if p.is_absolute() {
1237        p.to_path_buf()
1238    } else {
1239        match includes.dir.as_deref() {
1240            Some(b) => b.join(p),
1241            None => {
1242                return Err(mkerr(format!(
1243                    "copy \"{fname}\": file includes require a file path (unavailable here)"
1244                )));
1245            }
1246        }
1247    };
1248    if includes.policy == IncludePolicy::SandboxedToBase {
1249        // Canonicalize (resolving `..` and symlinks) and require the result
1250        // to stay inside the fence root. A fence that failed to resolve at
1251        // setup fails closed. The error names the path as written, never the
1252        // resolved location.
1253        let inside = includes
1254            .fence
1255            .as_deref()
1256            .is_some_and(|fence| std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(fence)));
1257        if !inside {
1258            return Err(denied("path resolves outside the include base directory"));
1259        }
1260    }
1261    let content =
1262        std::fs::read_to_string(&path).map_err(|e| mkerr(format!("copy \"{fname}\": {e}")))?;
1263    let toks = lex_named(&content, fname)?;
1264    let inc_base = path.parent().map(|d| d.to_path_buf());
1265    let inc_ctx = includes.child(inc_base);
1266    let mut expanded = expand(&toks, macros, depth + 1, &inc_ctx)?;
1267    if matches!(expanded.last().map(|s| &s.tok), Some(Token::Eof)) {
1268        expanded.pop();
1269    }
1270    Ok(expanded)
1271}
1272
1273/// Find the next occurrence of keyword `kw` at bracket-depth 0 from `start`.
1274fn find_kw_depth0(toks: &[Spanned], start: usize, kw: Kw) -> Option<usize> {
1275    let mut depth = 0i32;
1276    for (off, s) in toks[start..].iter().enumerate() {
1277        match &s.tok {
1278            Token::Lparen | Token::LeftBrace | Token::LeftBrack => depth += 1,
1279            Token::Rparen | Token::RightBrace | Token::RightBrack => {
1280                depth -= 1;
1281                if depth < 0 {
1282                    return None;
1283                }
1284            }
1285            Token::Kw(k) if *k == kw && depth == 0 => return Some(start + off),
1286            _ => {}
1287        }
1288    }
1289    None
1290}
1291
1292/// Copy a brace-delimited block verbatim into `out` (including any nested
1293/// braces), skipping/copying leading newlines. Returns the index past the `}`.
1294fn copy_braced(
1295    toks: &[Spanned],
1296    mut i: usize,
1297    out: &mut Vec<Spanned>,
1298    macros: &HashMap<String, Vec<Spanned>>,
1299) -> Result<usize, ParseError> {
1300    while matches!(toks.get(i).map(|s| &s.tok), Some(Token::Newline)) {
1301        out.push(toks[i].clone());
1302        i += 1;
1303    }
1304    if !matches!(toks.get(i).map(|s| &s.tok), Some(Token::LeftBrace)) {
1305        return Ok(i); // no body; the parser will report the problem
1306    }
1307    let mut depth = 0i32;
1308    let mut tagged_body = false;
1309    while let Some(s) = toks.get(i) {
1310        let mut s = s.clone();
1311        let outer_left_brace = depth == 0 && matches!(s.tok, Token::LeftBrace);
1312        match &s.tok {
1313            Token::LeftBrace => depth += 1,
1314            Token::RightBrace => {
1315                out.push(s);
1316                i += 1;
1317                depth -= 1;
1318                if depth == 0 {
1319                    return Ok(i);
1320                }
1321                continue;
1322            }
1323            _ => {}
1324        }
1325        if depth == 1 && !outer_left_brace && !tagged_body {
1326            s = s.with_macro_frame(macros);
1327            tagged_body = true;
1328        }
1329        out.push(s);
1330        i += 1;
1331    }
1332    Err(ParseError::new("unterminated `{` body", Span::new(0, 0, 0)))
1333}
1334
1335fn read_braced_body(
1336    toks: &[Spanned],
1337    mut i: usize,
1338) -> Result<Option<(Vec<Spanned>, usize)>, ParseError> {
1339    while matches!(toks.get(i).map(|s| &s.tok), Some(Token::Newline)) {
1340        i += 1;
1341    }
1342    if !matches!(toks.get(i).map(|s| &s.tok), Some(Token::LeftBrace)) {
1343        return Ok(None);
1344    }
1345    i += 1;
1346    let start = i;
1347    let mut depth = 1i32;
1348    while let Some(s) = toks.get(i) {
1349        match &s.tok {
1350            Token::LeftBrace => depth += 1,
1351            Token::RightBrace => {
1352                depth -= 1;
1353                if depth == 0 {
1354                    return Ok(Some((toks[start..i].to_vec(), i + 1)));
1355                }
1356            }
1357            _ => {}
1358        }
1359        i += 1;
1360    }
1361    Err(ParseError::new("unterminated `{` body", Span::new(0, 0, 0)))
1362}
1363
1364fn static_truth(toks: &[Spanned]) -> Option<bool> {
1365    let toks = trim_trailing_eof(toks);
1366    match toks {
1367        [
1368            Spanned {
1369                tok: Token::Float(v),
1370                ..
1371            },
1372        ] => Some(*v != 0.0),
1373        [
1374            Spanned {
1375                tok: Token::Not, ..
1376            },
1377            Spanned {
1378                tok: Token::Float(v),
1379                ..
1380            },
1381        ] => Some(*v == 0.0),
1382        [a, op, b] => match op.tok {
1383            Token::EqEq | Token::Neq => {
1384                if let (Some(lhs), Some(rhs)) = (static_string(a), static_string(b)) {
1385                    return Some(if matches!(op.tok, Token::EqEq) {
1386                        lhs == rhs
1387                    } else {
1388                        lhs != rhs
1389                    });
1390                }
1391                if let (Some(lhs), Some(rhs)) = (static_number(a), static_number(b)) {
1392                    return Some(if matches!(op.tok, Token::EqEq) {
1393                        (lhs - rhs).abs() < f64::EPSILON
1394                    } else {
1395                        (lhs - rhs).abs() >= f64::EPSILON
1396                    });
1397                }
1398                None
1399            }
1400            _ => None,
1401        },
1402        [a, op, b, op2, c] if matches!(op2.tok, Token::Plus) => {
1403            let lhs = static_string(a)?;
1404            let mut rhs = static_string(b)?;
1405            rhs.push_str(&static_string(c)?);
1406            match op.tok {
1407                Token::EqEq => Some(lhs == rhs),
1408                Token::Neq => Some(lhs != rhs),
1409                _ => None,
1410            }
1411        }
1412        _ => None,
1413    }
1414}
1415
1416fn trim_trailing_eof(toks: &[Spanned]) -> &[Spanned] {
1417    if matches!(toks.last().map(|s| &s.tok), Some(Token::Eof)) {
1418        &toks[..toks.len() - 1]
1419    } else {
1420        toks
1421    }
1422}
1423
1424fn static_string(s: &Spanned) -> Option<String> {
1425    match &s.tok {
1426        Token::Str(v) => Some(v.clone()),
1427        _ => None,
1428    }
1429}
1430
1431fn static_number(s: &Spanned) -> Option<f64> {
1432    match &s.tok {
1433        Token::Float(v) => Some(*v),
1434        Token::Name(n) | Token::Label(n) => dpic_backend_constant(n),
1435        _ => None,
1436    }
1437}
1438
1439fn dpic_backend_constant(name: &str) -> Option<f64> {
1440    // ONE-based, oracle-checked against dpic (`dpic -v` prints optMFpic=1 …
1441    // optSVG=9 … optxfig=12); keep in sync with `install_dpic_compat_vars`.
1442    match name {
1443        "optMFpic" => Some(1.0),
1444        "optMpost" => Some(2.0),
1445        "optPDF" => Some(3.0),
1446        "optPGF" => Some(4.0),
1447        "optPict2e" => Some(5.0),
1448        "optPS" => Some(6.0),
1449        "optPSfrag" => Some(7.0),
1450        "optPSTricks" => Some(8.0),
1451        "optSVG" | "dpicopt" => Some(9.0),
1452        "optTeX" => Some(10.0),
1453        "opttTeX" => Some(11.0),
1454        "optxfig" => Some(12.0),
1455        _ => None,
1456    }
1457}
1458
1459type PResult<T> = Result<T, ParseError>;
1460
1461fn is_assign_op(t: &Token) -> bool {
1462    matches!(
1463        t,
1464        Token::Eq
1465            | Token::ColonEq
1466            | Token::PlusEq
1467            | Token::MinusEq
1468            | Token::MultEq
1469            | Token::DivEq
1470            | Token::RemEq
1471    )
1472}
1473
1474struct Parser {
1475    toks: Vec<Spanned>,
1476    idx: usize,
1477    depth: u32,
1478}
1479
1480/// Recursive-descent nesting limit. The macro expander already caps its own
1481/// depth (`expand`, `depth > 64`), but parentheses and `[…]` blocks pass
1482/// through it untouched and only recurse in the descent parser — so without
1483/// this guard, pathological input like `((((…))))` overflows the stack and
1484/// aborts the process instead of returning a normal error (#283). Chosen for a
1485/// comfortable margin on wasm's ~1 MB stack (each level costs ~1.7 KB of native
1486/// stack across the precedence-climbing chain, more on wasm) while dwarfing any
1487/// real figure — the entire committed corpus nests at most 4 deep.
1488const MAX_PARSE_DEPTH: u32 = 128;
1489/// Flat operator chains build left-deep ASTs without going through
1490/// `descend`, so cap them explicitly as well (#306).
1491const MAX_EXPR_CHAIN_OPS: u32 = MAX_PARSE_DEPTH;
1492
1493impl Parser {
1494    fn new(toks: Vec<Spanned>) -> Self {
1495        Parser {
1496            toks,
1497            idx: 0,
1498            depth: 0,
1499        }
1500    }
1501
1502    /// Run `f` one recursion level deeper, failing cleanly past
1503    /// `MAX_PARSE_DEPTH` instead of overflowing the stack. Wrap the mutually
1504    /// recursive entry points (expressions, positions, objects/blocks).
1505    fn descend<T>(&mut self, f: impl FnOnce(&mut Self) -> PResult<T>) -> PResult<T> {
1506        self.depth += 1;
1507        if self.depth > MAX_PARSE_DEPTH {
1508            self.depth -= 1;
1509            return self.err("expression or block nested too deeply".to_string());
1510        }
1511        let r = f(self);
1512        self.depth -= 1;
1513        r
1514    }
1515
1516    fn check_expr_chain(&self, ops: u32) -> PResult<()> {
1517        if ops > MAX_EXPR_CHAIN_OPS {
1518            return self.err(format!(
1519                "expression has too many chained operators (maximum {MAX_EXPR_CHAIN_OPS})"
1520            ));
1521        }
1522        Ok(())
1523    }
1524
1525    // ---- cursor helpers ----------------------------------------------------
1526
1527    fn cur(&self) -> &Token {
1528        &self.toks[self.idx].tok
1529    }
1530    fn cur_span(&self) -> Span {
1531        self.toks[self.idx].span()
1532    }
1533    fn peek(&self, n: usize) -> &Token {
1534        self.toks
1535            .get(self.idx + n)
1536            .map(|s| &s.tok)
1537            .unwrap_or(&Token::Eof)
1538    }
1539    fn at(&self, t: &Token) -> bool {
1540        self.cur() == t
1541    }
1542    fn bump(&mut self) -> Token {
1543        let t = self.toks[self.idx].tok.clone();
1544        if self.idx + 1 < self.toks.len() {
1545            self.idx += 1;
1546        }
1547        t
1548    }
1549    fn eat(&mut self, t: &Token) -> bool {
1550        if self.at(t) {
1551            self.bump();
1552            true
1553        } else {
1554            false
1555        }
1556    }
1557    fn expect(&mut self, t: &Token) -> PResult<()> {
1558        if self.eat(t) {
1559            Ok(())
1560        } else {
1561            self.expected_here(token_text(t))
1562        }
1563    }
1564    fn err<T>(&self, msg: impl Into<String>) -> PResult<T> {
1565        let s = &self.toks[self.idx];
1566        Err(ParseError::new(msg, s.span()))
1567    }
1568    fn expected_here<T>(&self, expected: impl Into<String>) -> PResult<T> {
1569        let s = &self.toks[self.idx];
1570        Err(ParseError::expected(expected, token_text(&s.tok), s.span()))
1571    }
1572    fn expected_object<T>(&self) -> PResult<T> {
1573        let s = &self.toks[self.idx];
1574        let mut e = ParseError::expected("an object", token_text(&s.tok), s.span());
1575        if let Token::Name(name) | Token::Label(name) = &s.tok
1576            && let Some(hint) = suggest_object(name)
1577        {
1578            e.detail.hint = Some(format!("did you mean `{hint}`?"));
1579        }
1580        Err(e)
1581    }
1582    fn at_kw(&self, k: Kw) -> bool {
1583        matches!(self.cur(), Token::Kw(x) if *x == k)
1584    }
1585    fn eat_kw(&mut self, k: Kw) -> bool {
1586        if self.at_kw(k) {
1587            self.bump();
1588            true
1589        } else {
1590            false
1591        }
1592    }
1593    fn skip_newlines(&mut self) {
1594        while self.at(&Token::Newline) {
1595            self.bump();
1596        }
1597    }
1598
1599    // ---- top level ---------------------------------------------------------
1600
1601    fn parse_picture(&mut self) -> PResult<Picture> {
1602        // `.PS`/`.PE` are treated as markers that may appear anywhere; statements
1603        // (including `animate`) are collected across them up to EOF. The first
1604        // `.PS` may carry optional width/height.
1605        let (mut width, mut height) = (None, None);
1606        let mut seen_ps = false;
1607        let mut stmts = Vec::new();
1608        loop {
1609            self.skip_newlines();
1610            match self.cur() {
1611                Token::Eof => break,
1612                Token::DotPS => {
1613                    self.bump();
1614                    if !seen_ps && self.starts_scalar() {
1615                        width = Some(self.parse_expr()?);
1616                        if self.starts_scalar() {
1617                            height = Some(self.parse_expr()?);
1618                        }
1619                    }
1620                    seen_ps = true;
1621                    while !self.at(&Token::Newline) && !self.at(&Token::Eof) {
1622                        self.bump();
1623                    }
1624                    continue;
1625                }
1626                Token::DotPE => {
1627                    self.bump();
1628                    continue;
1629                }
1630                _ => {}
1631            }
1632            stmts.push(self.parse_element()?);
1633            if !self.at(&Token::Newline)
1634                && !self.at(&Token::Eof)
1635                && !self.at(&Token::DotPE)
1636                && !self.at(&Token::DotPS)
1637            {
1638                return self.err(format!("unexpected {:?} after statement", self.cur()));
1639            }
1640        }
1641        Ok(Picture {
1642            width,
1643            height,
1644            stmts,
1645            macros: HashMap::new(),
1646            includes: IncludeCtx::default(),
1647        })
1648    }
1649
1650    /// Parse elements until one of `terminators` (or EOF) is the current token.
1651    fn parse_elementlist(&mut self, terminators: &[Token]) -> PResult<Vec<Stmt>> {
1652        let mut stmts = Vec::new();
1653        loop {
1654            self.skip_newlines();
1655            if self.at(&Token::Eof) || terminators.iter().any(|t| self.at(t)) {
1656                break;
1657            }
1658            let s = self.parse_element()?;
1659            stmts.push(s);
1660            // a statement must end at a newline, a terminator, or EOF
1661            if !self.at(&Token::Newline)
1662                && !self.at(&Token::Eof)
1663                && !terminators.iter().any(|t| self.at(t))
1664            {
1665                return self.err(format!("unexpected {:?} after statement", self.cur()));
1666            }
1667        }
1668        Ok(stmts)
1669    }
1670
1671    // ---- statements --------------------------------------------------------
1672
1673    fn parse_element(&mut self) -> PResult<Stmt> {
1674        // A `%`-led line is a comment convention in some source documents (pic
1675        // proper uses `#`). `%` is never valid at statement start, so skip the
1676        // line as a no-op.
1677        if self.at(&Token::Percent) {
1678            while !self.at(&Token::Newline) && !self.at(&Token::Eof) {
1679                self.bump();
1680            }
1681            return Ok(Stmt::Print(PrintItem::Str(StringExpr::Lit(String::new()))));
1682        }
1683
1684        // rpic animation directive.
1685        if self.at_kw(Kw::Animate) {
1686            // `animate scroll` is a timeline-level directive, not an object
1687            // animation — dispatch it before the `animate <place> …` form.
1688            if matches!(self.peek(1), Token::Kw(Kw::Scroll)) {
1689                self.bump();
1690                self.bump();
1691                return Ok(Stmt::AnimateScroll);
1692            }
1693            return Ok(Stmt::Animate(Box::new(self.parse_animate()?)));
1694        }
1695
1696        // rpic `class <place> "name"` statement (extension). Contextual:
1697        // `class = 2` stays an assignment and `class` remains usable as a
1698        // variable, mirroring how `animate` targets are referenced.
1699        if matches!(self.cur(), Token::Name(n) if n == "class")
1700            && !is_assign_op(self.peek(1))
1701            && !matches!(self.peek(1), Token::LeftBrack)
1702        {
1703            self.bump();
1704            let target = self.parse_place()?;
1705            let class = self.parse_stringexpr()?;
1706            return Ok(Stmt::Class { target, class });
1707        }
1708
1709        // rpic `link <place> "<url>"` statement (extension). Contextual like
1710        // `class`: `link = 2` stays an assignment and `link` remains usable
1711        // as a variable.
1712        if matches!(self.cur(), Token::Name(n) if n == "link")
1713            && !is_assign_op(self.peek(1))
1714            && !matches!(self.peek(1), Token::LeftBrack)
1715        {
1716            self.bump();
1717            let target = self.parse_place()?;
1718            let url = self.parse_stringexpr()?;
1719            return Ok(Stmt::Link { target, url });
1720        }
1721
1722        // rpic `draggable <place> [inertia] [bounds <place>] [x|y]` statement
1723        // (extension). Contextual like `class`: `draggable = 1` stays an
1724        // assignment and `draggable` remains usable as a variable.
1725        if matches!(self.cur(), Token::Name(n) if n == "draggable")
1726            && !is_assign_op(self.peek(1))
1727            && !matches!(self.peek(1), Token::LeftBrack)
1728        {
1729            return Ok(Stmt::Draggable(self.parse_draggable()?));
1730        }
1731
1732        // rpic `canvas from <pos> to <pos>` statement (extension). Contextual
1733        // and stricter than `class`: only the exact `canvas from …` spelling
1734        // triggers, so `canvas = 2`, a `canvas(…)` macro and a plain variable
1735        // named canvas all keep their classic meaning.
1736        if matches!(self.cur(), Token::Name(n) if n == "canvas")
1737            && matches!(self.peek(1), Token::Kw(Kw::From))
1738        {
1739            self.bump();
1740            self.bump();
1741            let from = self.parse_position()?;
1742            self.expect(&Token::Kw(Kw::To))?;
1743            let to = self.parse_position()?;
1744            return Ok(Stmt::Canvas { from, to });
1745        }
1746
1747        // control constructs
1748        match self.cur() {
1749            Token::Kw(Kw::If) => return self.parse_if(),
1750            Token::Kw(Kw::For) => return self.parse_for(),
1751            Token::Kw(Kw::Print) => return self.parse_print(),
1752            Token::Kw(Kw::Exec) => return self.parse_exec(),
1753            Token::Kw(Kw::Reset) => return self.parse_reset(),
1754            _ => {}
1755        }
1756
1757        // `define`/`undef` are handled by the macro preprocessor before parsing;
1758        // reaching here means a non-brace form we don't support.
1759        if let Token::Kw(k) = self.cur() {
1760            match k {
1761                Kw::Define | Kw::Undef => {
1762                    return self.err("only the `define name { body }` macro form is supported");
1763                }
1764                // Policy (docs/raw-backend-policy in dpic-compat-audit.md):
1765                // `command` raw backend text is never injected into the SVG
1766                // output, and `sh` is never executed — both are tolerated as
1767                // true no-ops so dpic sources keep compiling. The lexer already
1768                // skipped their raw argument text.
1769                Kw::Command | Kw::Sh => {
1770                    self.bump();
1771                    return Ok(Stmt::Group(Vec::new()));
1772                }
1773                Kw::Copy => {
1774                    return self.err("`copy` is not supported yet (planned milestone)");
1775                }
1776                _ => {}
1777            }
1778        }
1779
1780        // `{ … }` grouping. Nesting recurses parse_element → parse_elementlist
1781        // → parse_element, so bound it through `descend` like the other block
1782        // forms (a deeply nested `{{{…}}}` would otherwise overflow the stack).
1783        if self.eat(&Token::LeftBrace) {
1784            let stmts = self.descend(|p| p.parse_elementlist(&[Token::RightBrace]))?;
1785            self.expect(&Token::RightBrace)?;
1786            return Ok(Stmt::Group(stmts));
1787        }
1788
1789        // Labelled element: `Label [suffix] : (object | position)`
1790        if matches!(self.cur(), Token::Label(_)) && self.label_colon_ahead() {
1791            let label = self.parse_label()?;
1792            self.expect(&Token::Colon)?;
1793            if self.at_object_start() {
1794                let object = self.parse_object()?;
1795                return Ok(Stmt::Object {
1796                    label: Some(label),
1797                    object,
1798                });
1799            } else {
1800                let pos = self.parse_label_position()?;
1801                return Ok(Stmt::Place { label, pos });
1802            }
1803        }
1804
1805        // Assignment: `name [suffix] op …` or `envvar op …`
1806        if self.at_assignment_start() {
1807            return Ok(Stmt::Assign(self.parse_assignlist()?));
1808        }
1809
1810        // Bare direction change.
1811        if let Token::Dir(d) = self.cur() {
1812            let d = *d;
1813            // Only a standalone direction (next token ends the statement).
1814            if matches!(self.peek(1), Token::Newline | Token::Eof) {
1815                self.bump();
1816                return Ok(Stmt::Direction(d));
1817            }
1818        }
1819
1820        // Otherwise: an unlabelled object.
1821        let object = self.parse_object()?;
1822        Ok(Stmt::Object {
1823            label: None,
1824            object,
1825        })
1826    }
1827
1828    fn parse_if(&mut self) -> PResult<Stmt> {
1829        self.expect_kw(Kw::If)?;
1830        let cond = self.parse_expr()?;
1831        self.expect_kw(Kw::Then)?;
1832        let then_body = self.capture_braced()?;
1833        // optional `else { … }`, possibly across newlines (which otherwise end
1834        // the statement)
1835        let save = self.idx;
1836        self.skip_newlines();
1837        let else_body = if self.eat_kw(Kw::Else) {
1838            Some(self.capture_braced()?)
1839        } else {
1840            self.idx = save;
1841            None
1842        };
1843        Ok(Stmt::If {
1844            cond,
1845            then_body,
1846            else_body,
1847        })
1848    }
1849
1850    fn parse_for(&mut self) -> PResult<Stmt> {
1851        self.expect_kw(Kw::For)?;
1852        let var = match self.bump() {
1853            Token::Name(s) | Token::Label(s) => s,
1854            other => return self.err(format!("expected loop variable, found {other:?}")),
1855        };
1856        let subscript = if self.eat(&Token::LeftBrack) {
1857            let e = self.parse_subscript()?;
1858            self.expect(&Token::RightBrack)?;
1859            Some(e)
1860        } else {
1861            None
1862        };
1863        match self.bump() {
1864            Token::Eq | Token::ColonEq => {}
1865            other => return self.err(format!("expected `=` in for, found {other:?}")),
1866        }
1867        let from = self.parse_expr()?;
1868        self.expect_kw(Kw::To)?;
1869        let to = self.parse_expr()?;
1870        let mut by = Expr::Num(1.0);
1871        let mut mult = false;
1872        if self.eat_kw(Kw::By) {
1873            mult = self.eat(&Token::Mult);
1874            by = self.parse_expr()?;
1875        }
1876        self.expect_kw(Kw::Do)?;
1877        let body = self.capture_braced()?;
1878        Ok(Stmt::For {
1879            var,
1880            subscript,
1881            from,
1882            to,
1883            by,
1884            mult,
1885            body,
1886        })
1887    }
1888
1889    /// Capture a brace-delimited block as raw tokens (excluding the braces),
1890    /// for deferred parsing by the evaluator. Assumes the body follows.
1891    fn capture_braced(&mut self) -> PResult<Body> {
1892        self.skip_newlines();
1893        self.expect(&Token::LeftBrace)?;
1894        let start = self.idx;
1895        let mut depth = 1i32;
1896        loop {
1897            match self.cur() {
1898                Token::LeftBrace => depth += 1,
1899                Token::RightBrace => {
1900                    depth -= 1;
1901                    if depth == 0 {
1902                        break;
1903                    }
1904                }
1905                Token::Eof => return self.err("unterminated `{` body"),
1906                _ => {}
1907            }
1908            self.bump();
1909        }
1910        let body = self.toks[start..self.idx].to_vec();
1911        self.expect(&Token::RightBrace)?;
1912        Ok(body)
1913    }
1914
1915    fn parse_print(&mut self) -> PResult<Stmt> {
1916        self.expect_kw(Kw::Print)?;
1917        let item = if self.at_string_start() {
1918            PrintItem::Str(self.parse_stringexpr()?)
1919        } else {
1920            PrintItem::Expr(self.parse_expr()?)
1921        };
1922        Ok(Stmt::Print(item))
1923    }
1924
1925    fn parse_exec(&mut self) -> PResult<Stmt> {
1926        let arg_frame = self.toks[self.idx].arg_frame.clone();
1927        self.expect_kw(Kw::Exec)?;
1928        let command = self.parse_stringexpr()?;
1929        Ok(Stmt::Exec { command, arg_frame })
1930    }
1931
1932    fn parse_reset(&mut self) -> PResult<Stmt> {
1933        self.expect_kw(Kw::Reset)?;
1934        let mut list = Vec::new();
1935        if let Token::EnvVar(v) = self.cur() {
1936            list.push(*v);
1937            self.bump();
1938            while self.eat(&Token::Comma) {
1939                match self.cur() {
1940                    Token::EnvVar(v) => {
1941                        list.push(*v);
1942                        self.bump();
1943                    }
1944                    other => {
1945                        return self.err(format!("expected environment variable, found {other:?}"));
1946                    }
1947                }
1948            }
1949        }
1950        Ok(Stmt::Reset(list))
1951    }
1952
1953    fn at_string_start(&self) -> bool {
1954        self.token_starts_string_at(0)
1955    }
1956
1957    fn parse_animate(&mut self) -> PResult<Animate> {
1958        self.expect_kw(Kw::Animate)?;
1959        let target = self.parse_place()?;
1960        self.expect_kw(Kw::With)?;
1961        let effect_span = Some(self.cur_span());
1962        let effect = self.parse_stringexpr()?;
1963        // `to`/`from` are overloaded: `to <colour>` for `highlight`, `from <dir>`
1964        // for `slide`, but a *reveal fraction* for `draw` (`draw from 40% to 60%`).
1965        // A literal `"draw"` effect switches those two clauses over; a dynamic
1966        // effect string keeps the colour/direction reading.
1967        let is_draw = matches!(&effect, StringExpr::Lit(s) if s == "draw");
1968        let mut duration = None;
1969        let mut timing = Timing::Sequential;
1970        let mut delay = None;
1971        let mut repeat = None;
1972        let mut yoyo = false;
1973        let mut ease = None;
1974        let mut along = None;
1975        let mut color = None;
1976        let mut stagger = None;
1977        let mut out = false;
1978        let mut slide_from = None;
1979        let mut morph_into = None;
1980        let mut type_unit = None;
1981        let mut scramble_chars = None;
1982        let mut wiggles = None;
1983        let mut draw_from = None;
1984        let mut draw_to = None;
1985        loop {
1986            if self.eat_kw(Kw::For) {
1987                duration = Some(self.parse_expr()?);
1988            } else if self.eat_kw(Kw::At) {
1989                timing = Timing::At(self.parse_expr()?);
1990            } else if self.eat_kw(Kw::After) {
1991                timing = Timing::After(self.parse_place()?);
1992            } else if self.eat_kw(Kw::Delay) {
1993                delay = Some(self.parse_expr()?);
1994            } else if self.eat_kw(Kw::Repeat) {
1995                repeat = Some(self.parse_expr()?);
1996            } else if self.eat_kw(Kw::Yoyo) {
1997                yoyo = true;
1998            } else if self.eat_kw(Kw::Ease) {
1999                ease = Some(self.parse_stringexpr()?);
2000            } else if self.eat_kw(Kw::Along) {
2001                along = Some(self.parse_place()?);
2002            } else if self.eat_kw(Kw::To) {
2003                if is_draw {
2004                    draw_to = Some(self.parse_draw_amount()?);
2005                } else {
2006                    color = Some(self.parse_color_like()?);
2007                }
2008            } else if self.eat_kw(Kw::Stagger) {
2009                stagger = Some(self.parse_expr()?);
2010            } else if self.eat_kw(Kw::Out) {
2011                out = true;
2012            } else if self.eat_kw(Kw::From) {
2013                if is_draw {
2014                    draw_from = Some(self.parse_draw_amount()?);
2015                } else {
2016                    slide_from = Some(self.parse_dir()?);
2017                }
2018            } else if self.eat_kw(Kw::Into) {
2019                morph_into = Some(self.parse_place()?);
2020            } else if self.eat_kw(Kw::By) {
2021                // `by "…"` is the scramble charset; `by word`/`by char` is the
2022                // type unit — a quoted string vs. a bareword disambiguates.
2023                if self.at_string_start() {
2024                    scramble_chars = Some(self.parse_stringexpr()?);
2025                } else {
2026                    type_unit = Some(self.parse_type_unit()?);
2027                }
2028            } else if matches!(self.cur(), Token::Name(n) if n == "wiggles") {
2029                // contextual keyword (like `class`/`animate`): stays a usable
2030                // variable name outside an animate clause
2031                self.bump();
2032                wiggles = Some(self.parse_expr()?);
2033            } else {
2034                break;
2035            }
2036        }
2037        Ok(Animate {
2038            target,
2039            effect,
2040            effect_span,
2041            duration,
2042            timing,
2043            delay,
2044            repeat,
2045            yoyo,
2046            ease,
2047            along,
2048            color,
2049            stagger,
2050            out,
2051            slide_from,
2052            morph_into,
2053            type_unit,
2054            scramble_chars,
2055            wiggles,
2056            draw_from,
2057            draw_to,
2058        })
2059    }
2060
2061    /// A `draw` reveal fraction: a multiplicative term (no bare `%`, which is
2062    /// reserved as the percent suffix here) with an optional trailing `%` that
2063    /// divides by 100 — so `60%` and `0.6` both mean 0.6 of the stroke.
2064    fn parse_draw_amount(&mut self) -> PResult<Expr> {
2065        let mut e = self.parse_unary()?;
2066        loop {
2067            let op = match self.cur() {
2068                Token::Mult => BinOp::Mul,
2069                Token::Div => BinOp::Div,
2070                _ => break,
2071            };
2072            self.bump();
2073            let r = self.parse_unary()?;
2074            e = Expr::Bin(op, Box::new(e), Box::new(r));
2075        }
2076        if matches!(self.cur(), Token::Percent) {
2077            self.bump();
2078            e = Expr::Bin(BinOp::Div, Box::new(e), Box::new(Expr::Num(100.0)));
2079        }
2080        Ok(e)
2081    }
2082
2083    /// Consume the `type` effect's split unit after `by`: `word` or `char`.
2084    fn parse_type_unit(&mut self) -> PResult<TypeUnit> {
2085        match self.cur() {
2086            Token::Name(n) if n == "word" => {
2087                self.bump();
2088                Ok(TypeUnit::Word)
2089            }
2090            Token::Name(n) if n == "char" => {
2091                self.bump();
2092                Ok(TypeUnit::Char)
2093            }
2094            _ => self.err("expected `word` or `char` after `by`"),
2095        }
2096    }
2097
2098    /// `draggable <place> [inertia] [bounds <place>] [x|y]`.
2099    fn parse_draggable(&mut self) -> PResult<Draggable> {
2100        self.bump(); // `draggable`
2101        let target = self.parse_place()?;
2102        let mut inertia = false;
2103        let mut bounds = None;
2104        let mut axis = None;
2105        loop {
2106            match self.cur() {
2107                Token::Name(n) if n == "inertia" => {
2108                    self.bump();
2109                    inertia = true;
2110                }
2111                Token::Name(n) if n == "bounds" => {
2112                    self.bump();
2113                    bounds = Some(self.parse_place()?);
2114                }
2115                Token::Name(n) if n == "x" => {
2116                    self.bump();
2117                    axis = Some(DragAxis::X);
2118                }
2119                Token::Name(n) if n == "y" => {
2120                    self.bump();
2121                    axis = Some(DragAxis::Y);
2122                }
2123                _ => break,
2124            }
2125        }
2126        Ok(Draggable {
2127            target,
2128            inertia,
2129            bounds,
2130            axis,
2131        })
2132    }
2133
2134    /// Consume a bare compass direction token (`up`/`down`/`left`/`right`).
2135    fn parse_dir(&mut self) -> PResult<Dir> {
2136        if let Token::Dir(d) = self.cur() {
2137            let d = *d;
2138            self.bump();
2139            Ok(d)
2140        } else {
2141            self.expected_here("a direction (up, down, left, or right)")
2142        }
2143    }
2144
2145    /// True if the current `Label` is followed by `:` (allowing a `[suffix]`).
2146    fn label_colon_ahead(&self) -> bool {
2147        match self.peek(1) {
2148            Token::Colon => true,
2149            Token::LeftBrack => {
2150                // scan past a balanced [ … ] suffix to find ':'
2151                let mut depth = 0;
2152                let mut i = self.idx + 1;
2153                while i < self.toks.len() {
2154                    match &self.toks[i].tok {
2155                        Token::LeftBrack => depth += 1,
2156                        Token::RightBrack => {
2157                            depth -= 1;
2158                            if depth == 0 {
2159                                return matches!(
2160                                    self.toks.get(i + 1).map(|s| &s.tok),
2161                                    Some(Token::Colon)
2162                                );
2163                            }
2164                        }
2165                        Token::Newline | Token::Eof => return false,
2166                        _ => {}
2167                    }
2168                    i += 1;
2169                }
2170                false
2171            }
2172            _ => false,
2173        }
2174    }
2175
2176    fn at_object_start(&self) -> bool {
2177        matches!(
2178            self.cur(),
2179            Token::Prim(_)
2180                | Token::LeftBrack
2181                | Token::Block
2182                | Token::Str(_)
2183                | Token::Arg(_)
2184                | Token::Kw(Kw::Sprintf)
2185        ) || matches!(self.cur(), Token::Name(n) if n == "brace" || n == "dot")
2186    }
2187
2188    fn at_assignment_start(&self) -> bool {
2189        match self.cur() {
2190            Token::Name(_) | Token::Label(_) => self.assignment_op_after_var_ref(),
2191            Token::EnvVar(_) => is_assign_op(self.peek(1)),
2192            _ => false,
2193        }
2194    }
2195
2196    fn assignment_op_after_var_ref(&self) -> bool {
2197        let mut i = self.idx + 1;
2198        if matches!(self.toks.get(i).map(|s| &s.tok), Some(Token::LeftBrack)) {
2199            i += 1;
2200            let mut depth = 1i32;
2201            while let Some(tok) = self.toks.get(i).map(|s| &s.tok) {
2202                match tok {
2203                    Token::LeftBrack => depth += 1,
2204                    Token::RightBrack => {
2205                        depth -= 1;
2206                        if depth == 0 {
2207                            i += 1;
2208                            break;
2209                        }
2210                    }
2211                    Token::Eof | Token::Newline if depth > 0 => return false,
2212                    _ => {}
2213                }
2214                i += 1;
2215            }
2216            if depth != 0 {
2217                return false;
2218            }
2219        }
2220        self.toks.get(i).is_some_and(|s| is_assign_op(&s.tok))
2221    }
2222
2223    fn parse_label(&mut self) -> PResult<Label> {
2224        let name = match self.bump() {
2225            Token::Label(s) => s,
2226            other => return self.err(format!("expected label, found {other:?}")),
2227        };
2228        let subscript = if self.eat(&Token::LeftBrack) {
2229            let e = self.parse_subscript()?;
2230            self.expect(&Token::RightBrack)?;
2231            Some(e)
2232        } else {
2233            None
2234        };
2235        Ok(Label { name, subscript })
2236    }
2237
2238    fn parse_assignlist(&mut self) -> PResult<Vec<Assignment>> {
2239        let mut list = vec![self.parse_assignment()?];
2240        while self.eat(&Token::Comma) {
2241            list.push(self.parse_assignment()?);
2242        }
2243        Ok(list)
2244    }
2245
2246    fn parse_assignment(&mut self) -> PResult<Assignment> {
2247        let target = match self.cur().clone() {
2248            Token::Name(name) | Token::Label(name) => {
2249                self.bump();
2250                let sub = if self.eat(&Token::LeftBrack) {
2251                    let e = self.parse_subscript()?;
2252                    self.expect(&Token::RightBrack)?;
2253                    Some(e)
2254                } else {
2255                    None
2256                };
2257                AssignTarget::Var(name, sub)
2258            }
2259            Token::EnvVar(v) => {
2260                self.bump();
2261                AssignTarget::Env(v)
2262            }
2263            other => return self.err(format!("expected assignment target, found {other:?}")),
2264        };
2265        let op = match self.bump() {
2266            Token::Eq => AssignOp::Set,
2267            Token::ColonEq => AssignOp::ColonSet,
2268            Token::PlusEq => AssignOp::Add,
2269            Token::MinusEq => AssignOp::Sub,
2270            Token::MultEq => AssignOp::Mul,
2271            Token::DivEq => AssignOp::Div,
2272            Token::RemEq => AssignOp::Rem,
2273            other => return self.err(format!("expected assignment operator, found {other:?}")),
2274        };
2275        let value = self.parse_expr()?;
2276        Ok(Assignment { target, op, value })
2277    }
2278
2279    fn parse_subscript(&mut self) -> PResult<Expr> {
2280        let mut items = vec![self.parse_expr()?];
2281        while self.eat(&Token::Comma) {
2282            items.push(self.parse_expr()?);
2283        }
2284        if items.len() == 1 {
2285            Ok(items.pop().unwrap())
2286        } else {
2287            Ok(Expr::Index(items))
2288        }
2289    }
2290
2291    // ---- objects & attributes ---------------------------------------------
2292
2293    fn parse_object(&mut self) -> PResult<Object> {
2294        self.descend(Self::parse_object_inner)
2295    }
2296
2297    fn parse_object_inner(&mut self) -> PResult<Object> {
2298        let span = Some(self.cur_span());
2299        let mut attrs = Vec::new();
2300        // a bare string expression (literal, `$arg`, sprintf, concatenation)
2301        // places a text-only object.
2302        if self.at_string_start() {
2303            attrs.push(Attr::Text(self.parse_stringexpr()?));
2304            while let Some(a) = self.parse_attr(false, false, false, false)? {
2305                attrs.push(a);
2306            }
2307            return Ok(Object {
2308                span,
2309                kind: ObjectKind::Text,
2310                attrs,
2311            });
2312        }
2313        let kind = match self.cur().clone() {
2314            Token::Prim(p) => {
2315                self.bump();
2316                ObjectKind::Primitive(p)
2317            }
2318            Token::Block => {
2319                self.bump();
2320                ObjectKind::Empty
2321            }
2322            Token::Name(n) if n == "brace" => {
2323                self.bump();
2324                ObjectKind::Brace
2325            }
2326            Token::Name(n) if n == "dot" => {
2327                self.bump();
2328                ObjectKind::Dot
2329            }
2330            Token::LeftBrack => {
2331                self.bump();
2332                let stmts = self.parse_elementlist(&[Token::RightBrack])?;
2333                self.expect(&Token::RightBrack)?;
2334                ObjectKind::Block(stmts)
2335            }
2336            Token::Str(s) => {
2337                self.bump();
2338                attrs.push(Attr::Text(self.continue_string(StringExpr::Lit(s))?));
2339                ObjectKind::Text
2340            }
2341            Token::Kw(Kw::Continue) => {
2342                self.bump();
2343                ObjectKind::Continue
2344            }
2345            Token::Name(_) | Token::Label(_) => return self.expected_object(),
2346            _ => return self.expected_here("an object"),
2347        };
2348        // `spline <expr> <linespec>`: dpic's documented exception to the bare
2349        // distance rule — the expression right after `spline` is a tension
2350        // parameter, not a length. Parse it here so the attribute loop below
2351        // doesn't read it as `Attr::Dist`.
2352        if matches!(kind, ObjectKind::Primitive(Prim::Spline)) && self.spline_tension_ahead() {
2353            attrs.push(Attr::SplineTension(self.parse_expr()?));
2354        }
2355        let allow_fit = matches!(
2356            kind,
2357            ObjectKind::Primitive(Prim::Box | Prim::Circle | Prim::Ellipse)
2358        );
2359        let allow_brace = matches!(kind, ObjectKind::Brace);
2360        let allow_dot_fill = matches!(kind, ObjectKind::Dot);
2361        let allow_hatch = matches!(
2362            kind,
2363            ObjectKind::Primitive(
2364                Prim::Box
2365                    | Prim::Circle
2366                    | Prim::Ellipse
2367                    | Prim::Line
2368                    | Prim::Arrow
2369                    | Prim::Spline
2370                    | Prim::Arc
2371            )
2372        );
2373        let allow_close = matches!(kind, ObjectKind::Primitive(Prim::Line));
2374        while let Some(a) = self.parse_attr(
2375            allow_fit,
2376            allow_brace,
2377            allow_hatch || allow_dot_fill,
2378            allow_close,
2379        )? {
2380            attrs.push(a);
2381        }
2382        Ok(Object { kind, attrs, span })
2383    }
2384
2385    /// True if the next token begins a bare scalar expression — the leading
2386    /// tension argument of `spline <expr>` — rather than a linespec keyword
2387    /// (`from`/`to`/`up`/`then`/…) or another attribute.
2388    fn spline_tension_ahead(&self) -> bool {
2389        matches!(
2390            self.cur(),
2391            Token::Float(_)
2392                | Token::Lparen
2393                | Token::EnvVar(_)
2394                | Token::Func1(_)
2395                | Token::Func2(_)
2396                | Token::Name(_)
2397                | Token::Minus
2398                | Token::Plus
2399                | Token::Kw(Kw::Rand)
2400        )
2401    }
2402
2403    fn parse_attr(
2404        &mut self,
2405        allow_fit: bool,
2406        allow_brace: bool,
2407        allow_hatch: bool,
2408        allow_close: bool,
2409    ) -> PResult<Option<Attr>> {
2410        // any string expression (literal, sprintf, $arg, concatenation) is text
2411        if self.at_string_start() {
2412            return Ok(Some(Attr::Text(self.parse_stringexpr()?)));
2413        }
2414        let attr = match self.cur().clone() {
2415            Token::Kw(Kw::Ht) => {
2416                self.bump();
2417                Attr::Dim(DimKind::Ht, self.parse_expr()?)
2418            }
2419            Token::Kw(Kw::Wid) => {
2420                self.bump();
2421                Attr::Dim(DimKind::Wid, self.parse_expr()?)
2422            }
2423            Token::Kw(Kw::Rad) => {
2424                self.bump();
2425                Attr::Dim(DimKind::Rad, self.parse_expr()?)
2426            }
2427            Token::Kw(Kw::Diam) => {
2428                self.bump();
2429                Attr::Dim(DimKind::Diam, self.parse_expr()?)
2430            }
2431            Token::Kw(Kw::Thick) => {
2432                self.bump();
2433                Attr::Dim(DimKind::Thick, self.parse_expr()?)
2434            }
2435            Token::Kw(Kw::Thin) => {
2436                self.bump();
2437                Attr::Thin
2438            }
2439            Token::Kw(Kw::Scaled) => {
2440                self.bump();
2441                Attr::Dim(DimKind::Scaled, self.parse_expr()?)
2442            }
2443            Token::Dir(d) => {
2444                self.bump();
2445                Attr::Direction(
2446                    d,
2447                    self.opt_attr_expr(allow_fit, allow_brace, allow_hatch, allow_close)?,
2448                )
2449            }
2450            Token::LineType(lt) => {
2451                self.bump();
2452                Attr::LineStyle(
2453                    lt,
2454                    self.opt_attr_expr(allow_fit, allow_brace, allow_hatch, allow_close)?,
2455                )
2456            }
2457            Token::Kw(Kw::Chop) => {
2458                self.bump();
2459                Attr::Chop(self.opt_attr_expr(allow_fit, allow_brace, allow_hatch, allow_close)?)
2460            }
2461            Token::Kw(Kw::Fill) => {
2462                self.bump();
2463                Attr::Fill(self.opt_attr_expr(allow_fit, allow_brace, allow_hatch, allow_close)?)
2464            }
2465            Token::Arrow(a) => {
2466                self.bump();
2467                Attr::Arrowhead(
2468                    a,
2469                    self.opt_attr_expr(allow_fit, allow_brace, allow_hatch, allow_close)?,
2470                )
2471            }
2472            Token::Kw(Kw::Then) => {
2473                self.bump();
2474                Attr::Then
2475            }
2476            Token::Kw(Kw::Cw) => {
2477                self.bump();
2478                Attr::Cw
2479            }
2480            Token::Kw(Kw::Ccw) => {
2481                self.bump();
2482                Attr::Ccw
2483            }
2484            Token::Kw(Kw::Same) => {
2485                self.bump();
2486                Attr::Same
2487            }
2488            Token::Kw(Kw::Continue) => {
2489                self.bump();
2490                Attr::Continue
2491            }
2492            Token::Kw(Kw::From) => {
2493                self.bump();
2494                Attr::From(self.parse_position()?)
2495            }
2496            Token::Kw(Kw::To) => {
2497                self.bump();
2498                Attr::To(self.parse_position()?)
2499            }
2500            Token::Kw(Kw::At) => {
2501                self.bump();
2502                Attr::At(self.parse_position()?)
2503            }
2504            Token::Kw(Kw::By) => {
2505                self.bump();
2506                Attr::By(self.parse_position()?)
2507            }
2508            Token::Kw(Kw::With) => {
2509                self.bump();
2510                let anchor = if self.eat(&Token::Dot) {
2511                    WithAnchor::Place(self.parse_place()?)
2512                } else if let Token::Corner(c) = self.cur() {
2513                    let c = *c;
2514                    self.bump();
2515                    WithAnchor::Corner(c)
2516                } else if self.at(&Token::Lparen) {
2517                    self.bump();
2518                    let x = self.parse_expr()?;
2519                    self.expect(&Token::Comma)?;
2520                    let y = self.parse_expr()?;
2521                    self.expect(&Token::Rparen)?;
2522                    WithAnchor::Pair(x, y)
2523                } else {
2524                    WithAnchor::Plain
2525                };
2526                self.expect_kw(Kw::At)?;
2527                Attr::With {
2528                    anchor,
2529                    at: self.parse_position()?,
2530                }
2531            }
2532            Token::TextPos(tp) => {
2533                self.bump();
2534                Attr::TextPos(tp)
2535            }
2536            Token::Color(c) => {
2537                self.bump();
2538                // a colour may be a quoted string, a bareword name (`shaded
2539                // Custom`, `outlined red`), `rgb(r,g,b)` or a 0x hex literal.
2540                let span = Some(self.cur_span());
2541                Attr::Color(c, self.parse_color_like()?, span)
2542            }
2543            Token::Name(n) if allow_fit && n == "fit" => {
2544                self.bump();
2545                Attr::Fit
2546            }
2547            Token::Name(n) if allow_hatch && n == "hatch" => {
2548                self.bump();
2549                Attr::Hatch(HatchKind::Single)
2550            }
2551            Token::Name(n) if allow_hatch && n == "crosshatch" => {
2552                self.bump();
2553                Attr::Hatch(HatchKind::Cross)
2554            }
2555            Token::Name(n) if allow_hatch && n == "hatchangle" => {
2556                self.bump();
2557                Attr::HatchAngle(self.parse_expr()?)
2558            }
2559            Token::Name(n) if allow_hatch && n == "hatchsep" => {
2560                self.bump();
2561                Attr::HatchSep(self.parse_expr()?)
2562            }
2563            Token::Name(n) if allow_hatch && (n == "hatchwid" || n == "hatchwidth") => {
2564                self.bump();
2565                Attr::HatchWidth(self.parse_expr()?)
2566            }
2567            Token::Name(n) if allow_hatch && n == "hatchcolor" => {
2568                self.bump();
2569                let span = Some(self.cur_span());
2570                Attr::HatchColor(self.parse_color_like()?, span)
2571            }
2572            Token::Name(n) if allow_hatch && n == "gradient" => {
2573                self.bump();
2574                let from_span = Some(self.cur_span());
2575                let from = self.parse_color_like()?;
2576                let to_span = Some(self.cur_span());
2577                let to = self.parse_color_like()?;
2578                Attr::Gradient(from, from_span, to, to_span)
2579            }
2580            Token::Name(n) if allow_hatch && n == "gradientangle" => {
2581                self.bump();
2582                Attr::GradientAngle(self.parse_expr()?)
2583            }
2584            Token::Name(n) if n == "opacity" => {
2585                self.bump();
2586                Attr::Opacity(self.parse_expr()?)
2587            }
2588            Token::Name(n) if allow_close && n == "close" => {
2589                self.bump();
2590                Attr::Close
2591            }
2592            Token::Name(n) if allow_brace && n == "bracepos" => {
2593                self.bump();
2594                Attr::BracePos(self.parse_expr()?)
2595            }
2596            Token::Name(n) if allow_brace && n == "labeloffset" => {
2597                self.bump();
2598                Attr::BraceLabelOffset(self.parse_expr()?)
2599            }
2600            Token::Name(n) if n == "bold" => {
2601                self.bump();
2602                Attr::Bold
2603            }
2604            Token::Name(n) if n == "italic" => {
2605                self.bump();
2606                Attr::Italic
2607            }
2608            Token::Name(n) if n == "mono" => {
2609                self.bump();
2610                Attr::Mono
2611            }
2612            Token::Name(n) if n == "font" => {
2613                self.bump();
2614                // a family may be a quoted string or a bareword name, like
2615                // colours (`font "IBM Plex Mono"`, `font serif`)
2616                let s = match self.cur().clone() {
2617                    Token::Name(n) | Token::Label(n) => {
2618                        self.bump();
2619                        StringExpr::Lit(n)
2620                    }
2621                    _ => self.parse_stringexpr()?,
2622                };
2623                Attr::Font(s)
2624            }
2625            Token::Name(n) if n == "fontsize" => {
2626                self.bump();
2627                Attr::FontSize(self.parse_expr()?)
2628            }
2629            Token::Name(n) if n == "rotated" => {
2630                self.bump();
2631                Attr::Rotated(self.parse_expr()?)
2632            }
2633            Token::Name(n) if n == "aligned" => {
2634                self.bump();
2635                Attr::Aligned
2636            }
2637            Token::Name(n) if n == "big" => {
2638                self.bump();
2639                Attr::Sized(true)
2640            }
2641            Token::Name(n) if n == "small" => {
2642                self.bump();
2643                Attr::Sized(false)
2644            }
2645            Token::Name(n) if n == "behind" => {
2646                self.bump();
2647                Attr::Behind(self.parse_place()?)
2648            }
2649            Token::Name(n) if n == "class" => {
2650                self.bump();
2651                Attr::Class(self.parse_stringexpr()?)
2652            }
2653            Token::Name(n) if n == "link" => {
2654                self.bump();
2655                Attr::Link(self.parse_stringexpr()?)
2656            }
2657            // a bare expression distance with no direction word, e.g. `move 1`,
2658            // `move -0.1`, `spline x` (length in the prevailing direction)
2659            Token::Float(_)
2660            | Token::Lparen
2661            | Token::EnvVar(_)
2662            | Token::Func1(_)
2663            | Token::Func2(_)
2664            | Token::Name(_)
2665            | Token::Minus
2666            | Token::Plus
2667            | Token::Kw(Kw::Rand) => {
2668                let span = self.cur_span();
2669                Attr::Dist(self.parse_expr()?, Some(span))
2670            }
2671            _ if self.place_is_scalar_ahead() => {
2672                let span = self.cur_span();
2673                Attr::Dist(self.parse_expr()?, Some(span))
2674            }
2675            _ => return Ok(None),
2676        };
2677        Ok(Some(attr))
2678    }
2679
2680    fn expect_kw(&mut self, k: Kw) -> PResult<()> {
2681        if self.eat_kw(k) {
2682            Ok(())
2683        } else {
2684            self.expected_here(kw_text(k))
2685        }
2686    }
2687
2688    // ---- string expressions ------------------------------------------------
2689
2690    fn parse_stringexpr(&mut self) -> PResult<StringExpr> {
2691        let first = self.parse_string_atom()?;
2692        self.continue_string(first)
2693    }
2694
2695    /// Continue a string expression with trailing `+ string` parts.
2696    fn continue_string(&mut self, first: StringExpr) -> PResult<StringExpr> {
2697        let mut e = first;
2698        // Iterative parse, but a left-deep `StringExpr::Concat` recurses on Drop
2699        // and evaluation — cap the chain length like numeric operator chains.
2700        let mut ops = 0u32;
2701        while self.at(&Token::Plus) && self.string_after_plus() {
2702            self.bump();
2703            let rhs = self.parse_string_atom()?;
2704            e = StringExpr::Concat(Box::new(e), Box::new(rhs));
2705            ops += 1;
2706            self.check_expr_chain(ops)?;
2707        }
2708        Ok(e)
2709    }
2710
2711    fn string_after_plus(&self) -> bool {
2712        self.token_starts_string_at(1)
2713    }
2714
2715    fn token_starts_string_at(&self, offset: usize) -> bool {
2716        match self.toks.get(self.idx + offset).map(|s| &s.tok) {
2717            Some(Token::Str(_) | Token::Arg(_) | Token::Kw(Kw::Sprintf)) => true,
2718            Some(Token::Name(n)) if n == "svg_font" => {
2719                matches!(
2720                    self.toks.get(self.idx + offset + 1).map(|s| &s.tok),
2721                    Some(Token::Lparen)
2722                )
2723            }
2724            _ => false,
2725        }
2726    }
2727
2728    fn parse_string_atom(&mut self) -> PResult<StringExpr> {
2729        match self.cur().clone() {
2730            Token::Str(s) => {
2731                self.bump();
2732                Ok(StringExpr::Lit(s))
2733            }
2734            Token::Arg(n) => {
2735                self.bump();
2736                Ok(StringExpr::Arg(n))
2737            }
2738            Token::Kw(Kw::Sprintf) => {
2739                self.bump();
2740                self.expect(&Token::Lparen)?;
2741                let fmt = self.parse_stringexpr()?;
2742                let mut args = Vec::new();
2743                while self.eat(&Token::Comma) {
2744                    args.push(self.parse_expr()?);
2745                }
2746                self.expect(&Token::Rparen)?;
2747                Ok(StringExpr::Sprintf(Box::new(fmt), args))
2748            }
2749            Token::Name(n) if n == "svg_font" && matches!(self.peek(1), Token::Lparen) => {
2750                self.bump();
2751                self.expect(&Token::Lparen)?;
2752                let mut args = Vec::new();
2753                if !self.at(&Token::Rparen) {
2754                    args.push(self.parse_expr()?);
2755                    while self.eat(&Token::Comma) {
2756                        args.push(self.parse_expr()?);
2757                    }
2758                }
2759                self.expect(&Token::Rparen)?;
2760                Ok(StringExpr::SvgFont(args))
2761            }
2762            other => self.err(format!("expected a string, found {other:?}")),
2763        }
2764    }
2765
2766    // ---- positions ---------------------------------------------------------
2767
2768    fn parse_label_position(&mut self) -> PResult<Position> {
2769        let save = self.idx;
2770        if let Ok(x) = self.parse_expr()
2771            && self.eat(&Token::Comma)
2772        {
2773            let y = self.parse_expr()?;
2774            return Ok(Position::Pair(x, y));
2775        }
2776        self.idx = save;
2777        self.parse_position()
2778    }
2779
2780    /// Positions support vector arithmetic; `+`/`-` are the lowest precedence.
2781    fn parse_position(&mut self) -> PResult<Position> {
2782        self.descend(Self::parse_position_inner)
2783    }
2784
2785    fn parse_position_inner(&mut self) -> PResult<Position> {
2786        let mut left = self.parse_pos_mul()?;
2787        loop {
2788            let sign = if self.at(&Token::Plus) {
2789                Sign::Plus
2790            } else if self.at(&Token::Minus) {
2791                Sign::Minus
2792            } else {
2793                break;
2794            };
2795            self.bump();
2796            let right = self.parse_pos_mul()?;
2797            left = Position::Sum(sign, Box::new(left), Box::new(right));
2798        }
2799        Ok(left)
2800    }
2801
2802    /// Scaling a position by a scalar: `p * s`, `p / s` (binds tighter than ±).
2803    fn parse_pos_mul(&mut self) -> PResult<Position> {
2804        let mut left = self.parse_pos_primary()?;
2805        loop {
2806            if self.eat(&Token::Mult) {
2807                left = Position::Scale(Box::new(left), self.parse_unary()?, false);
2808            } else if self.eat(&Token::Div) {
2809                left = Position::Scale(Box::new(left), self.parse_unary()?, true);
2810            } else {
2811                break;
2812            }
2813        }
2814        Ok(left)
2815    }
2816
2817    fn parse_pos_primary(&mut self) -> PResult<Position> {
2818        // A fraction-led interpolation — `frac between A and B`, `frac <p,p>`, or
2819        // `frac of the way between A and B`. The fraction can be parenthesised
2820        // (e.g. `(X/Y) between A and B`), so try it before the `(`/place branches.
2821        if let Some(p) = self.try_fraction()? {
2822            return Ok(p);
2823        }
2824        // `( … )`: coordinate pair `(x,y)` of scalar expressions, a
2825        // parenthesised position, or `(pos, pos)` — x of the first, y of the
2826        // second. Try a scalar pair first so components that are themselves
2827        // parenthesised scalars (e.g. `((a*g)*cos(t), …)`) parse correctly.
2828        if self.eat(&Token::Lparen) {
2829            let save = self.idx;
2830            // Prefer parsing the contents as position(s) — handles `(A, B.c)`,
2831            // `(pos, pos)`, `(2,3)`, `(0.5 between A and B)`. If that fails, the
2832            // contents are scalar coordinate expressions that the position
2833            // grammar can't represent alone (e.g. `((a*g)*cos t, (a*g)*sin t)`).
2834            if let Ok(p1) = self.parse_position() {
2835                let p = if self.eat(&Token::Comma) {
2836                    let p2 = self.parse_position()?;
2837                    Position::Place(Location::ParenPair(Box::new(p1), Box::new(p2)))
2838                } else {
2839                    p1 // drop the redundant parentheses
2840                };
2841                self.expect(&Token::Rparen)?;
2842                return Ok(p);
2843            }
2844            self.idx = save;
2845            let e1 = self.parse_add()?;
2846            self.expect(&Token::Comma)?;
2847            let e2 = self.parse_add()?;
2848            self.expect(&Token::Rparen)?;
2849            return Ok(Position::Pair(e1, e2));
2850        }
2851        // A leading place is point-valued UNLESS it is a scalar accessor
2852        // (`place.x` / `.y` / `.attr`), which begins an `(expr, expr)` pair.
2853        if self.at_place_start() && !self.place_is_scalar_ahead() {
2854            return Ok(Position::Place(Location::Place(self.parse_place()?)));
2855        }
2856        // expression-led coordinate pair `x, y` (interpolation handled above)
2857        let e1 = self.parse_add()?;
2858        if self.eat(&Token::Comma) {
2859            let e2 = self.parse_add()?;
2860            return Ok(Position::Pair(e1, e2));
2861        }
2862        self.err("expected `,`, `between`, or `of the way between` in position")
2863    }
2864
2865    /// Try to parse `frac (between | <p,p> | of the way between)`; if the leading
2866    /// expression isn't followed by an interpolation, backtrack and return `None`
2867    /// so the caller can parse a plain place / pair / parenthesised position.
2868    fn try_fraction(&mut self) -> PResult<Option<Position>> {
2869        let save = self.idx;
2870        let Ok(frac) = self.parse_add() else {
2871            self.idx = save;
2872            return Ok(None);
2873        };
2874        let mk = |frac, a, b, of_the_way| {
2875            Ok(Some(Position::Between {
2876                frac: Box::new(frac),
2877                a: Box::new(a),
2878                b: Box::new(b),
2879                of_the_way,
2880            }))
2881        };
2882        if self.eat(&Token::Lt) {
2883            let a = self.parse_position()?;
2884            self.expect(&Token::Comma)?;
2885            let b = self.parse_position()?;
2886            self.expect(&Token::Gt)?;
2887            return mk(frac, a, b, false);
2888        }
2889        let of_the_way = if self.at_kw(Kw::Of) {
2890            self.eat_kw(Kw::Of);
2891            if !(self.eat_kw(Kw::The) && self.eat_kw(Kw::Way) && self.eat_kw(Kw::Between)) {
2892                self.idx = save;
2893                return Ok(None);
2894            }
2895            true
2896        } else if self.eat_kw(Kw::Between) {
2897            false
2898        } else {
2899            self.idx = save;
2900            return Ok(None);
2901        };
2902        let a = self.parse_position()?;
2903        self.expect_kw(Kw::And)?;
2904        let b = self.parse_position()?;
2905        mk(frac, a, b, of_the_way)
2906    }
2907
2908    /// Lookahead: does the upcoming place end in a scalar accessor
2909    /// (`.x` / `.y` / `.attr`)? If so it is a number, not a point. Non-consuming.
2910    fn place_is_scalar_ahead(&mut self) -> bool {
2911        let save = self.idx;
2912        let parsed = self.parse_place().is_ok();
2913        let scalar = parsed && matches!(self.cur(), Token::DotX | Token::DotY | Token::Param(_));
2914        self.idx = save;
2915        scalar
2916    }
2917
2918    fn at_place_start(&self) -> bool {
2919        match self.cur() {
2920            Token::Label(_) | Token::Block | Token::Corner(_) => true,
2921            Token::Kw(Kw::Last) | Token::Kw(Kw::Here) => true,
2922            Token::Float(_) => matches!(self.peek(1), Token::Kw(Kw::Nth)),
2923            // `{expr}th …` / `` `expr`th … `` ordinal counts (only valid as a
2924            // place in position/expression context, never a group here)
2925            Token::LeftBrace | Token::LeftQuote => true,
2926            _ => false,
2927        }
2928    }
2929
2930    fn parse_place(&mut self) -> PResult<Place> {
2931        // `corner [of] placename` — recurses on a leading corner chain
2932        // (`.n.n.n…`), so bound it through `descend` to avoid a stack overflow.
2933        if let Token::Corner(c) = self.cur() {
2934            let c = *c;
2935            self.bump();
2936            self.eat_kw(Kw::Of);
2937            let inner = self.descend(Self::parse_place)?;
2938            return Ok(Place::CornerOf(c, Box::new(inner)));
2939        }
2940
2941        let mut place = self.parse_place_base()?;
2942
2943        // trailing `.corner`, `.label`, `.nth primobj`. The loop parses
2944        // iteratively but builds a left-deep `Place::Corner`/`Place::Member`
2945        // AST whose Drop and evaluation recurse, so cap the chain length.
2946        let mut accessors = 0u32;
2947        loop {
2948            if let Token::Corner(c) = self.cur() {
2949                let c = *c;
2950                self.bump();
2951                place = Place::Corner(Box::new(place), c);
2952            } else if self.at(&Token::Dot) {
2953                self.bump();
2954                let rhs = self.parse_place_base()?;
2955                place = Place::Member(Box::new(place), Box::new(rhs));
2956            } else {
2957                break;
2958            }
2959            accessors += 1;
2960            self.check_expr_chain(accessors)?;
2961        }
2962        Ok(place)
2963    }
2964
2965    fn parse_place_base(&mut self) -> PResult<Place> {
2966        match self.cur().clone() {
2967            Token::Kw(Kw::Here) => {
2968                self.bump();
2969                Ok(Place::Here)
2970            }
2971            Token::Label(name) => {
2972                let span = Some(self.cur_span());
2973                self.bump();
2974                let subscript = if self.eat(&Token::LeftBrack) {
2975                    let e = self.parse_subscript()?;
2976                    self.expect(&Token::RightBrack)?;
2977                    Some(Box::new(e))
2978                } else {
2979                    None
2980                };
2981                Ok(Place::Name {
2982                    name,
2983                    subscript,
2984                    span,
2985                })
2986            }
2987            Token::Kw(Kw::Last) | Token::Float(_) | Token::LeftBrace | Token::LeftQuote => {
2988                let span = Some(self.cur_span());
2989                let count = self.parse_nth()?;
2990                // A type keyword may follow (`last box`); without one, this is an
2991                // untyped reference to the most recent object of any kind
2992                // (`last`, `last.c`, `2nd last.n`).
2993                let obj = if self.at_primobj() {
2994                    self.parse_primobj()?
2995                } else {
2996                    PrimObj::Any
2997                };
2998                Ok(Place::Nth { count, obj, span })
2999            }
3000            other => self.err(format!("expected a place, found {other:?}")),
3001        }
3002    }
3003
3004    fn parse_nth(&mut self) -> PResult<Nth> {
3005        if self.eat_kw(Kw::Last) {
3006            return Ok(Nth::Last);
3007        }
3008        // ncount ordinal [last]
3009        let e = self.parse_ncount()?;
3010        self.expect_kw(Kw::Nth)?;
3011        let from_last = self.eat_kw(Kw::Last);
3012        Ok(Nth::Count(Box::new(e), from_last))
3013    }
3014
3015    /// An ordinal count: a number, `` `expr' ``, or `{ expr }` (grammar:
3016    /// `ncount`). Must NOT recurse into the general expression grammar on a
3017    /// bare number, or `2nd` would re-enter place parsing.
3018    fn parse_ncount(&mut self) -> PResult<Expr> {
3019        match self.cur().clone() {
3020            Token::Float(v) => {
3021                self.bump();
3022                Ok(Expr::Num(v))
3023            }
3024            Token::LeftBrace => {
3025                self.bump();
3026                let e = self.parse_expr()?;
3027                self.expect(&Token::RightBrace)?;
3028                Ok(e)
3029            }
3030            Token::LeftQuote => {
3031                self.bump();
3032                let e = self.parse_expr()?;
3033                self.expect(&Token::RightQuote)?;
3034                Ok(e)
3035            }
3036            other => self.err(format!("expected an ordinal count, found {other:?}")),
3037        }
3038    }
3039
3040    /// Whether the current token can begin a primitive-object type keyword
3041    /// (`box`, `[`, a string, …) — i.e. an explicit type after `last`/ordinal.
3042    fn at_primobj(&self) -> bool {
3043        matches!(
3044            self.cur(),
3045            Token::Prim(_) | Token::Block | Token::Str(_) | Token::LeftBrack
3046        ) || matches!(self.cur(), Token::Name(n) if n == "brace")
3047    }
3048
3049    fn parse_primobj(&mut self) -> PResult<PrimObj> {
3050        match self.cur().clone() {
3051            Token::Prim(p) => {
3052                self.bump();
3053                Ok(PrimObj::Prim(p))
3054            }
3055            Token::Name(n) if n == "brace" => {
3056                self.bump();
3057                Ok(PrimObj::Brace)
3058            }
3059            Token::Block => {
3060                self.bump();
3061                Ok(PrimObj::Block)
3062            }
3063            Token::Str(s) => {
3064                self.bump();
3065                Ok(PrimObj::Str(s))
3066            }
3067            Token::LeftBrack => {
3068                self.bump();
3069                self.expect(&Token::RightBrack)?;
3070                Ok(PrimObj::EmptyBrack)
3071            }
3072            other => self.err(format!("expected a primitive object, found {other:?}")),
3073        }
3074    }
3075
3076    // ---- expressions -------------------------------------------------------
3077
3078    fn opt_expr(&mut self) -> PResult<Option<Expr>> {
3079        if self.starts_scalar() || self.place_is_scalar_ahead() {
3080            Ok(Some(self.parse_expr()?))
3081        } else {
3082            Ok(None)
3083        }
3084    }
3085
3086    /// A color argument: a bare name (`red`), a label-cased name, or any
3087    /// string expression — the same grammar `hatchcolor` accepts.
3088    fn parse_color_like(&mut self) -> PResult<StringExpr> {
3089        match self.cur().clone() {
3090            // rpic extension: `rgb(r,g,b)` colour literal
3091            Token::Name(n) if n == "rgb" && matches!(self.peek(1), Token::Lparen) => {
3092                self.bump();
3093                self.expect(&Token::Lparen)?;
3094                let r = self.parse_expr()?;
3095                self.expect(&Token::Comma)?;
3096                let g = self.parse_expr()?;
3097                self.expect(&Token::Comma)?;
3098                let b = self.parse_expr()?;
3099                self.expect(&Token::Rparen)?;
3100                Ok(StringExpr::Rgb(Box::new([r, g, b])))
3101            }
3102            Token::Name(n) | Token::Label(n) => {
3103                self.bump();
3104                Ok(StringExpr::Lit(n))
3105            }
3106            // rpic extension (pikchr-style): a numeric colour — typically a
3107            // `0xRRGGBB` hex literal (`shaded 0x1b5e20`), or a parenthesised
3108            // expression in colour position (`colored (base + 0x10)`).
3109            Token::Float(_) | Token::Lparen => {
3110                Ok(StringExpr::ColorNum(Box::new(self.parse_expr()?)))
3111            }
3112            _ => self.parse_stringexpr(),
3113        }
3114    }
3115
3116    fn opt_attr_expr(
3117        &mut self,
3118        allow_fit: bool,
3119        allow_brace: bool,
3120        allow_hatch: bool,
3121        allow_close: bool,
3122    ) -> PResult<Option<Expr>> {
3123        if self.contextual_attr_ahead(allow_fit, allow_brace, allow_hatch, allow_close) {
3124            Ok(None)
3125        } else {
3126            self.opt_expr()
3127        }
3128    }
3129
3130    fn contextual_attr_ahead(
3131        &self,
3132        allow_fit: bool,
3133        allow_brace: bool,
3134        allow_hatch: bool,
3135        allow_close: bool,
3136    ) -> bool {
3137        matches!(
3138            self.cur(),
3139            Token::Name(n)
3140                if (allow_fit && n == "fit")
3141                    || (allow_hatch
3142                        && matches!(
3143                            n.as_str(),
3144                            "hatch"
3145                                | "crosshatch"
3146                                | "hatchangle"
3147                                | "hatchsep"
3148                                | "hatchwid"
3149                                | "hatchwidth"
3150                                | "hatchcolor"
3151                                | "gradient"
3152                                | "gradientangle"
3153                        ))
3154                    || n == "opacity"
3155                    || (allow_brace && matches!(n.as_str(), "bracepos" | "labeloffset"))
3156                    || n == "behind"
3157                    || n == "class"
3158                    || n == "link"
3159                    || (allow_close && n == "close")
3160        )
3161    }
3162
3163    fn starts_scalar(&self) -> bool {
3164        matches!(
3165            self.cur(),
3166            Token::Float(_)
3167                | Token::Name(_)
3168                | Token::EnvVar(_)
3169                | Token::Lparen
3170                | Token::Minus
3171                | Token::Plus
3172                | Token::Not
3173                | Token::Func1(_)
3174                | Token::Func2(_)
3175                | Token::Kw(Kw::Rand)
3176                | Token::ArgCount
3177        )
3178    }
3179
3180    fn parse_expr(&mut self) -> PResult<Expr> {
3181        self.descend(Self::parse_or)
3182    }
3183
3184    fn parse_or(&mut self) -> PResult<Expr> {
3185        let mut e = self.parse_and()?;
3186        let mut ops = 0;
3187        while self.eat(&Token::OrOr) {
3188            ops += 1;
3189            self.check_expr_chain(ops)?;
3190            let r = self.parse_and()?;
3191            e = Expr::Bin(BinOp::Or, Box::new(e), Box::new(r));
3192        }
3193        Ok(e)
3194    }
3195
3196    fn parse_and(&mut self) -> PResult<Expr> {
3197        let mut e = self.parse_cmp()?;
3198        let mut ops = 0;
3199        while self.eat(&Token::AndAnd) {
3200            ops += 1;
3201            self.check_expr_chain(ops)?;
3202            let r = self.parse_cmp()?;
3203            e = Expr::Bin(BinOp::And, Box::new(e), Box::new(r));
3204        }
3205        Ok(e)
3206    }
3207
3208    fn parse_cmp(&mut self) -> PResult<Expr> {
3209        let mut e = self.parse_add()?;
3210        let mut ops = 0;
3211        loop {
3212            let op = match self.cur() {
3213                Token::EqEq => BinOp::Eq,
3214                Token::Neq => BinOp::Ne,
3215                Token::Lt => BinOp::Lt,
3216                Token::Le => BinOp::Le,
3217                Token::Gt => BinOp::Gt,
3218                Token::Ge => BinOp::Ge,
3219                _ => break,
3220            };
3221            self.bump();
3222            ops += 1;
3223            self.check_expr_chain(ops)?;
3224            let r = self.parse_add()?;
3225            e = Expr::Bin(op, Box::new(e), Box::new(r));
3226        }
3227        Ok(e)
3228    }
3229
3230    fn parse_add(&mut self) -> PResult<Expr> {
3231        let mut e = self.parse_mul()?;
3232        let mut ops = 0;
3233        loop {
3234            let op = match self.cur() {
3235                Token::Plus => BinOp::Add,
3236                Token::Minus => BinOp::Sub,
3237                _ => break,
3238            };
3239            self.bump();
3240            ops += 1;
3241            self.check_expr_chain(ops)?;
3242            let r = self.parse_mul()?;
3243            e = Expr::Bin(op, Box::new(e), Box::new(r));
3244        }
3245        Ok(e)
3246    }
3247
3248    fn parse_mul(&mut self) -> PResult<Expr> {
3249        let mut e = self.parse_unary()?;
3250        let mut ops = 0;
3251        loop {
3252            let op = match self.cur() {
3253                Token::Mult => BinOp::Mul,
3254                Token::Div => BinOp::Div,
3255                Token::Percent => BinOp::Mod,
3256                _ => break,
3257            };
3258            self.bump();
3259            ops += 1;
3260            self.check_expr_chain(ops)?;
3261            let r = self.parse_unary()?;
3262            e = Expr::Bin(op, Box::new(e), Box::new(r));
3263        }
3264        Ok(e)
3265    }
3266
3267    fn parse_unary(&mut self) -> PResult<Expr> {
3268        let mut ops = Vec::new();
3269        loop {
3270            let op = match self.cur() {
3271                Token::Minus => Some(UnOp::Neg),
3272                Token::Plus => Some(UnOp::Pos),
3273                Token::Not => Some(UnOp::Not),
3274                _ => None,
3275            };
3276            let Some(op) = op else {
3277                break;
3278            };
3279            self.bump();
3280            ops.push(op);
3281            self.check_expr_chain(ops.len() as u32)?;
3282        }
3283        let mut e = self.parse_pow()?;
3284        for op in ops.into_iter().rev() {
3285            e = Expr::Unary(op, Box::new(e));
3286        }
3287        Ok(e)
3288    }
3289
3290    fn parse_pow(&mut self) -> PResult<Expr> {
3291        let base = self.parse_primary()?;
3292        if self.eat(&Token::Caret) {
3293            let exp = self.descend(Self::parse_unary)?; // right-associative
3294            Ok(Expr::Bin(BinOp::Pow, Box::new(base), Box::new(exp)))
3295        } else {
3296            Ok(base)
3297        }
3298    }
3299
3300    /// Lookahead from just inside a `(`: is the matching `)` immediately followed
3301    /// by `.x` or `.y`? (Used to read `( position ).x` as a coordinate.)
3302    fn paren_followed_by_dot_xy(&self) -> bool {
3303        let mut depth = 1i32;
3304        let mut i = self.idx;
3305        while let Some(s) = self.toks.get(i) {
3306            match &s.tok {
3307                Token::Lparen => depth += 1,
3308                Token::Rparen => {
3309                    depth -= 1;
3310                    if depth == 0 {
3311                        return matches!(
3312                            self.toks.get(i + 1).map(|t| &t.tok),
3313                            Some(Token::DotX | Token::DotY)
3314                        );
3315                    }
3316                }
3317                Token::Eof => return false,
3318                _ => {}
3319            }
3320            i += 1;
3321        }
3322        false
3323    }
3324
3325    fn parse_primary(&mut self) -> PResult<Expr> {
3326        // place-derived scalars: location.x / location.y / place.attr
3327        if self.at_place_start() && self.place_is_scalar_ahead() {
3328            return self.parse_place_scalar();
3329        }
3330        // a string operand (only meaningful as an `==`/`!=` operand)
3331        if self.at_string_start() {
3332            return Ok(Expr::Str(self.parse_stringexpr()?));
3333        }
3334        match self.cur().clone() {
3335            Token::Float(v) => {
3336                self.bump();
3337                Ok(Expr::Num(v))
3338            }
3339            Token::Name(name) | Token::Label(name) => {
3340                self.bump();
3341                let subscript = if self.eat(&Token::LeftBrack) {
3342                    let e = self.parse_subscript()?;
3343                    self.expect(&Token::RightBrack)?;
3344                    Some(Box::new(e))
3345                } else {
3346                    None
3347                };
3348                Ok(Expr::Var(name, subscript))
3349            }
3350            Token::EnvVar(v) => {
3351                self.bump();
3352                Ok(Expr::Env(v))
3353            }
3354            Token::Lparen => {
3355                self.bump();
3356                // embedded assignment `( name = expr )` yields the assigned value
3357                if matches!(self.cur(), Token::Name(_) | Token::Label(_))
3358                    && self.assignment_op_after_var_ref()
3359                {
3360                    let name = match self.bump() {
3361                        Token::Name(n) | Token::Label(n) => n,
3362                        _ => unreachable!(),
3363                    };
3364                    let subscript = if self.eat(&Token::LeftBrack) {
3365                        let e = self.parse_subscript()?;
3366                        self.expect(&Token::RightBrack)?;
3367                        Some(Box::new(e))
3368                    } else {
3369                        None
3370                    };
3371                    self.bump(); // `=`
3372                    let v = self.parse_expr()?;
3373                    self.expect(&Token::Rparen)?;
3374                    return Ok(Expr::Assign(name, subscript, Box::new(v)));
3375                }
3376                // `( position ).x` / `.y` — a coordinate of a parenthesised
3377                // position (e.g. `(A - B).x`, `($1-($2)).y`). Chosen by lookahead
3378                // for a trailing `.x`/`.y`, since `(A - B)` alone parses as scalar
3379                // (labels read as variables).
3380                if self.paren_followed_by_dot_xy() {
3381                    let pos = self.parse_position()?;
3382                    self.expect(&Token::Rparen)?;
3383                    let loc = Location::Paren(Box::new(pos));
3384                    return Ok(if self.eat(&Token::DotX) {
3385                        Expr::DotX(loc)
3386                    } else {
3387                        self.expect(&Token::DotY)?;
3388                        Expr::DotY(loc)
3389                    });
3390                }
3391                // a plain scalar group `( expr )`
3392                let e = self.parse_expr()?;
3393                self.expect(&Token::Rparen)?;
3394                Ok(e)
3395            }
3396            // `$+` outside any macro invocation: zero arguments
3397            Token::ArgCount => {
3398                self.bump();
3399                Ok(Expr::Num(0.0))
3400            }
3401            Token::Func1(f) => {
3402                self.bump();
3403                self.expect(&Token::Lparen)?;
3404                let e = self.parse_expr()?;
3405                self.expect(&Token::Rparen)?;
3406                Ok(Expr::Func1(f, Box::new(e)))
3407            }
3408            Token::Func2(f) => {
3409                self.bump();
3410                self.expect(&Token::Lparen)?;
3411                let a = self.parse_expr()?;
3412                self.expect(&Token::Comma)?;
3413                let b = self.parse_expr()?;
3414                self.expect(&Token::Rparen)?;
3415                Ok(Expr::Func2(f, Box::new(a), Box::new(b)))
3416            }
3417            Token::Kw(Kw::Rand) => {
3418                self.bump();
3419                self.expect(&Token::Lparen)?;
3420                let arg = if self.at(&Token::Rparen) {
3421                    None
3422                } else {
3423                    Some(Box::new(self.parse_expr()?))
3424                };
3425                self.expect(&Token::Rparen)?;
3426                Ok(Expr::Rand(arg))
3427            }
3428            other => self.err(format!("expected an expression, found {other:?}")),
3429        }
3430    }
3431
3432    /// Parse a place followed by `.x` / `.y` / `.attr` to yield a scalar.
3433    fn parse_place_scalar(&mut self) -> PResult<Expr> {
3434        let place = self.parse_place()?;
3435        match self.cur().clone() {
3436            Token::DotX => {
3437                self.bump();
3438                Ok(Expr::DotX(Location::Place(place)))
3439            }
3440            Token::DotY => {
3441                self.bump();
3442                Ok(Expr::DotY(Location::Place(place)))
3443            }
3444            Token::Param(p) => {
3445                self.bump();
3446                Ok(Expr::PlaceAttr(place, p))
3447            }
3448            other => self.err(format!(
3449                "a place is not a number here; expected `.x`, `.y`, or an attribute, found {other:?}"
3450            )),
3451        }
3452    }
3453}
3454
3455#[cfg(test)]
3456mod tests {
3457    use super::*;
3458
3459    fn pic(src: &str) -> Picture {
3460        parse(src).unwrap_or_else(|e| panic!("parse error: {e}"))
3461    }
3462
3463    #[test]
3464    fn kernighan_pipeline() {
3465        let p = pic(r#".PS
3466ellipse "document"
3467arrow
3468box "PIC"
3469arrow
3470box "TBL/EQN" "(optional)" dashed
3471arrow
3472box "TROFF"
3473arrow
3474ellipse "typesetter"
3475.PE
3476"#);
3477        assert_eq!(p.stmts.len(), 9);
3478        // the dashed box with two strings
3479        if let Stmt::Object { object, .. } = &p.stmts[4] {
3480            assert_eq!(object.kind, ObjectKind::Primitive(Prim::Box));
3481            let texts = object
3482                .attrs
3483                .iter()
3484                .filter(|a| matches!(a, Attr::Text(_)))
3485                .count();
3486            assert_eq!(texts, 2);
3487            assert!(
3488                object
3489                    .attrs
3490                    .iter()
3491                    .any(|a| matches!(a, Attr::LineStyle(LineType::Dashed, _)))
3492            );
3493        } else {
3494            panic!("expected object");
3495        }
3496    }
3497
3498    #[test]
3499    fn box_with_dims_and_at() {
3500        let p = pic("box ht 0.3 wid 0.5 at 0.25,0.15");
3501        let Stmt::Object { object, .. } = &p.stmts[0] else {
3502            panic!()
3503        };
3504        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Ht, _)));
3505        assert!(matches!(object.attrs[1], Attr::Dim(DimKind::Wid, _)));
3506        assert!(matches!(object.attrs[2], Attr::At(Position::Pair(_, _))));
3507    }
3508
3509    #[test]
3510    fn behind_parses_as_contextual_extension_attribute() {
3511        let p = pic("A: box\nbox behind A");
3512        let Stmt::Object { object, .. } = &p.stmts[1] else {
3513            panic!()
3514        };
3515        let Some(Attr::Behind(Place::Name {
3516            name, subscript, ..
3517        })) = object.attrs.last()
3518        else {
3519            panic!("expected behind attribute");
3520        };
3521        assert_eq!(name, "A");
3522        assert!(subscript.is_none());
3523
3524        let p = pic("behind = 2\nbox wid behind");
3525        assert_eq!(p.stmts.len(), 2);
3526        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3527    }
3528
3529    #[test]
3530    fn fit_parses_as_contextual_extension_attribute() {
3531        let p = pic("box \"long label\" fit");
3532        let Stmt::Object { object, .. } = &p.stmts[0] else {
3533            panic!()
3534        };
3535        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Fit)));
3536
3537        let p = pic("fit = 2\nbox wid fit");
3538        assert_eq!(p.stmts.len(), 2);
3539        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3540
3541        let p = pic("fit = 2\nline fit");
3542        let Stmt::Object { object, .. } = &p.stmts[1] else {
3543            panic!()
3544        };
3545        assert!(matches!(object.attrs[0], Attr::Dist(_, _)));
3546    }
3547
3548    #[test]
3549    fn hatch_parses_as_contextual_extension_attribute() {
3550        let p = pic("box hatch hatchangle 30 hatchsep .05 hatchwid 1.2 hatchcolor red");
3551        let Stmt::Object { object, .. } = &p.stmts[0] else {
3552            panic!()
3553        };
3554        assert!(
3555            object
3556                .attrs
3557                .iter()
3558                .any(|a| matches!(a, Attr::Hatch(HatchKind::Single)))
3559        );
3560        assert!(
3561            object
3562                .attrs
3563                .iter()
3564                .any(|a| matches!(a, Attr::HatchAngle(_)))
3565        );
3566        assert!(object.attrs.iter().any(|a| matches!(a, Attr::HatchSep(_))));
3567        assert!(
3568            object
3569                .attrs
3570                .iter()
3571                .any(|a| matches!(a, Attr::HatchWidth(_)))
3572        );
3573        assert!(
3574            object
3575                .attrs
3576                .iter()
3577                .any(|a| matches!(a, Attr::HatchColor(..)))
3578        );
3579
3580        let p = pic("hatch = 2\nbox wid hatch");
3581        assert_eq!(p.stmts.len(), 2);
3582        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3583        let Stmt::Object { object, .. } = &p.stmts[1] else {
3584            panic!()
3585        };
3586        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3587    }
3588
3589    #[test]
3590    fn opacity_parses_as_contextual_extension_attribute() {
3591        let p = pic("box opacity 0.5");
3592        let Stmt::Object { object, .. } = &p.stmts[0] else {
3593            panic!()
3594        };
3595        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Opacity(_))));
3596
3597        let p = pic("opacity = 2\nbox wid opacity");
3598        assert_eq!(p.stmts.len(), 2);
3599        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3600        let Stmt::Object { object, .. } = &p.stmts[1] else {
3601            panic!()
3602        };
3603        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3604    }
3605
3606    #[test]
3607    fn close_parses_as_contextual_line_extension_attribute() {
3608        let p = pic("line right then up close");
3609        let Stmt::Object { object, .. } = &p.stmts[0] else {
3610            panic!()
3611        };
3612        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Close)));
3613
3614        let p = pic("close = 2\nbox wid close");
3615        assert_eq!(p.stmts.len(), 2);
3616        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3617        let Stmt::Object { object, .. } = &p.stmts[1] else {
3618            panic!()
3619        };
3620        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3621    }
3622
3623    #[test]
3624    fn gradient_parses_as_contextual_extension_attribute() {
3625        let p = pic("box gradient \"steelblue\" white gradientangle 45");
3626        let Stmt::Object { object, .. } = &p.stmts[0] else {
3627            panic!()
3628        };
3629        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Gradient(..))));
3630        assert!(
3631            object
3632                .attrs
3633                .iter()
3634                .any(|a| matches!(a, Attr::GradientAngle(_)))
3635        );
3636
3637        // contextual fallback: `gradient` stays usable as a variable
3638        let p = pic("gradient = 2\nbox wid gradient");
3639        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3640        let Stmt::Object { object, .. } = &p.stmts[1] else {
3641            panic!()
3642        };
3643        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3644    }
3645
3646    #[test]
3647    fn class_parses_inline_and_statement_forms() {
3648        let p = pic("box class \"critical\"");
3649        let Stmt::Object { object, .. } = &p.stmts[0] else {
3650            panic!()
3651        };
3652        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Class(_))));
3653
3654        let p = pic("A: box\nclass A \"hot\"\nclass last box \"cold\"");
3655        assert!(matches!(p.stmts[1], Stmt::Class { .. }));
3656        assert!(matches!(p.stmts[2], Stmt::Class { .. }));
3657
3658        // contextual fallbacks: assignment and expression use survive
3659        let p = pic("class = 2\nbox wid class");
3660        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3661        let Stmt::Object { object, .. } = &p.stmts[1] else {
3662            panic!()
3663        };
3664        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3665    }
3666
3667    #[test]
3668    fn link_parses_inline_and_statement_forms() {
3669        let p = pic("box link \"https://rpic.dev\"");
3670        let Stmt::Object { object, .. } = &p.stmts[0] else {
3671            panic!()
3672        };
3673        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Link(_))));
3674
3675        let p = pic("A: box\nlink A \"https://rpic.dev\"\nlink last box \"#top\"");
3676        assert!(matches!(p.stmts[1], Stmt::Link { .. }));
3677        assert!(matches!(p.stmts[2], Stmt::Link { .. }));
3678
3679        // contextual fallbacks: assignment and expression use survive
3680        let p = pic("link = 2\nbox wid link");
3681        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3682        let Stmt::Object { object, .. } = &p.stmts[1] else {
3683            panic!()
3684        };
3685        assert!(matches!(object.attrs[0], Attr::Dim(DimKind::Wid, _)));
3686    }
3687
3688    #[test]
3689    fn brace_parses_as_contextual_extension_object() {
3690        let p = pic(
3691            "A: box\nB: box\nbrace from A.e to B.w down \"group\" wid .2 bracepos .4 labeloffset .1",
3692        );
3693        let Stmt::Object { object, .. } = &p.stmts[2] else {
3694            panic!()
3695        };
3696        assert_eq!(object.kind, ObjectKind::Brace);
3697        assert!(object.attrs.iter().any(|a| matches!(a, Attr::From(_))));
3698        assert!(object.attrs.iter().any(|a| matches!(a, Attr::To(_))));
3699        assert!(
3700            object
3701                .attrs
3702                .iter()
3703                .any(|a| matches!(a, Attr::Direction(Dir::Down, None)))
3704        );
3705        assert!(object.attrs.iter().any(|a| matches!(a, Attr::Text(_))));
3706        assert!(object.attrs.iter().any(|a| matches!(a, Attr::BracePos(_))));
3707        assert!(
3708            object
3709                .attrs
3710                .iter()
3711                .any(|a| matches!(a, Attr::BraceLabelOffset(_)))
3712        );
3713
3714        let p = pic("brace = 2\nline right brace");
3715        assert!(matches!(p.stmts[0], Stmt::Assign(_)));
3716        let Stmt::Object { object, .. } = &p.stmts[1] else {
3717            panic!()
3718        };
3719        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Line));
3720
3721        let p = pic("brace from 0,0 to 1,0\nline from last brace.start to last brace.end");
3722        assert_eq!(p.stmts.len(), 2);
3723    }
3724
3725    #[test]
3726    fn labeled_and_corners() {
3727        let p = pic("B1: box\narc -> from top of B1 to last box.ne");
3728        assert!(matches!(p.stmts[0], Stmt::Object { label: Some(_), .. }));
3729        let Stmt::Object { object, .. } = &p.stmts[1] else {
3730            panic!()
3731        };
3732        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Arc));
3733        assert!(
3734            object
3735                .attrs
3736                .iter()
3737                .any(|a| matches!(a, Attr::Arrowhead(Arrow::Right, _)))
3738        );
3739        // from top of B1
3740        assert!(object.attrs.iter().any(|a| matches!(
3741            a,
3742            Attr::From(Position::Place(Location::Place(Place::CornerOf(
3743                Corner::N,
3744                _
3745            ))))
3746        )));
3747    }
3748
3749    #[test]
3750    fn with_at_and_shift() {
3751        let p = pic("ellipse \"2\" with .nw at last ellipse.se + (0.1,0)");
3752        let Stmt::Object { object, .. } = &p.stmts[0] else {
3753            panic!()
3754        };
3755        let with = object
3756            .attrs
3757            .iter()
3758            .find(|a| matches!(a, Attr::With { .. }))
3759            .unwrap();
3760        let Attr::With { anchor, at } = with else {
3761            panic!()
3762        };
3763        assert_eq!(*anchor, WithAnchor::Corner(Corner::Nw));
3764        // `last ellipse.se + (0.1,0)` is a position sum
3765        assert!(matches!(at, Position::Sum(Sign::Plus, _, _)));
3766    }
3767
3768    #[test]
3769    fn with_member_anchor_parses() {
3770        let p = pic("[ A: box ] with .A.c at Here");
3771        let Stmt::Object { object, .. } = &p.stmts[0] else {
3772            panic!()
3773        };
3774        let with = object
3775            .attrs
3776            .iter()
3777            .find(|a| matches!(a, Attr::With { .. }))
3778            .unwrap();
3779        let Attr::With { anchor, .. } = with else {
3780            panic!()
3781        };
3782        assert!(matches!(
3783            anchor,
3784            WithAnchor::Place(Place::Corner(inner, Corner::Center))
3785                if matches!(inner.as_ref(), Place::Name { name, .. } if name == "A")
3786        ));
3787    }
3788
3789    #[test]
3790    fn expression_precedence() {
3791        // 2 + 3 * 4 ^ 2  ==  2 + (3 * (4^2)) = 50
3792        let p = pic("x = 2 + 3 * 4 ^ 2");
3793        let Stmt::Assign(list) = &p.stmts[0] else {
3794            panic!()
3795        };
3796        // structure: Add(2, Mul(3, Pow(4,2)))
3797        let Expr::Bin(BinOp::Add, _, rhs) = &list[0].value else {
3798            panic!("expected top-level add")
3799        };
3800        assert!(matches!(**rhs, Expr::Bin(BinOp::Mul, _, _)));
3801    }
3802
3803    #[test]
3804    fn between_position() {
3805        let p = pic("arrow from 1/3 of the way between A.ne and A.se");
3806        let Stmt::Object { object, .. } = &p.stmts[0] else {
3807            panic!()
3808        };
3809        assert!(object.attrs.iter().any(|a| matches!(
3810            a,
3811            Attr::From(Position::Between {
3812                of_the_way: true,
3813                ..
3814            })
3815        )));
3816    }
3817
3818    #[test]
3819    fn assignment_list_and_envvar() {
3820        let p = pic("boxht = 0.3; boxwid = 2 * boxht");
3821        assert_eq!(p.stmts.len(), 2);
3822        let Stmt::Assign(a0) = &p.stmts[0] else {
3823            panic!()
3824        };
3825        assert_eq!(a0[0].target, AssignTarget::Env(EnvVar::Boxht));
3826    }
3827
3828    #[test]
3829    fn dpic_svg_font_stub_parses_as_string() {
3830        let p = pic("print svg_font(\"Times\", 12)");
3831        let Stmt::Print(PrintItem::Str(StringExpr::SvgFont(args))) = &p.stmts[0] else {
3832            panic!()
3833        };
3834        assert_eq!(args.len(), 2);
3835    }
3836
3837    #[test]
3838    fn subscripted_variable_refs_parse() {
3839        let p = pic("P[1] = 2\nx = P[1]");
3840        let Stmt::Assign(a0) = &p.stmts[0] else {
3841            panic!()
3842        };
3843        assert!(matches!(&a0[0].target, AssignTarget::Var(name, Some(_)) if name == "P"));
3844
3845        let Stmt::Assign(a1) = &p.stmts[1] else {
3846            panic!()
3847        };
3848        assert!(matches!(&a1[0].value, Expr::Var(name, Some(_)) if name == "P"));
3849    }
3850
3851    #[test]
3852    fn block_object() {
3853        let p = pic("[ box; circle ] with .nw at Here");
3854        let Stmt::Object { object, .. } = &p.stmts[0] else {
3855            panic!()
3856        };
3857        let ObjectKind::Block(inner) = &object.kind else {
3858            panic!()
3859        };
3860        assert_eq!(inner.len(), 2);
3861    }
3862
3863    #[test]
3864    fn diamond_line_with_then() {
3865        let p = pic("line up right then down right then down left then up left");
3866        let Stmt::Object { object, .. } = &p.stmts[0] else {
3867            panic!()
3868        };
3869        let thens = object
3870            .attrs
3871            .iter()
3872            .filter(|a| matches!(a, Attr::Then))
3873            .count();
3874        assert_eq!(thens, 3);
3875    }
3876
3877    #[test]
3878    fn place_scalar_in_coord_pair() {
3879        // issue #3: (A.x, expr) must parse as an (expr,expr) pair, not a place
3880        let p = pic("A: box\n\"t\" at (A.x, A.y - 0.5)");
3881        let Stmt::Object { object, .. } = &p.stmts[1] else {
3882            panic!()
3883        };
3884        assert!(
3885            object
3886                .attrs
3887                .iter()
3888                .any(|a| matches!(a, Attr::At(Position::Pair(_, _))))
3889        );
3890        // a plain point place still parses as a place position
3891        let q = pic("A: box\nbox at A.ne");
3892        let Stmt::Object { object, .. } = &q.stmts[1] else {
3893            panic!()
3894        };
3895        assert!(object.attrs.iter().any(|a| matches!(
3896            a,
3897            Attr::At(Position::Place(Location::Place(Place::Corner(_, _))))
3898        )));
3899    }
3900
3901    #[test]
3902    fn ignores_non_svg_backend_preambles() {
3903        let p = pic(r#".PS
3904verbatimtex
3905\global\def\foo#1{#1}
3906etex
3907\global\def\bar#1{#1}
3908\psset{arrowsize=4pt}
3909box
3910.PE
3911"#);
3912        assert_eq!(p.stmts.len(), 1);
3913        let Stmt::Object { object, .. } = &p.stmts[0] else {
3914            panic!()
3915        };
3916        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Box));
3917    }
3918
3919    #[test]
3920    fn backend_filter_keeps_global_lines_inside_strings() {
3921        let p = pic(
3922            "sh \"echo -n \\\"print \\\\\"\\\" > x\"\nif dpicopt==optPGF then { command \"cycle; \\\n\\global\\let\\dpicdraw=x\" } else { box }",
3923        );
3924        assert_eq!(p.stmts.len(), 2);
3925        let Stmt::Object { object, .. } = &p.stmts[1] else {
3926            panic!()
3927        };
3928        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Box));
3929    }
3930
3931    #[test]
3932    fn static_if_copy_defines_macros_before_following_statements() {
3933        let dir = std::env::temp_dir().join(format!("rpic_static_if_{}", std::process::id()));
3934        std::fs::create_dir_all(&dir).unwrap();
3935        std::fs::write(dir.join("macros.pic"), "define makebox { box wid $1 }\n").unwrap();
3936        std::fs::write(
3937            dir.join("inc.pic"),
3938            "define makecircle { circle rad 0.1 }\n",
3939        )
3940        .unwrap();
3941        let p = parse_in_dir(
3942            "if \"plotlib\" != \"1\" then { copy \"macros.pic\" }\ndefine choose { if \"$1\"==\"\" then { box } else { copy \"$1/inc.pic\" } }\nchoose(.)\nmakecircle()\nmakebox(0.4)",
3943            Some(dir.as_path()),
3944        )
3945        .unwrap_or_else(|e| panic!("parse error: {e}"));
3946        let _ = std::fs::remove_dir_all(&dir);
3947        assert_eq!(p.stmts.len(), 2);
3948        let Stmt::Object { object, .. } = &p.stmts[0] else {
3949            panic!()
3950        };
3951        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Circle));
3952        let Stmt::Object { object, .. } = &p.stmts[1] else {
3953            panic!()
3954        };
3955        assert_eq!(object.kind, ObjectKind::Primitive(Prim::Box));
3956    }
3957
3958    #[test]
3959    fn unsupported_control_is_clear() {
3960        // `copy "file"` with no filesystem context reports a clear file error
3961        let e = parse("copy \"x\"").unwrap_err();
3962        assert!(e.msg.contains("copy") && e.msg.contains("file"));
3963    }
3964
3965    #[test]
3966    fn deeply_nested_input_errors_instead_of_overflowing() {
3967        // #283: pathological nesting must return a clean error, not abort the
3968        // process by overflowing the stack. Run on a roomy thread stack:
3969        // reaching MAX_PARSE_DEPTH costs far more stack in an unoptimized test
3970        // build than the harness's default thread provides (release/wasm frames
3971        // are much smaller — that's what the limit is tuned for).
3972        std::thread::Builder::new()
3973            .stack_size(32 * 1024 * 1024)
3974            .spawn(|| {
3975                let deep_parens = format!("box wid {}1{}", "(".repeat(5000), ")".repeat(5000));
3976                let e = parse(&deep_parens).unwrap_err();
3977                assert!(e.msg.contains("nested too deeply"), "{}", e.msg);
3978                let deep_blocks = format!("{}box{}", "[".repeat(5000), "]".repeat(5000));
3979                assert!(
3980                    parse(&deep_blocks)
3981                        .unwrap_err()
3982                        .msg
3983                        .contains("nested too deeply")
3984                );
3985                // a realistic depth (the corpus max is 4) parses fine
3986                assert!(parse("[[[[ box ]]]]").is_ok());
3987                assert!(parse(&format!("box wid {}1{}", "(".repeat(64), ")".repeat(64))).is_ok());
3988            })
3989            .unwrap()
3990            .join()
3991            .unwrap();
3992    }
3993
3994    #[test]
3995    fn flat_binary_expression_chain_errors_instead_of_overflowing() {
3996        // #306: flat chains used to bypass `descend` and later overflow the
3997        // evaluator stack. They now fail at parse time with a normal error.
3998        let expr = (0..2000).map(|_| "1").collect::<Vec<_>>().join("+");
3999        let e = parse(&format!("x = {expr}")).unwrap_err();
4000        assert!(e.msg.contains("too many chained operators"), "{}", e.msg);
4001    }
4002
4003    #[test]
4004    fn recursion_and_ast_depth_vectors_error_instead_of_overflowing() {
4005        // #318: four constructs that #306/#314 left uncovered used to abort by
4006        // overflowing the stack (parser recursion) or dropping/evaluating an
4007        // unbounded left-deep AST. Each must now return a clean parse error.
4008        // Roomy stack for the unoptimized test build (see the #283 test).
4009        std::thread::Builder::new()
4010            .stack_size(32 * 1024 * 1024)
4011            .spawn(|| {
4012                // brace-group nesting (parser recursion)
4013                let braces = format!("{}box{}", "{".repeat(5000), "}".repeat(5000));
4014                assert!(
4015                    parse(&braces)
4016                        .unwrap_err()
4017                        .msg
4018                        .contains("nested too deeply"),
4019                    "brace group"
4020                );
4021                // leading corner chain (parser recursion)
4022                let corners = format!("line to {}Here", ".n".repeat(5000));
4023                assert!(
4024                    parse(&corners)
4025                        .unwrap_err()
4026                        .msg
4027                        .contains("nested too deeply"),
4028                    "corner chain"
4029                );
4030                // string concatenation (left-deep StringExpr::Concat)
4031                let concat = vec!["\"a\""; 2000].join("+");
4032                assert!(
4033                    parse(&format!("print {concat}"))
4034                        .unwrap_err()
4035                        .msg
4036                        .contains("too many chained operators"),
4037                    "string concat"
4038                );
4039                // trailing member/corner chain in place context (left-deep AST)
4040                let members = format!("line to A{}.sw", ".B".repeat(2000));
4041                assert!(
4042                    parse(&format!("box\n{members}"))
4043                        .unwrap_err()
4044                        .msg
4045                        .contains("too many chained operators"),
4046                    "member chain"
4047                );
4048                // realistic depths still parse
4049                assert!(parse("{{{{ box }}}}").is_ok());
4050                assert!(parse("box\nline to A.n.sw").is_ok());
4051            })
4052            .unwrap()
4053            .join()
4054            .unwrap();
4055    }
4056
4057    #[test]
4058    fn control_constructs_parse() {
4059        assert!(parse("for i = 1 to 3 do { box }").is_ok());
4060        assert!(parse("if 1 > 0 then { box } else { circle }").is_ok());
4061        assert!(parse("reset boxht, boxwid").is_ok());
4062        // define is consumed by the preprocessor and expanded
4063        let p = parse("define e { box }\ne\ne").unwrap();
4064        assert_eq!(p.stmts.len(), 2);
4065    }
4066}