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<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: &[Vec<Spanned>]) -> Self {
61        self.arg_frame = Some(args.to_vec());
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        "scaled" => Kw(self::Kw::Scaled),
706        "from" => Kw(self::Kw::From),
707        "to" => Kw(self::Kw::To),
708        "at" => Kw(self::Kw::At),
709        "with" => Kw(self::Kw::With),
710        "by" => Kw(self::Kw::By),
711        "then" => Kw(self::Kw::Then),
712        "cw" => Kw(self::Kw::Cw),
713        "ccw" => Kw(self::Kw::Ccw),
714        "continue" => Kw(self::Kw::Continue),
715        "chop" => Kw(self::Kw::Chop),
716        "same" => Kw(self::Kw::Same),
717        "of" => Kw(self::Kw::Of),
718        "the" => Kw(self::Kw::The),
719        "way" => Kw(self::Kw::Way),
720        "between" => Kw(self::Kw::Between),
721        "and" => Kw(self::Kw::And),
722        "last" => Kw(self::Kw::Last),
723        "fill" | "filled" => Kw(self::Kw::Fill),
724        "st" | "nd" | "rd" | "th" => Kw(self::Kw::Nth),
725        "Here" => Kw(self::Kw::Here),
726        // bare corner words
727        "top" => Corner(self::Corner::N),
728        "bottom" => Corner(self::Corner::S),
729        "start" => Corner(self::Corner::Start),
730        "end" => Corner(self::Corner::End),
731        // commands / control
732        "print" => Kw(self::Kw::Print),
733        "copy" => Kw(self::Kw::Copy),
734        "reset" => Kw(self::Kw::Reset),
735        "exec" => Kw(self::Kw::Exec),
736        "sh" => Kw(self::Kw::Sh),
737        "command" => Kw(self::Kw::Command),
738        "define" => Kw(self::Kw::Define),
739        "undefine" | "undef" => Kw(self::Kw::Undef),
740        "rand" => Kw(self::Kw::Rand),
741        "if" => Kw(self::Kw::If),
742        "else" => Kw(self::Kw::Else),
743        "for" => Kw(self::Kw::For),
744        "do" => Kw(self::Kw::Do),
745        "sprintf" => Kw(self::Kw::Sprintf),
746        // rpic animation extension
747        "animate" => Kw(self::Kw::Animate),
748        "after" => Kw(self::Kw::After),
749        "delay" => Kw(self::Kw::Delay),
750        // line types
751        "solid" => LineType(self::LineType::Solid),
752        "dotted" => LineType(self::LineType::Dotted),
753        "dashed" => LineType(self::LineType::Dashed),
754        "invis" | "invisible" => LineType(self::LineType::Invis),
755        // color / outline / shade
756        "color" | "colour" | "colored" | "coloured" => Color(self::Color::Colored),
757        "outline" | "outlined" => Color(self::Color::Outlined),
758        "shade" | "shaded" => Color(self::Color::Shaded),
759        // text position
760        "center" | "centre" => TextPos(self::TextPos::Center),
761        "ljust" => TextPos(self::TextPos::Ljust),
762        "rjust" => TextPos(self::TextPos::Rjust),
763        "above" => TextPos(self::TextPos::Above),
764        "below" => TextPos(self::TextPos::Below),
765        // one-arg functions
766        "abs" => Func1(self::Func1::Abs),
767        "acos" => Func1(self::Func1::Acos),
768        "asin" => Func1(self::Func1::Asin),
769        "cos" => Func1(self::Func1::Cos),
770        "exp" => Func1(self::Func1::Exp),
771        "expe" => Func1(self::Func1::Expe),
772        "int" => Func1(self::Func1::Int),
773        "log" => Func1(self::Func1::Log),
774        "loge" => Func1(self::Func1::Loge),
775        "sign" => Func1(self::Func1::Sign),
776        "sin" => Func1(self::Func1::Sin),
777        "sqrt" => Func1(self::Func1::Sqrt),
778        "tan" => Func1(self::Func1::Tan),
779        "floor" => Func1(self::Func1::Floor),
780        // two-arg functions
781        "atan2" => Func2(self::Func2::Atan2),
782        "max" => Func2(self::Func2::Max),
783        "min" => Func2(self::Func2::Min),
784        "pmod" => Func2(self::Func2::Pmod),
785        // environment variables
786        "arcrad" => EnvVar(self::EnvVar::Arcrad),
787        "arrowht" => EnvVar(self::EnvVar::Arrowht),
788        "arrowwid" => EnvVar(self::EnvVar::Arrowwid),
789        "boxht" => EnvVar(self::EnvVar::Boxht),
790        "boxrad" => EnvVar(self::EnvVar::Boxrad),
791        "boxwid" => EnvVar(self::EnvVar::Boxwid),
792        "circlerad" => EnvVar(self::EnvVar::Circlerad),
793        "dashwid" => EnvVar(self::EnvVar::Dashwid),
794        "ellipseht" => EnvVar(self::EnvVar::Ellipseht),
795        "ellipsewid" => EnvVar(self::EnvVar::Ellipsewid),
796        "lineht" => EnvVar(self::EnvVar::Lineht),
797        "linewid" => EnvVar(self::EnvVar::Linewid),
798        "moveht" => EnvVar(self::EnvVar::Moveht),
799        "movewid" => EnvVar(self::EnvVar::Movewid),
800        "textht" => EnvVar(self::EnvVar::Textht),
801        "textoffset" => EnvVar(self::EnvVar::Textoffset),
802        "textwid" => EnvVar(self::EnvVar::Textwid),
803        "arrowhead" => EnvVar(self::EnvVar::Arrowhead),
804        "fillval" => EnvVar(self::EnvVar::Fillval),
805        "linethick" => EnvVar(self::EnvVar::Linethick),
806        "margin" => EnvVar(self::EnvVar::Margin),
807        "topmargin" => EnvVar(self::EnvVar::Topmargin),
808        "rightmargin" => EnvVar(self::EnvVar::Rightmargin),
809        "bottommargin" => EnvVar(self::EnvVar::Bottommargin),
810        "leftmargin" => EnvVar(self::EnvVar::Leftmargin),
811        "maxpsht" => EnvVar(self::EnvVar::Maxpsht),
812        "maxpswid" => EnvVar(self::EnvVar::Maxpswid),
813        "scale" => EnvVar(self::EnvVar::Scale),
814        "texlabels" => EnvVar(self::EnvVar::Texlabels),
815        "dotrad" => EnvVar(self::EnvVar::Dotrad),
816        // identifier / label
817        _ => {
818            if w.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
819                Label(w.to_string())
820            } else {
821                Name(w.to_string())
822            }
823        }
824    }
825}
826
827/// Classify the word following a `.`. Returns `None` if it is not a dotted
828/// keyword (so the caller emits a bare `.`).
829fn dot_keyword(w: &str) -> Option<Token> {
830    use Token::*;
831    let t = match w {
832        "PS" => DotPS,
833        "PE" => DotPE,
834        "x" => DotX,
835        "y" => DotY,
836        // compass corners
837        "ne" => Corner(self::Corner::Ne),
838        "se" => Corner(self::Corner::Se),
839        "nw" => Corner(self::Corner::Nw),
840        "sw" => Corner(self::Corner::Sw),
841        "n" | "t" | "top" | "north" => Corner(self::Corner::N),
842        "s" | "b" | "bot" | "bottom" | "south" => Corner(self::Corner::S),
843        "e" | "r" | "east" | "right" => Corner(self::Corner::E),
844        "w" | "l" | "west" | "left" => Corner(self::Corner::W),
845        "start" => Corner(self::Corner::Start),
846        "end" => Corner(self::Corner::End),
847        "c" | "center" | "centre" => Corner(self::Corner::Center),
848        // dotted attribute accessors
849        "ht" | "height" => Param(self::Param::Height),
850        "wid" | "width" => Param(self::Param::Width),
851        "rad" | "radius" => Param(self::Param::Radius),
852        "diam" | "diameter" => Param(self::Param::Diameter),
853        "thick" | "thickness" => Param(self::Param::Thickness),
854        "len" | "length" => Param(self::Param::Length),
855        _ => return None,
856    };
857    Some(t)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    fn toks(src: &str) -> Vec<Token> {
865        lex(src).unwrap().into_iter().map(|s| s.tok).collect()
866    }
867
868    #[test]
869    fn basic_box() {
870        assert_eq!(
871            toks("box \"hi\""),
872            vec![Token::Prim(Prim::Box), Token::Str("hi".into()), Token::Eof]
873        );
874    }
875
876    #[test]
877    fn label_vs_name() {
878        assert_eq!(
879            toks("Start: boxwid"),
880            vec![
881                Token::Label("Start".into()),
882                Token::Colon,
883                Token::EnvVar(EnvVar::Boxwid),
884                Token::Eof
885            ]
886        );
887        // a lower-initial non-keyword is a Name
888        assert_eq!(toks("myvar"), vec![Token::Name("myvar".into()), Token::Eof]);
889    }
890
891    #[test]
892    fn numbers() {
893        assert_eq!(toks("0.5"), vec![Token::Float(0.5), Token::Eof]);
894        assert_eq!(toks(".25"), vec![Token::Float(0.25), Token::Eof]);
895        assert_eq!(toks("1e3"), vec![Token::Float(1000.0), Token::Eof]);
896        assert_eq!(toks("2.5e-1"), vec![Token::Float(0.25), Token::Eof]);
897    }
898
899    #[test]
900    fn rejects_non_finite_numbers() {
901        let err = lex("box wid 1e999").unwrap_err();
902        assert!(err.msg.contains("not finite"), "{err}");
903    }
904
905    #[test]
906    fn operators_and_arrows() {
907        assert_eq!(
908            toks("a := b <= c -> d <- e <-> f"),
909            vec![
910                Token::Name("a".into()),
911                Token::ColonEq,
912                Token::Name("b".into()),
913                Token::Le,
914                Token::Name("c".into()),
915                Token::Arrow(Arrow::Right),
916                Token::Name("d".into()),
917                Token::Arrow(Arrow::Left),
918                Token::Name("e".into()),
919                Token::Arrow(Arrow::Double),
920                Token::Name("f".into()),
921                Token::Eof
922            ]
923        );
924    }
925
926    #[test]
927    fn minus_is_not_arrow_without_gt() {
928        assert_eq!(
929            toks("2-3"),
930            vec![
931                Token::Float(2.0),
932                Token::Minus,
933                Token::Float(3.0),
934                Token::Eof
935            ]
936        );
937    }
938
939    #[test]
940    fn compass_and_params() {
941        assert_eq!(
942            toks("last box.ne A.ht .center"),
943            vec![
944                Token::Kw(Kw::Last),
945                Token::Prim(Prim::Box),
946                Token::Corner(Corner::Ne),
947                Token::Label("A".into()),
948                Token::Param(Param::Height),
949                Token::Corner(Corner::Center),
950                Token::Eof
951            ]
952        );
953    }
954
955    #[test]
956    fn dot_then_label() {
957        // `.` not followed by a dotted keyword is a bare Dot, then the word.
958        assert_eq!(
959            toks("B.A"),
960            vec![
961                Token::Label("B".into()),
962                Token::Dot,
963                Token::Label("A".into()),
964                Token::Eof
965            ]
966        );
967    }
968
969    #[test]
970    fn statement_separators_and_comments() {
971        assert_eq!(
972            toks("box; circle # a comment\narc"),
973            vec![
974                Token::Prim(Prim::Box),
975                Token::Newline,
976                Token::Prim(Prim::Circle),
977                Token::Newline,
978                Token::Prim(Prim::Arc),
979                Token::Eof
980            ]
981        );
982    }
983
984    #[test]
985    fn shell_commands_skip_raw_line_tail() {
986        assert_eq!(
987            toks("sh \"echo -n \\\"print \\\\\\\"\\\" > $1_prow\"\nbox"),
988            vec![
989                Token::Kw(Kw::Sh),
990                Token::Newline,
991                Token::Prim(Prim::Box),
992                Token::Eof
993            ]
994        );
995        assert_eq!(
996            toks("command \\foo $bad \"unterminated\ncircle"),
997            vec![
998                Token::Kw(Kw::Command),
999                Token::Newline,
1000                Token::Prim(Prim::Circle),
1001                Token::Eof
1002            ]
1003        );
1004        assert_eq!(
1005            toks("sh \"sed something \\\n  > tmp\"\narc"),
1006            vec![
1007                Token::Kw(Kw::Sh),
1008                Token::Newline,
1009                Token::Prim(Prim::Arc),
1010                Token::Eof
1011            ]
1012        );
1013        assert_eq!(
1014            toks("sh \"rm\";}\ncommand sprintf(\"x\", y) }\n"),
1015            vec![
1016                Token::Kw(Kw::Sh),
1017                Token::Newline,
1018                Token::RightBrace,
1019                Token::Newline,
1020                Token::Kw(Kw::Command),
1021                Token::RightBrace,
1022                Token::Newline,
1023                Token::Eof
1024            ]
1025        );
1026        assert_eq!(
1027            toks("sh \"echo -n \\\"print \\\\\"\\\" > $1_prow\"\nbox"),
1028            vec![
1029                Token::Kw(Kw::Sh),
1030                Token::Newline,
1031                Token::Prim(Prim::Box),
1032                Token::Eof
1033            ]
1034        );
1035    }
1036
1037    #[test]
1038    fn line_continuation() {
1039        assert_eq!(
1040            toks("box \\\n  wid 2"),
1041            vec![
1042                Token::Prim(Prim::Box),
1043                Token::Kw(Kw::Wid),
1044                Token::Float(2.0),
1045                Token::Eof
1046            ]
1047        );
1048    }
1049
1050    #[test]
1051    fn ps_pe_and_block() {
1052        assert_eq!(
1053            toks(".PS\nbox\n.PE"),
1054            vec![
1055                Token::DotPS,
1056                Token::Newline,
1057                Token::Prim(Prim::Box),
1058                Token::Newline,
1059                Token::DotPE,
1060                Token::Eof
1061            ]
1062        );
1063        assert_eq!(toks("[]"), vec![Token::Block, Token::Eof]);
1064        assert_eq!(
1065            toks("[ box ]"),
1066            vec![
1067                Token::LeftBrack,
1068                Token::Prim(Prim::Box),
1069                Token::RightBrack,
1070                Token::Eof
1071            ]
1072        );
1073    }
1074
1075    #[test]
1076    fn macro_arg() {
1077        assert_eq!(
1078            toks("box $1"),
1079            vec![Token::Prim(Prim::Box), Token::Arg(1), Token::Eof]
1080        );
1081    }
1082
1083    #[test]
1084    fn func_and_envvar() {
1085        assert_eq!(
1086            toks("sqrt(2) atan2 scale margin topmargin"),
1087            vec![
1088                Token::Func1(Func1::Sqrt),
1089                Token::Lparen,
1090                Token::Float(2.0),
1091                Token::Rparen,
1092                Token::Func2(Func2::Atan2),
1093                Token::EnvVar(EnvVar::Scale),
1094                Token::EnvVar(EnvVar::Margin),
1095                Token::EnvVar(EnvVar::Topmargin),
1096                Token::Eof
1097            ]
1098        );
1099    }
1100
1101    #[test]
1102    fn newline_after_then_is_continuation() {
1103        // `then` ⏎ continues a path (no statement break); a normal newline does not.
1104        assert_eq!(
1105            toks("line right then\nup"),
1106            vec![
1107                Token::Prim(Prim::Line),
1108                Token::Dir(Dir::Right),
1109                Token::Kw(Kw::Then),
1110                Token::Dir(Dir::Up),
1111                Token::Eof,
1112            ]
1113        );
1114        assert_eq!(
1115            toks("box\nup"),
1116            vec![
1117                Token::Prim(Prim::Box),
1118                Token::Newline,
1119                Token::Dir(Dir::Up),
1120                Token::Eof,
1121            ]
1122        );
1123    }
1124
1125    #[test]
1126    fn newline_before_then_is_continuation() {
1127        // a path may also wrap *before* `then` (`… right\nthen up`).
1128        assert_eq!(
1129            toks("line right\nthen up"),
1130            vec![
1131                Token::Prim(Prim::Line),
1132                Token::Dir(Dir::Right),
1133                Token::Kw(Kw::Then),
1134                Token::Dir(Dir::Up),
1135                Token::Eof,
1136            ]
1137        );
1138    }
1139
1140    #[test]
1141    fn dollar_and_backslash_are_literal_text() {
1142        // `$` not before a digit/`+`, and a non-continuation `\`, are literal
1143        // text (e.g. `$\beta$` LaTeX passed unquoted as a macro argument).
1144        assert_eq!(
1145            toks("$\\beta$"),
1146            vec![
1147                Token::Dollar,
1148                Token::Backslash,
1149                Token::Name("beta".into()),
1150                Token::Dollar,
1151                Token::Eof,
1152            ]
1153        );
1154        // `$1` is still a macro argument.
1155        assert_eq!(toks("$1"), vec![Token::Arg(1), Token::Eof]);
1156    }
1157}