Skip to main content

rpic_core/
lexer.rs

1//! Hand-written lexer for the pic language.
2//!
3//! Recognises the full dpic token vocabulary (see `token.rs`). It is a simple
4//! character scanner — pic sources are small, so we keep the whole input in a
5//! `Vec<char>` and index into it. Line/column are tracked for diagnostics.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::token::*;
11
12/// A token together with its source position (1-based line/column).
13#[derive(Clone)]
14pub struct Spanned {
15    pub tok: Token,
16    pub line: u32,
17    pub col: u32,
18    /// Macro arguments that were in scope when this token was produced by
19    /// macro substitution. Used by eval-time `exec` expansion.
20    pub arg_frame: Option<Vec<Vec<Spanned>>>,
21    /// Macro definitions that were in scope when a deferred `if`/`for` body was
22    /// copied. Used when that body is parsed later by the evaluator.
23    pub macro_frame: Option<Arc<HashMap<String, Vec<Spanned>>>>,
24}
25
26impl Spanned {
27    pub fn new(tok: Token, line: u32, col: u32) -> Self {
28        Self {
29            tok,
30            line,
31            col,
32            arg_frame: None,
33            macro_frame: None,
34        }
35    }
36
37    pub fn with_arg_frame(mut self, args: &[Vec<Spanned>]) -> Self {
38        self.arg_frame = Some(args.to_vec());
39        self
40    }
41
42    pub fn with_macro_frame(mut self, macros: &HashMap<String, Vec<Spanned>>) -> Self {
43        self.macro_frame = Some(Arc::new(macros.clone()));
44        self
45    }
46}
47
48impl std::fmt::Debug for Spanned {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("Spanned")
51            .field("tok", &self.tok)
52            .field("line", &self.line)
53            .field("col", &self.col)
54            .field("arg_frame", &self.arg_frame)
55            .finish_non_exhaustive()
56    }
57}
58
59impl PartialEq for Spanned {
60    fn eq(&self, other: &Self) -> bool {
61        self.tok == other.tok
62            && self.line == other.line
63            && self.col == other.col
64            && self.arg_frame == other.arg_frame
65    }
66}
67
68/// A lexing error with location.
69#[derive(Debug, Clone, PartialEq)]
70pub struct LexError {
71    pub msg: String,
72    pub line: u32,
73    pub col: u32,
74}
75
76impl std::fmt::Display for LexError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        write!(f, "{}:{}: {}", self.line, self.col, self.msg)
79    }
80}
81
82/// Tokenize `src`. The returned vector always ends with [`Token::Eof`].
83pub fn lex(src: &str) -> Result<Vec<Spanned>, LexError> {
84    Lexer::new(src).run()
85}
86
87struct Lexer {
88    chars: Vec<char>,
89    pos: usize,
90    line: u32,
91    col: u32,
92    out: Vec<Spanned>,
93}
94
95impl Lexer {
96    fn new(src: &str) -> Self {
97        Lexer {
98            chars: src.chars().collect(),
99            pos: 0,
100            line: 1,
101            col: 1,
102            out: Vec::new(),
103        }
104    }
105
106    fn peek(&self) -> Option<char> {
107        self.chars.get(self.pos).copied()
108    }
109    fn peek_at(&self, n: usize) -> Option<char> {
110        self.chars.get(self.pos + n).copied()
111    }
112
113    /// Consume and return the next char, advancing line/column.
114    fn bump(&mut self) -> Option<char> {
115        let c = self.chars.get(self.pos).copied()?;
116        self.pos += 1;
117        if c == '\n' {
118            self.line += 1;
119            self.col = 1;
120        } else {
121            self.col += 1;
122        }
123        Some(c)
124    }
125
126    fn err<T>(&self, msg: impl Into<String>) -> Result<T, LexError> {
127        Err(LexError {
128            msg: msg.into(),
129            line: self.line,
130            col: self.col,
131        })
132    }
133
134    fn run(mut self) -> Result<Vec<Spanned>, LexError> {
135        loop {
136            // Skip spaces, tabs, CR, and `#` comments. Backslash-newline is a
137            // line continuation (both consumed).
138            loop {
139                match self.peek() {
140                    Some(' ') | Some('\t') | Some('\r') => {
141                        self.bump();
142                    }
143                    Some('#') => {
144                        while let Some(c) = self.peek() {
145                            if c == '\n' {
146                                break;
147                            }
148                            self.bump();
149                        }
150                    }
151                    Some('\\') => {
152                        // continuation: `\` [spaces] newline
153                        let save = (self.pos, self.line, self.col);
154                        self.bump();
155                        while matches!(self.peek(), Some(' ') | Some('\t') | Some('\r')) {
156                            self.bump();
157                        }
158                        if self.peek() == Some('\n') {
159                            self.bump();
160                        } else {
161                            // not a continuation; restore and let the main
162                            // matcher report the stray backslash.
163                            self.pos = save.0;
164                            self.line = save.1;
165                            self.col = save.2;
166                            break;
167                        }
168                    }
169                    _ => break,
170                }
171            }
172
173            let (line, col) = (self.line, self.col);
174            let c = match self.peek() {
175                None => {
176                    self.push(Token::Eof, line, col);
177                    return Ok(self.out);
178                }
179                Some(c) => c,
180            };
181
182            let tok = if c == '\n' || c == ';' {
183                self.bump();
184                Token::Newline
185            } else if c == '"' {
186                self.lex_string()?
187            } else if c == '$' {
188                self.lex_arg()?
189            } else if c.is_ascii_digit()
190                || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit()))
191            {
192                self.lex_number()?
193            } else if c == '.' {
194                self.lex_dot()?
195            } else if c.is_alphabetic() || c == '_' {
196                let tok = self.lex_word();
197                if matches!(tok, Token::Kw(Kw::Sh | Kw::Command)) {
198                    self.skip_raw_command_arg();
199                }
200                tok
201            } else {
202                match self.lex_operator()? {
203                    Some(t) => t,
204                    None => continue, // (should not happen)
205                }
206            };
207            self.push(tok, line, col);
208        }
209    }
210
211    fn push(&mut self, tok: Token, line: u32, col: u32) {
212        // A newline adjacent to `then` is a line/spline-path continuation, not a
213        // statement terminator: circuit_macros figures wrap long paths across
214        // lines without a trailing `\`, breaking either after `then`
215        // (`… then ⏎ left_ …`) or before it (`… 3*dimen_ ⏎ then …`). `then` only
216        // occurs inside a path and always needs a following element, so dropping
217        // the newline is unambiguous in both directions.
218        if tok == Token::Newline
219            && matches!(self.out.last().map(|s| &s.tok), Some(Token::Kw(Kw::Then)))
220        {
221            return;
222        }
223        if tok == Token::Kw(Kw::Then)
224            && matches!(self.out.last().map(|s| &s.tok), Some(Token::Newline))
225        {
226            self.out.pop();
227        }
228        self.out.push(Spanned::new(tok, line, col));
229    }
230
231    fn lex_string(&mut self) -> Result<Token, LexError> {
232        self.bump(); // opening quote
233        let mut s = String::new();
234        loop {
235            match self.bump() {
236                None => return self.err("unterminated string literal"),
237                Some('"') => break,
238                Some('\\') => {
239                    // Preserve escape sequences (e.g. troff escapes) verbatim;
240                    // a backslash-quote does not terminate the string.
241                    s.push('\\');
242                    match self.bump() {
243                        Some(c) => s.push(c),
244                        None => return self.err("unterminated string literal"),
245                    }
246                }
247                Some(c) => s.push(c),
248            }
249        }
250        Ok(Token::Str(s))
251    }
252
253    fn skip_raw_command_arg(&mut self) {
254        while matches!(self.peek(), Some(' ') | Some('\t') | Some('\r')) {
255            self.bump();
256        }
257        if self.peek() == Some('"') {
258            self.skip_raw_quoted();
259        } else if self.starts_word_here("sprintf") {
260            for _ in 0.."sprintf".len() {
261                self.bump();
262            }
263            while matches!(self.peek(), Some(' ') | Some('\t') | Some('\r')) {
264                self.bump();
265            }
266            if self.peek() == Some('(') {
267                self.skip_raw_parens();
268            }
269        } else {
270            self.skip_raw_line_tail();
271        }
272    }
273
274    fn starts_word_here(&self, word: &str) -> bool {
275        for (i, want) in word.chars().enumerate() {
276            if self.peek_at(i) != Some(want) {
277                return false;
278            }
279        }
280        !self
281            .peek_at(word.len())
282            .is_some_and(|c| c.is_alphanumeric() || c == '_')
283    }
284
285    fn skip_raw_quoted(&mut self) {
286        if self.peek() != Some('"') {
287            return;
288        }
289        self.bump();
290        while let Some(c) = self.bump() {
291            match c {
292                '\\' => {
293                    self.bump();
294                }
295                '"' if self.raw_quote_is_followed_by(&['\n', ';', '}']) => break,
296                _ => {}
297            }
298        }
299    }
300
301    fn skip_raw_parens(&mut self) {
302        let mut depth = 0i32;
303        while let Some(c) = self.bump() {
304            match c {
305                '"' => self.skip_raw_quoted_tail(),
306                '(' => depth += 1,
307                ')' => {
308                    depth -= 1;
309                    if depth == 0 {
310                        break;
311                    }
312                }
313                _ => {}
314            }
315        }
316    }
317
318    fn skip_raw_quoted_tail(&mut self) {
319        while let Some(c) = self.bump() {
320            match c {
321                '\\' => {
322                    self.bump();
323                }
324                '"' if self.raw_quote_is_followed_by(&['\n', ';', '}', ',', ')']) => break,
325                _ => {}
326            }
327        }
328    }
329
330    fn raw_quote_is_followed_by(&self, stops: &[char]) -> bool {
331        let mut off = 0;
332        loop {
333            match self.peek_at(off) {
334                Some(' ' | '\t' | '\r') => off += 1,
335                Some(c) => return stops.contains(&c),
336                None => return true,
337            }
338        }
339    }
340
341    fn skip_raw_line_tail(&mut self) {
342        loop {
343            let mut last_non_ws = None;
344            while let Some(c) = self.peek() {
345                if c == '\n' {
346                    break;
347                }
348                if !matches!(c, ' ' | '\t' | '\r') {
349                    last_non_ws = Some(c);
350                }
351                self.bump();
352            }
353            if self.peek() == Some('\n') && last_non_ws == Some('\\') {
354                self.bump();
355                continue;
356            }
357            break;
358        }
359    }
360
361    fn lex_arg(&mut self) -> Result<Token, LexError> {
362        self.bump(); // '$'
363        // `$+` is the count of arguments to the current macro
364        if self.peek() == Some('+') {
365            self.bump();
366            return Ok(Token::ArgCount);
367        }
368        let mut n = String::new();
369        while let Some(c) = self.peek() {
370            if c.is_ascii_digit() {
371                n.push(c);
372                self.bump();
373            } else {
374                break;
375            }
376        }
377        if n.is_empty() {
378            // A `$` not followed by a digit or `+` is a literal `$` (e.g. `$f$`
379            // LaTeX text passed unquoted as a macro argument), as in m4/dpic.
380            return Ok(Token::Dollar);
381        }
382        match n.parse() {
383            Ok(v) => Ok(Token::Arg(v)),
384            Err(_) => self.err(format!("macro argument `${n}` is too large")),
385        }
386    }
387
388    fn lex_number(&mut self) -> Result<Token, LexError> {
389        let mut s = String::new();
390        if self.peek() == Some('.') {
391            s.push('0'); // ".5" -> "0.5"
392        }
393        while let Some(c) = self.peek() {
394            if c.is_ascii_digit() {
395                s.push(c);
396                self.bump();
397            } else {
398                break;
399            }
400        }
401        // fractional part: only consume `.` when a digit follows, so a trailing
402        // `.` (e.g. in `box.ne`) stays a separate token.
403        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|d| d.is_ascii_digit()) {
404            s.push('.');
405            self.bump();
406            while let Some(c) = self.peek() {
407                if c.is_ascii_digit() {
408                    s.push(c);
409                    self.bump();
410                } else {
411                    break;
412                }
413            }
414        }
415        // exponent
416        if matches!(self.peek(), Some('e') | Some('E'))
417            && (self.peek_at(1).is_some_and(|d| d.is_ascii_digit())
418                || (matches!(self.peek_at(1), Some('+') | Some('-'))
419                    && self.peek_at(2).is_some_and(|d| d.is_ascii_digit())))
420        {
421            s.push('e');
422            self.bump();
423            if matches!(self.peek(), Some('+') | Some('-')) {
424                s.push(self.bump().unwrap());
425            }
426            while let Some(c) = self.peek() {
427                if c.is_ascii_digit() {
428                    s.push(c);
429                    self.bump();
430                } else {
431                    break;
432                }
433            }
434        }
435        // optional inch unit suffix `i`/`I` (pic's base unit is the inch, so the
436        // suffix is informational); don't consume it when it begins an
437        // identifier, e.g. the `in` of a following word.
438        if matches!(self.peek(), Some('i') | Some('I'))
439            && !self
440                .peek_at(1)
441                .is_some_and(|c| c.is_alphanumeric() || c == '_')
442        {
443            self.bump();
444        }
445        match s.parse::<f64>() {
446            Ok(v) => Ok(Token::Float(v)),
447            Err(_) => self.err(format!("invalid number `{s}`")),
448        }
449    }
450
451    /// Lex a `.`-prefixed token: `.PS/.PE`, compass corners, `.x/.y`, dotted
452    /// attribute accessors, or a bare `.` (the placename separator).
453    fn lex_dot(&mut self) -> Result<Token, LexError> {
454        let save = (self.pos, self.line, self.col);
455        self.bump(); // '.'
456        // read the following word
457        let mut w = String::new();
458        while let Some(c) = self.peek() {
459            if c.is_alphanumeric() || c == '_' {
460                w.push(c);
461                self.bump();
462            } else {
463                break;
464            }
465        }
466        if let Some(tok) = dot_keyword(&w) {
467            Ok(tok)
468        } else {
469            // Not a dotted keyword: emit a bare `.` and rewind so the word is
470            // lexed normally on the next pass.
471            self.pos = save.0 + 1;
472            self.line = save.1;
473            self.col = save.2 + 1;
474            Ok(Token::Dot)
475        }
476    }
477
478    fn lex_word(&mut self) -> Token {
479        let mut w = String::new();
480        while let Some(c) = self.peek() {
481            if c.is_alphanumeric() || c == '_' {
482                w.push(c);
483                self.bump();
484            } else {
485                break;
486            }
487        }
488        word_keyword(&w)
489    }
490
491    fn lex_operator(&mut self) -> Result<Option<Token>, LexError> {
492        let c = self.bump().unwrap();
493        let next = self.peek();
494        let tok = match c {
495            '(' => Token::Lparen,
496            ')' => Token::Rparen,
497            ',' => Token::Comma,
498            '^' => Token::Caret,
499            '{' => Token::LeftBrace,
500            '}' => Token::RightBrace,
501            ']' => Token::RightBrack,
502            '`' => Token::LeftQuote,
503            '\'' => Token::RightQuote,
504            '[' => {
505                if next == Some(']') {
506                    self.bump();
507                    Token::Block
508                } else {
509                    Token::LeftBrack
510                }
511            }
512            ':' => self.two('=', Token::ColonEq, Token::Colon),
513            '=' => self.two('=', Token::EqEq, Token::Eq),
514            '!' => self.two('=', Token::Neq, Token::Not),
515            '+' => self.two('=', Token::PlusEq, Token::Plus),
516            '*' => self.two('=', Token::MultEq, Token::Mult),
517            '/' => self.two('=', Token::DivEq, Token::Div),
518            '%' => self.two('=', Token::RemEq, Token::Percent),
519            '>' => self.two('=', Token::Ge, Token::Gt),
520            '&' => self.two('&', Token::AndAnd, Token::Ampersand),
521            '|' => {
522                if next == Some('|') {
523                    self.bump();
524                    Token::OrOr
525                } else {
526                    return self.err("unexpected `|`");
527                }
528            }
529            '-' => match next {
530                Some('>') => {
531                    self.bump();
532                    Token::Arrow(Arrow::Right)
533                }
534                Some('=') => {
535                    self.bump();
536                    Token::MinusEq
537                }
538                _ => Token::Minus,
539            },
540            '<' => match next {
541                Some('=') => {
542                    self.bump();
543                    Token::Le
544                }
545                Some('-') => {
546                    self.bump();
547                    if self.peek() == Some('>') {
548                        self.bump();
549                        Token::Arrow(Arrow::Double)
550                    } else {
551                        Token::Arrow(Arrow::Left)
552                    }
553                }
554                _ => Token::Lt,
555            },
556            // A `\` that is not a line continuation (handled in the whitespace
557            // skip) is literal text — e.g. a LaTeX command like `\beta` passed
558            // unquoted as a macro argument, as in m4/dpic. (`c` is already
559            // consumed by the `bump()` above.)
560            '\\' => Token::Backslash,
561            other => return self.err(format!("unexpected character `{other}`")),
562        };
563        Ok(Some(tok))
564    }
565
566    /// If the next char is `c`, consume it and return `yes`; otherwise `no`.
567    fn two(&mut self, c: char, yes: Token, no: Token) -> Token {
568        if self.peek() == Some(c) {
569            self.bump();
570            yes
571        } else {
572            no
573        }
574    }
575}
576
577/// Classify a bare word (after the keyword vocabulary). Unknown words become a
578/// [`Token::Label`] (upper-initial) or [`Token::Name`] (otherwise).
579fn word_keyword(w: &str) -> Token {
580    use Token::*;
581    match w {
582        // primitives
583        "box" => Prim(self::Prim::Box),
584        "circle" => Prim(self::Prim::Circle),
585        "ellipse" => Prim(self::Prim::Ellipse),
586        "arc" => Prim(self::Prim::Arc),
587        "line" => Prim(self::Prim::Line),
588        "arrow" => Prim(self::Prim::Arrow),
589        "move" => Prim(self::Prim::Move),
590        "spline" => Prim(self::Prim::Spline),
591        // directions
592        "up" => Dir(self::Dir::Up),
593        "down" => Dir(self::Dir::Down),
594        "right" => Dir(self::Dir::Right),
595        "left" => Dir(self::Dir::Left),
596        // attributes & joiners
597        "height" | "ht" => Kw(self::Kw::Ht),
598        "width" | "wid" => Kw(self::Kw::Wid),
599        "radius" | "rad" => Kw(self::Kw::Rad),
600        "diameter" | "diam" => Kw(self::Kw::Diam),
601        "thickness" | "thick" => Kw(self::Kw::Thick),
602        "scaled" => Kw(self::Kw::Scaled),
603        "from" => Kw(self::Kw::From),
604        "to" => Kw(self::Kw::To),
605        "at" => Kw(self::Kw::At),
606        "with" => Kw(self::Kw::With),
607        "by" => Kw(self::Kw::By),
608        "then" => Kw(self::Kw::Then),
609        "cw" => Kw(self::Kw::Cw),
610        "ccw" => Kw(self::Kw::Ccw),
611        "continue" => Kw(self::Kw::Continue),
612        "chop" => Kw(self::Kw::Chop),
613        "same" => Kw(self::Kw::Same),
614        "of" => Kw(self::Kw::Of),
615        "the" => Kw(self::Kw::The),
616        "way" => Kw(self::Kw::Way),
617        "between" => Kw(self::Kw::Between),
618        "and" => Kw(self::Kw::And),
619        "last" => Kw(self::Kw::Last),
620        "fill" | "filled" => Kw(self::Kw::Fill),
621        "st" | "nd" | "rd" | "th" => Kw(self::Kw::Nth),
622        "Here" => Kw(self::Kw::Here),
623        // bare corner words
624        "top" => Corner(self::Corner::N),
625        "bottom" => Corner(self::Corner::S),
626        "start" => Corner(self::Corner::Start),
627        "end" => Corner(self::Corner::End),
628        // commands / control
629        "print" => Kw(self::Kw::Print),
630        "copy" => Kw(self::Kw::Copy),
631        "reset" => Kw(self::Kw::Reset),
632        "exec" => Kw(self::Kw::Exec),
633        "sh" => Kw(self::Kw::Sh),
634        "command" => Kw(self::Kw::Command),
635        "define" => Kw(self::Kw::Define),
636        "undefine" | "undef" => Kw(self::Kw::Undef),
637        "rand" => Kw(self::Kw::Rand),
638        "if" => Kw(self::Kw::If),
639        "else" => Kw(self::Kw::Else),
640        "for" => Kw(self::Kw::For),
641        "do" => Kw(self::Kw::Do),
642        "sprintf" => Kw(self::Kw::Sprintf),
643        // rpic animation extension
644        "animate" => Kw(self::Kw::Animate),
645        "after" => Kw(self::Kw::After),
646        "delay" => Kw(self::Kw::Delay),
647        // line types
648        "solid" => LineType(self::LineType::Solid),
649        "dotted" => LineType(self::LineType::Dotted),
650        "dashed" => LineType(self::LineType::Dashed),
651        "invis" | "invisible" => LineType(self::LineType::Invis),
652        // color / outline / shade
653        "color" | "colour" | "colored" | "coloured" => Color(self::Color::Colored),
654        "outline" | "outlined" => Color(self::Color::Outlined),
655        "shade" | "shaded" => Color(self::Color::Shaded),
656        // text position
657        "center" | "centre" => TextPos(self::TextPos::Center),
658        "ljust" => TextPos(self::TextPos::Ljust),
659        "rjust" => TextPos(self::TextPos::Rjust),
660        "above" => TextPos(self::TextPos::Above),
661        "below" => TextPos(self::TextPos::Below),
662        // one-arg functions
663        "abs" => Func1(self::Func1::Abs),
664        "acos" => Func1(self::Func1::Acos),
665        "asin" => Func1(self::Func1::Asin),
666        "cos" => Func1(self::Func1::Cos),
667        "exp" => Func1(self::Func1::Exp),
668        "expe" => Func1(self::Func1::Expe),
669        "int" => Func1(self::Func1::Int),
670        "log" => Func1(self::Func1::Log),
671        "loge" => Func1(self::Func1::Loge),
672        "sign" => Func1(self::Func1::Sign),
673        "sin" => Func1(self::Func1::Sin),
674        "sqrt" => Func1(self::Func1::Sqrt),
675        "tan" => Func1(self::Func1::Tan),
676        "floor" => Func1(self::Func1::Floor),
677        // two-arg functions
678        "atan2" => Func2(self::Func2::Atan2),
679        "max" => Func2(self::Func2::Max),
680        "min" => Func2(self::Func2::Min),
681        "pmod" => Func2(self::Func2::Pmod),
682        // environment variables
683        "arcrad" => EnvVar(self::EnvVar::Arcrad),
684        "arrowht" => EnvVar(self::EnvVar::Arrowht),
685        "arrowwid" => EnvVar(self::EnvVar::Arrowwid),
686        "boxht" => EnvVar(self::EnvVar::Boxht),
687        "boxrad" => EnvVar(self::EnvVar::Boxrad),
688        "boxwid" => EnvVar(self::EnvVar::Boxwid),
689        "circlerad" => EnvVar(self::EnvVar::Circlerad),
690        "dashwid" => EnvVar(self::EnvVar::Dashwid),
691        "ellipseht" => EnvVar(self::EnvVar::Ellipseht),
692        "ellipsewid" => EnvVar(self::EnvVar::Ellipsewid),
693        "lineht" => EnvVar(self::EnvVar::Lineht),
694        "linewid" => EnvVar(self::EnvVar::Linewid),
695        "moveht" => EnvVar(self::EnvVar::Moveht),
696        "movewid" => EnvVar(self::EnvVar::Movewid),
697        "textht" => EnvVar(self::EnvVar::Textht),
698        "textoffset" => EnvVar(self::EnvVar::Textoffset),
699        "textwid" => EnvVar(self::EnvVar::Textwid),
700        "arrowhead" => EnvVar(self::EnvVar::Arrowhead),
701        "fillval" => EnvVar(self::EnvVar::Fillval),
702        "linethick" => EnvVar(self::EnvVar::Linethick),
703        "margin" => EnvVar(self::EnvVar::Margin),
704        "topmargin" => EnvVar(self::EnvVar::Topmargin),
705        "rightmargin" => EnvVar(self::EnvVar::Rightmargin),
706        "bottommargin" => EnvVar(self::EnvVar::Bottommargin),
707        "leftmargin" => EnvVar(self::EnvVar::Leftmargin),
708        "maxpsht" => EnvVar(self::EnvVar::Maxpsht),
709        "maxpswid" => EnvVar(self::EnvVar::Maxpswid),
710        "scale" => EnvVar(self::EnvVar::Scale),
711        "texlabels" => EnvVar(self::EnvVar::Texlabels),
712        // identifier / label
713        _ => {
714            if w.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
715                Label(w.to_string())
716            } else {
717                Name(w.to_string())
718            }
719        }
720    }
721}
722
723/// Classify the word following a `.`. Returns `None` if it is not a dotted
724/// keyword (so the caller emits a bare `.`).
725fn dot_keyword(w: &str) -> Option<Token> {
726    use Token::*;
727    let t = match w {
728        "PS" => DotPS,
729        "PE" => DotPE,
730        "x" => DotX,
731        "y" => DotY,
732        // compass corners
733        "ne" => Corner(self::Corner::Ne),
734        "se" => Corner(self::Corner::Se),
735        "nw" => Corner(self::Corner::Nw),
736        "sw" => Corner(self::Corner::Sw),
737        "n" | "t" | "top" | "north" => Corner(self::Corner::N),
738        "s" | "b" | "bot" | "bottom" | "south" => Corner(self::Corner::S),
739        "e" | "r" | "east" | "right" => Corner(self::Corner::E),
740        "w" | "l" | "west" | "left" => Corner(self::Corner::W),
741        "start" => Corner(self::Corner::Start),
742        "end" => Corner(self::Corner::End),
743        "c" | "center" | "centre" => Corner(self::Corner::Center),
744        // dotted attribute accessors
745        "ht" | "height" => Param(self::Param::Height),
746        "wid" | "width" => Param(self::Param::Width),
747        "rad" | "radius" => Param(self::Param::Radius),
748        "diam" | "diameter" => Param(self::Param::Diameter),
749        "thick" | "thickness" => Param(self::Param::Thickness),
750        "len" | "length" => Param(self::Param::Length),
751        _ => return None,
752    };
753    Some(t)
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    fn toks(src: &str) -> Vec<Token> {
761        lex(src).unwrap().into_iter().map(|s| s.tok).collect()
762    }
763
764    #[test]
765    fn basic_box() {
766        assert_eq!(
767            toks("box \"hi\""),
768            vec![Token::Prim(Prim::Box), Token::Str("hi".into()), Token::Eof]
769        );
770    }
771
772    #[test]
773    fn label_vs_name() {
774        assert_eq!(
775            toks("Start: boxwid"),
776            vec![
777                Token::Label("Start".into()),
778                Token::Colon,
779                Token::EnvVar(EnvVar::Boxwid),
780                Token::Eof
781            ]
782        );
783        // a lower-initial non-keyword is a Name
784        assert_eq!(toks("myvar"), vec![Token::Name("myvar".into()), Token::Eof]);
785    }
786
787    #[test]
788    fn numbers() {
789        assert_eq!(toks("0.5"), vec![Token::Float(0.5), Token::Eof]);
790        assert_eq!(toks(".25"), vec![Token::Float(0.25), Token::Eof]);
791        assert_eq!(toks("1e3"), vec![Token::Float(1000.0), Token::Eof]);
792        assert_eq!(toks("2.5e-1"), vec![Token::Float(0.25), Token::Eof]);
793    }
794
795    #[test]
796    fn operators_and_arrows() {
797        assert_eq!(
798            toks("a := b <= c -> d <- e <-> f"),
799            vec![
800                Token::Name("a".into()),
801                Token::ColonEq,
802                Token::Name("b".into()),
803                Token::Le,
804                Token::Name("c".into()),
805                Token::Arrow(Arrow::Right),
806                Token::Name("d".into()),
807                Token::Arrow(Arrow::Left),
808                Token::Name("e".into()),
809                Token::Arrow(Arrow::Double),
810                Token::Name("f".into()),
811                Token::Eof
812            ]
813        );
814    }
815
816    #[test]
817    fn minus_is_not_arrow_without_gt() {
818        assert_eq!(
819            toks("2-3"),
820            vec![
821                Token::Float(2.0),
822                Token::Minus,
823                Token::Float(3.0),
824                Token::Eof
825            ]
826        );
827    }
828
829    #[test]
830    fn compass_and_params() {
831        assert_eq!(
832            toks("last box.ne A.ht .center"),
833            vec![
834                Token::Kw(Kw::Last),
835                Token::Prim(Prim::Box),
836                Token::Corner(Corner::Ne),
837                Token::Label("A".into()),
838                Token::Param(Param::Height),
839                Token::Corner(Corner::Center),
840                Token::Eof
841            ]
842        );
843    }
844
845    #[test]
846    fn dot_then_label() {
847        // `.` not followed by a dotted keyword is a bare Dot, then the word.
848        assert_eq!(
849            toks("B.A"),
850            vec![
851                Token::Label("B".into()),
852                Token::Dot,
853                Token::Label("A".into()),
854                Token::Eof
855            ]
856        );
857    }
858
859    #[test]
860    fn statement_separators_and_comments() {
861        assert_eq!(
862            toks("box; circle # a comment\narc"),
863            vec![
864                Token::Prim(Prim::Box),
865                Token::Newline,
866                Token::Prim(Prim::Circle),
867                Token::Newline,
868                Token::Prim(Prim::Arc),
869                Token::Eof
870            ]
871        );
872    }
873
874    #[test]
875    fn shell_commands_skip_raw_line_tail() {
876        assert_eq!(
877            toks("sh \"echo -n \\\"print \\\\\\\"\\\" > $1_prow\"\nbox"),
878            vec![
879                Token::Kw(Kw::Sh),
880                Token::Newline,
881                Token::Prim(Prim::Box),
882                Token::Eof
883            ]
884        );
885        assert_eq!(
886            toks("command \\foo $bad \"unterminated\ncircle"),
887            vec![
888                Token::Kw(Kw::Command),
889                Token::Newline,
890                Token::Prim(Prim::Circle),
891                Token::Eof
892            ]
893        );
894        assert_eq!(
895            toks("sh \"sed something \\\n  > tmp\"\narc"),
896            vec![
897                Token::Kw(Kw::Sh),
898                Token::Newline,
899                Token::Prim(Prim::Arc),
900                Token::Eof
901            ]
902        );
903        assert_eq!(
904            toks("sh \"rm\";}\ncommand sprintf(\"x\", y) }\n"),
905            vec![
906                Token::Kw(Kw::Sh),
907                Token::Newline,
908                Token::RightBrace,
909                Token::Newline,
910                Token::Kw(Kw::Command),
911                Token::RightBrace,
912                Token::Newline,
913                Token::Eof
914            ]
915        );
916        assert_eq!(
917            toks("sh \"echo -n \\\"print \\\\\"\\\" > $1_prow\"\nbox"),
918            vec![
919                Token::Kw(Kw::Sh),
920                Token::Newline,
921                Token::Prim(Prim::Box),
922                Token::Eof
923            ]
924        );
925    }
926
927    #[test]
928    fn line_continuation() {
929        assert_eq!(
930            toks("box \\\n  wid 2"),
931            vec![
932                Token::Prim(Prim::Box),
933                Token::Kw(Kw::Wid),
934                Token::Float(2.0),
935                Token::Eof
936            ]
937        );
938    }
939
940    #[test]
941    fn ps_pe_and_block() {
942        assert_eq!(
943            toks(".PS\nbox\n.PE"),
944            vec![
945                Token::DotPS,
946                Token::Newline,
947                Token::Prim(Prim::Box),
948                Token::Newline,
949                Token::DotPE,
950                Token::Eof
951            ]
952        );
953        assert_eq!(toks("[]"), vec![Token::Block, Token::Eof]);
954        assert_eq!(
955            toks("[ box ]"),
956            vec![
957                Token::LeftBrack,
958                Token::Prim(Prim::Box),
959                Token::RightBrack,
960                Token::Eof
961            ]
962        );
963    }
964
965    #[test]
966    fn macro_arg() {
967        assert_eq!(
968            toks("box $1"),
969            vec![Token::Prim(Prim::Box), Token::Arg(1), Token::Eof]
970        );
971    }
972
973    #[test]
974    fn func_and_envvar() {
975        assert_eq!(
976            toks("sqrt(2) atan2 scale margin topmargin"),
977            vec![
978                Token::Func1(Func1::Sqrt),
979                Token::Lparen,
980                Token::Float(2.0),
981                Token::Rparen,
982                Token::Func2(Func2::Atan2),
983                Token::EnvVar(EnvVar::Scale),
984                Token::EnvVar(EnvVar::Margin),
985                Token::EnvVar(EnvVar::Topmargin),
986                Token::Eof
987            ]
988        );
989    }
990
991    #[test]
992    fn newline_after_then_is_continuation() {
993        // `then` ⏎ continues a path (no statement break); a normal newline does not.
994        assert_eq!(
995            toks("line right then\nup"),
996            vec![
997                Token::Prim(Prim::Line),
998                Token::Dir(Dir::Right),
999                Token::Kw(Kw::Then),
1000                Token::Dir(Dir::Up),
1001                Token::Eof,
1002            ]
1003        );
1004        assert_eq!(
1005            toks("box\nup"),
1006            vec![
1007                Token::Prim(Prim::Box),
1008                Token::Newline,
1009                Token::Dir(Dir::Up),
1010                Token::Eof,
1011            ]
1012        );
1013    }
1014
1015    #[test]
1016    fn newline_before_then_is_continuation() {
1017        // a path may also wrap *before* `then` (`… right\nthen up`).
1018        assert_eq!(
1019            toks("line right\nthen up"),
1020            vec![
1021                Token::Prim(Prim::Line),
1022                Token::Dir(Dir::Right),
1023                Token::Kw(Kw::Then),
1024                Token::Dir(Dir::Up),
1025                Token::Eof,
1026            ]
1027        );
1028    }
1029
1030    #[test]
1031    fn dollar_and_backslash_are_literal_text() {
1032        // `$` not before a digit/`+`, and a non-continuation `\`, are literal
1033        // text (e.g. `$\beta$` LaTeX passed unquoted as a macro argument).
1034        assert_eq!(
1035            toks("$\\beta$"),
1036            vec![
1037                Token::Dollar,
1038                Token::Backslash,
1039                Token::Name("beta".into()),
1040                Token::Dollar,
1041                Token::Eof,
1042            ]
1043        );
1044        // `$1` is still a macro argument.
1045        assert_eq!(toks("$1"), vec![Token::Arg(1), Token::Eof]);
1046    }
1047}