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