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        let mut s = String::new();
472        if self.peek() == Some('.') {
473            s.push('0'); // ".5" -> "0.5"
474        }
475        while let Some(c) = self.peek() {
476            if c.is_ascii_digit() {
477                s.push(c);
478                self.bump();
479            } else {
480                break;
481            }
482        }
483        // fractional part: only consume `.` when a digit follows, so a trailing
484        // `.` (e.g. in `box.ne`) stays a separate token.
485        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|d| d.is_ascii_digit()) {
486            s.push('.');
487            self.bump();
488            while let Some(c) = self.peek() {
489                if c.is_ascii_digit() {
490                    s.push(c);
491                    self.bump();
492                } else {
493                    break;
494                }
495            }
496        }
497        // exponent
498        if matches!(self.peek(), Some('e') | Some('E'))
499            && (self.peek_at(1).is_some_and(|d| d.is_ascii_digit())
500                || (matches!(self.peek_at(1), Some('+') | Some('-'))
501                    && self.peek_at(2).is_some_and(|d| d.is_ascii_digit())))
502        {
503            s.push('e');
504            self.bump();
505            if matches!(self.peek(), Some('+') | Some('-')) {
506                s.push(self.bump().unwrap());
507            }
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        // optional inch unit suffix `i`/`I` (pic's base unit is the inch, so the
518        // suffix is informational); don't consume it when it begins an
519        // identifier, e.g. the `in` of a following word.
520        if matches!(self.peek(), Some('i') | Some('I'))
521            && !self
522                .peek_at(1)
523                .is_some_and(|c| c.is_alphanumeric() || c == '_')
524        {
525            self.bump();
526        }
527        match s.parse::<f64>() {
528            Ok(v) if v.is_finite() => Ok(Token::Float(v)),
529            Ok(_) => self.err(format!("number `{s}` is not finite")),
530            Err(_) => self.err(format!("invalid number `{s}`")),
531        }
532    }
533
534    /// Lex a `.`-prefixed token: `.PS/.PE`, compass corners, `.x/.y`, dotted
535    /// attribute accessors, or a bare `.` (the placename separator).
536    fn lex_dot(&mut self) -> Result<Token, LexError> {
537        let save = (self.pos, self.line, self.col);
538        self.bump(); // '.'
539        // read the following word
540        let mut w = String::new();
541        while let Some(c) = self.peek() {
542            if c.is_alphanumeric() || c == '_' {
543                w.push(c);
544                self.bump();
545            } else {
546                break;
547            }
548        }
549        if let Some(tok) = dot_keyword(&w) {
550            Ok(tok)
551        } else {
552            // Not a dotted keyword: emit a bare `.` and rewind so the word is
553            // lexed normally on the next pass.
554            self.pos = save.0 + 1;
555            self.line = save.1;
556            self.col = save.2 + 1;
557            Ok(Token::Dot)
558        }
559    }
560
561    fn lex_word(&mut self) -> Token {
562        let mut w = String::new();
563        while let Some(c) = self.peek() {
564            if c.is_alphanumeric() || c == '_' {
565                w.push(c);
566                self.bump();
567            } else {
568                break;
569            }
570        }
571        word_keyword(&w)
572    }
573
574    fn lex_operator(&mut self) -> Result<Option<Token>, LexError> {
575        let c = self.bump().unwrap();
576        let next = self.peek();
577        let tok = match c {
578            '(' => Token::Lparen,
579            ')' => Token::Rparen,
580            ',' => Token::Comma,
581            '^' => Token::Caret,
582            '{' => Token::LeftBrace,
583            '}' => Token::RightBrace,
584            ']' => Token::RightBrack,
585            '`' => Token::LeftQuote,
586            '\'' => Token::RightQuote,
587            '[' => {
588                if next == Some(']') {
589                    self.bump();
590                    Token::Block
591                } else {
592                    Token::LeftBrack
593                }
594            }
595            ':' => self.two('=', Token::ColonEq, Token::Colon),
596            '=' => self.two('=', Token::EqEq, Token::Eq),
597            '!' => self.two('=', Token::Neq, Token::Not),
598            '+' => self.two('=', Token::PlusEq, Token::Plus),
599            '*' => self.two('=', Token::MultEq, Token::Mult),
600            '/' => self.two('=', Token::DivEq, Token::Div),
601            '%' => self.two('=', Token::RemEq, Token::Percent),
602            '>' => self.two('=', Token::Ge, Token::Gt),
603            '&' => self.two('&', Token::AndAnd, Token::Ampersand),
604            '|' => {
605                if next == Some('|') {
606                    self.bump();
607                    Token::OrOr
608                } else {
609                    return self.err("unexpected `|`");
610                }
611            }
612            '-' => match next {
613                Some('>') => {
614                    self.bump();
615                    Token::Arrow(Arrow::Right)
616                }
617                Some('=') => {
618                    self.bump();
619                    Token::MinusEq
620                }
621                _ => Token::Minus,
622            },
623            '<' => match next {
624                Some('=') => {
625                    self.bump();
626                    Token::Le
627                }
628                Some('-') => {
629                    self.bump();
630                    if self.peek() == Some('>') {
631                        self.bump();
632                        Token::Arrow(Arrow::Double)
633                    } else {
634                        Token::Arrow(Arrow::Left)
635                    }
636                }
637                _ => Token::Lt,
638            },
639            // A `\` that is not a line continuation (handled in the whitespace
640            // skip) is literal text — e.g. a LaTeX command like `\beta` passed
641            // unquoted as a macro argument, as in m4/dpic. (`c` is already
642            // consumed by the `bump()` above.)
643            '\\' => Token::Backslash,
644            other => return self.err(format!("unexpected character `{other}`")),
645        };
646        Ok(Some(tok))
647    }
648
649    /// If the next char is `c`, consume it and return `yes`; otherwise `no`.
650    fn two(&mut self, c: char, yes: Token, no: Token) -> Token {
651        if self.peek() == Some(c) {
652            self.bump();
653            yes
654        } else {
655            no
656        }
657    }
658}
659
660/// Classify a bare word (after the keyword vocabulary). Unknown words become a
661/// [`Token::Label`] (upper-initial) or [`Token::Name`] (otherwise).
662fn word_keyword(w: &str) -> Token {
663    use Token::*;
664    match w {
665        // primitives
666        "box" => Prim(self::Prim::Box),
667        "circle" => Prim(self::Prim::Circle),
668        "ellipse" => Prim(self::Prim::Ellipse),
669        "arc" => Prim(self::Prim::Arc),
670        "line" => Prim(self::Prim::Line),
671        "arrow" => Prim(self::Prim::Arrow),
672        "move" => Prim(self::Prim::Move),
673        "spline" => Prim(self::Prim::Spline),
674        // directions
675        "up" => Dir(self::Dir::Up),
676        "down" => Dir(self::Dir::Down),
677        "right" => Dir(self::Dir::Right),
678        "left" => Dir(self::Dir::Left),
679        // attributes & joiners
680        "height" | "ht" => Kw(self::Kw::Ht),
681        "width" | "wid" => Kw(self::Kw::Wid),
682        "radius" | "rad" => Kw(self::Kw::Rad),
683        "diameter" | "diam" => Kw(self::Kw::Diam),
684        "thickness" | "thick" => Kw(self::Kw::Thick),
685        "scaled" => Kw(self::Kw::Scaled),
686        "from" => Kw(self::Kw::From),
687        "to" => Kw(self::Kw::To),
688        "at" => Kw(self::Kw::At),
689        "with" => Kw(self::Kw::With),
690        "by" => Kw(self::Kw::By),
691        "then" => Kw(self::Kw::Then),
692        "cw" => Kw(self::Kw::Cw),
693        "ccw" => Kw(self::Kw::Ccw),
694        "continue" => Kw(self::Kw::Continue),
695        "chop" => Kw(self::Kw::Chop),
696        "same" => Kw(self::Kw::Same),
697        "of" => Kw(self::Kw::Of),
698        "the" => Kw(self::Kw::The),
699        "way" => Kw(self::Kw::Way),
700        "between" => Kw(self::Kw::Between),
701        "and" => Kw(self::Kw::And),
702        "last" => Kw(self::Kw::Last),
703        "fill" | "filled" => Kw(self::Kw::Fill),
704        "st" | "nd" | "rd" | "th" => Kw(self::Kw::Nth),
705        "Here" => Kw(self::Kw::Here),
706        // bare corner words
707        "top" => Corner(self::Corner::N),
708        "bottom" => Corner(self::Corner::S),
709        "start" => Corner(self::Corner::Start),
710        "end" => Corner(self::Corner::End),
711        // commands / control
712        "print" => Kw(self::Kw::Print),
713        "copy" => Kw(self::Kw::Copy),
714        "reset" => Kw(self::Kw::Reset),
715        "exec" => Kw(self::Kw::Exec),
716        "sh" => Kw(self::Kw::Sh),
717        "command" => Kw(self::Kw::Command),
718        "define" => Kw(self::Kw::Define),
719        "undefine" | "undef" => Kw(self::Kw::Undef),
720        "rand" => Kw(self::Kw::Rand),
721        "if" => Kw(self::Kw::If),
722        "else" => Kw(self::Kw::Else),
723        "for" => Kw(self::Kw::For),
724        "do" => Kw(self::Kw::Do),
725        "sprintf" => Kw(self::Kw::Sprintf),
726        // rpic animation extension
727        "animate" => Kw(self::Kw::Animate),
728        "after" => Kw(self::Kw::After),
729        "delay" => Kw(self::Kw::Delay),
730        // line types
731        "solid" => LineType(self::LineType::Solid),
732        "dotted" => LineType(self::LineType::Dotted),
733        "dashed" => LineType(self::LineType::Dashed),
734        "invis" | "invisible" => LineType(self::LineType::Invis),
735        // color / outline / shade
736        "color" | "colour" | "colored" | "coloured" => Color(self::Color::Colored),
737        "outline" | "outlined" => Color(self::Color::Outlined),
738        "shade" | "shaded" => Color(self::Color::Shaded),
739        // text position
740        "center" | "centre" => TextPos(self::TextPos::Center),
741        "ljust" => TextPos(self::TextPos::Ljust),
742        "rjust" => TextPos(self::TextPos::Rjust),
743        "above" => TextPos(self::TextPos::Above),
744        "below" => TextPos(self::TextPos::Below),
745        // one-arg functions
746        "abs" => Func1(self::Func1::Abs),
747        "acos" => Func1(self::Func1::Acos),
748        "asin" => Func1(self::Func1::Asin),
749        "cos" => Func1(self::Func1::Cos),
750        "exp" => Func1(self::Func1::Exp),
751        "expe" => Func1(self::Func1::Expe),
752        "int" => Func1(self::Func1::Int),
753        "log" => Func1(self::Func1::Log),
754        "loge" => Func1(self::Func1::Loge),
755        "sign" => Func1(self::Func1::Sign),
756        "sin" => Func1(self::Func1::Sin),
757        "sqrt" => Func1(self::Func1::Sqrt),
758        "tan" => Func1(self::Func1::Tan),
759        "floor" => Func1(self::Func1::Floor),
760        // two-arg functions
761        "atan2" => Func2(self::Func2::Atan2),
762        "max" => Func2(self::Func2::Max),
763        "min" => Func2(self::Func2::Min),
764        "pmod" => Func2(self::Func2::Pmod),
765        // environment variables
766        "arcrad" => EnvVar(self::EnvVar::Arcrad),
767        "arrowht" => EnvVar(self::EnvVar::Arrowht),
768        "arrowwid" => EnvVar(self::EnvVar::Arrowwid),
769        "boxht" => EnvVar(self::EnvVar::Boxht),
770        "boxrad" => EnvVar(self::EnvVar::Boxrad),
771        "boxwid" => EnvVar(self::EnvVar::Boxwid),
772        "circlerad" => EnvVar(self::EnvVar::Circlerad),
773        "dashwid" => EnvVar(self::EnvVar::Dashwid),
774        "ellipseht" => EnvVar(self::EnvVar::Ellipseht),
775        "ellipsewid" => EnvVar(self::EnvVar::Ellipsewid),
776        "lineht" => EnvVar(self::EnvVar::Lineht),
777        "linewid" => EnvVar(self::EnvVar::Linewid),
778        "moveht" => EnvVar(self::EnvVar::Moveht),
779        "movewid" => EnvVar(self::EnvVar::Movewid),
780        "textht" => EnvVar(self::EnvVar::Textht),
781        "textoffset" => EnvVar(self::EnvVar::Textoffset),
782        "textwid" => EnvVar(self::EnvVar::Textwid),
783        "arrowhead" => EnvVar(self::EnvVar::Arrowhead),
784        "fillval" => EnvVar(self::EnvVar::Fillval),
785        "linethick" => EnvVar(self::EnvVar::Linethick),
786        "margin" => EnvVar(self::EnvVar::Margin),
787        "topmargin" => EnvVar(self::EnvVar::Topmargin),
788        "rightmargin" => EnvVar(self::EnvVar::Rightmargin),
789        "bottommargin" => EnvVar(self::EnvVar::Bottommargin),
790        "leftmargin" => EnvVar(self::EnvVar::Leftmargin),
791        "maxpsht" => EnvVar(self::EnvVar::Maxpsht),
792        "maxpswid" => EnvVar(self::EnvVar::Maxpswid),
793        "scale" => EnvVar(self::EnvVar::Scale),
794        "texlabels" => EnvVar(self::EnvVar::Texlabels),
795        "dotrad" => EnvVar(self::EnvVar::Dotrad),
796        // identifier / label
797        _ => {
798            if w.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
799                Label(w.to_string())
800            } else {
801                Name(w.to_string())
802            }
803        }
804    }
805}
806
807/// Classify the word following a `.`. Returns `None` if it is not a dotted
808/// keyword (so the caller emits a bare `.`).
809fn dot_keyword(w: &str) -> Option<Token> {
810    use Token::*;
811    let t = match w {
812        "PS" => DotPS,
813        "PE" => DotPE,
814        "x" => DotX,
815        "y" => DotY,
816        // compass corners
817        "ne" => Corner(self::Corner::Ne),
818        "se" => Corner(self::Corner::Se),
819        "nw" => Corner(self::Corner::Nw),
820        "sw" => Corner(self::Corner::Sw),
821        "n" | "t" | "top" | "north" => Corner(self::Corner::N),
822        "s" | "b" | "bot" | "bottom" | "south" => Corner(self::Corner::S),
823        "e" | "r" | "east" | "right" => Corner(self::Corner::E),
824        "w" | "l" | "west" | "left" => Corner(self::Corner::W),
825        "start" => Corner(self::Corner::Start),
826        "end" => Corner(self::Corner::End),
827        "c" | "center" | "centre" => Corner(self::Corner::Center),
828        // dotted attribute accessors
829        "ht" | "height" => Param(self::Param::Height),
830        "wid" | "width" => Param(self::Param::Width),
831        "rad" | "radius" => Param(self::Param::Radius),
832        "diam" | "diameter" => Param(self::Param::Diameter),
833        "thick" | "thickness" => Param(self::Param::Thickness),
834        "len" | "length" => Param(self::Param::Length),
835        _ => return None,
836    };
837    Some(t)
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn toks(src: &str) -> Vec<Token> {
845        lex(src).unwrap().into_iter().map(|s| s.tok).collect()
846    }
847
848    #[test]
849    fn basic_box() {
850        assert_eq!(
851            toks("box \"hi\""),
852            vec![Token::Prim(Prim::Box), Token::Str("hi".into()), Token::Eof]
853        );
854    }
855
856    #[test]
857    fn label_vs_name() {
858        assert_eq!(
859            toks("Start: boxwid"),
860            vec![
861                Token::Label("Start".into()),
862                Token::Colon,
863                Token::EnvVar(EnvVar::Boxwid),
864                Token::Eof
865            ]
866        );
867        // a lower-initial non-keyword is a Name
868        assert_eq!(toks("myvar"), vec![Token::Name("myvar".into()), Token::Eof]);
869    }
870
871    #[test]
872    fn numbers() {
873        assert_eq!(toks("0.5"), vec![Token::Float(0.5), Token::Eof]);
874        assert_eq!(toks(".25"), vec![Token::Float(0.25), Token::Eof]);
875        assert_eq!(toks("1e3"), vec![Token::Float(1000.0), Token::Eof]);
876        assert_eq!(toks("2.5e-1"), vec![Token::Float(0.25), Token::Eof]);
877    }
878
879    #[test]
880    fn rejects_non_finite_numbers() {
881        let err = lex("box wid 1e999").unwrap_err();
882        assert!(err.msg.contains("not finite"), "{err}");
883    }
884
885    #[test]
886    fn operators_and_arrows() {
887        assert_eq!(
888            toks("a := b <= c -> d <- e <-> f"),
889            vec![
890                Token::Name("a".into()),
891                Token::ColonEq,
892                Token::Name("b".into()),
893                Token::Le,
894                Token::Name("c".into()),
895                Token::Arrow(Arrow::Right),
896                Token::Name("d".into()),
897                Token::Arrow(Arrow::Left),
898                Token::Name("e".into()),
899                Token::Arrow(Arrow::Double),
900                Token::Name("f".into()),
901                Token::Eof
902            ]
903        );
904    }
905
906    #[test]
907    fn minus_is_not_arrow_without_gt() {
908        assert_eq!(
909            toks("2-3"),
910            vec![
911                Token::Float(2.0),
912                Token::Minus,
913                Token::Float(3.0),
914                Token::Eof
915            ]
916        );
917    }
918
919    #[test]
920    fn compass_and_params() {
921        assert_eq!(
922            toks("last box.ne A.ht .center"),
923            vec![
924                Token::Kw(Kw::Last),
925                Token::Prim(Prim::Box),
926                Token::Corner(Corner::Ne),
927                Token::Label("A".into()),
928                Token::Param(Param::Height),
929                Token::Corner(Corner::Center),
930                Token::Eof
931            ]
932        );
933    }
934
935    #[test]
936    fn dot_then_label() {
937        // `.` not followed by a dotted keyword is a bare Dot, then the word.
938        assert_eq!(
939            toks("B.A"),
940            vec![
941                Token::Label("B".into()),
942                Token::Dot,
943                Token::Label("A".into()),
944                Token::Eof
945            ]
946        );
947    }
948
949    #[test]
950    fn statement_separators_and_comments() {
951        assert_eq!(
952            toks("box; circle # a comment\narc"),
953            vec![
954                Token::Prim(Prim::Box),
955                Token::Newline,
956                Token::Prim(Prim::Circle),
957                Token::Newline,
958                Token::Prim(Prim::Arc),
959                Token::Eof
960            ]
961        );
962    }
963
964    #[test]
965    fn shell_commands_skip_raw_line_tail() {
966        assert_eq!(
967            toks("sh \"echo -n \\\"print \\\\\\\"\\\" > $1_prow\"\nbox"),
968            vec![
969                Token::Kw(Kw::Sh),
970                Token::Newline,
971                Token::Prim(Prim::Box),
972                Token::Eof
973            ]
974        );
975        assert_eq!(
976            toks("command \\foo $bad \"unterminated\ncircle"),
977            vec![
978                Token::Kw(Kw::Command),
979                Token::Newline,
980                Token::Prim(Prim::Circle),
981                Token::Eof
982            ]
983        );
984        assert_eq!(
985            toks("sh \"sed something \\\n  > tmp\"\narc"),
986            vec![
987                Token::Kw(Kw::Sh),
988                Token::Newline,
989                Token::Prim(Prim::Arc),
990                Token::Eof
991            ]
992        );
993        assert_eq!(
994            toks("sh \"rm\";}\ncommand sprintf(\"x\", y) }\n"),
995            vec![
996                Token::Kw(Kw::Sh),
997                Token::Newline,
998                Token::RightBrace,
999                Token::Newline,
1000                Token::Kw(Kw::Command),
1001                Token::RightBrace,
1002                Token::Newline,
1003                Token::Eof
1004            ]
1005        );
1006        assert_eq!(
1007            toks("sh \"echo -n \\\"print \\\\\"\\\" > $1_prow\"\nbox"),
1008            vec![
1009                Token::Kw(Kw::Sh),
1010                Token::Newline,
1011                Token::Prim(Prim::Box),
1012                Token::Eof
1013            ]
1014        );
1015    }
1016
1017    #[test]
1018    fn line_continuation() {
1019        assert_eq!(
1020            toks("box \\\n  wid 2"),
1021            vec![
1022                Token::Prim(Prim::Box),
1023                Token::Kw(Kw::Wid),
1024                Token::Float(2.0),
1025                Token::Eof
1026            ]
1027        );
1028    }
1029
1030    #[test]
1031    fn ps_pe_and_block() {
1032        assert_eq!(
1033            toks(".PS\nbox\n.PE"),
1034            vec![
1035                Token::DotPS,
1036                Token::Newline,
1037                Token::Prim(Prim::Box),
1038                Token::Newline,
1039                Token::DotPE,
1040                Token::Eof
1041            ]
1042        );
1043        assert_eq!(toks("[]"), vec![Token::Block, Token::Eof]);
1044        assert_eq!(
1045            toks("[ box ]"),
1046            vec![
1047                Token::LeftBrack,
1048                Token::Prim(Prim::Box),
1049                Token::RightBrack,
1050                Token::Eof
1051            ]
1052        );
1053    }
1054
1055    #[test]
1056    fn macro_arg() {
1057        assert_eq!(
1058            toks("box $1"),
1059            vec![Token::Prim(Prim::Box), Token::Arg(1), Token::Eof]
1060        );
1061    }
1062
1063    #[test]
1064    fn func_and_envvar() {
1065        assert_eq!(
1066            toks("sqrt(2) atan2 scale margin topmargin"),
1067            vec![
1068                Token::Func1(Func1::Sqrt),
1069                Token::Lparen,
1070                Token::Float(2.0),
1071                Token::Rparen,
1072                Token::Func2(Func2::Atan2),
1073                Token::EnvVar(EnvVar::Scale),
1074                Token::EnvVar(EnvVar::Margin),
1075                Token::EnvVar(EnvVar::Topmargin),
1076                Token::Eof
1077            ]
1078        );
1079    }
1080
1081    #[test]
1082    fn newline_after_then_is_continuation() {
1083        // `then` ⏎ continues a path (no statement break); a normal newline does not.
1084        assert_eq!(
1085            toks("line right then\nup"),
1086            vec![
1087                Token::Prim(Prim::Line),
1088                Token::Dir(Dir::Right),
1089                Token::Kw(Kw::Then),
1090                Token::Dir(Dir::Up),
1091                Token::Eof,
1092            ]
1093        );
1094        assert_eq!(
1095            toks("box\nup"),
1096            vec![
1097                Token::Prim(Prim::Box),
1098                Token::Newline,
1099                Token::Dir(Dir::Up),
1100                Token::Eof,
1101            ]
1102        );
1103    }
1104
1105    #[test]
1106    fn newline_before_then_is_continuation() {
1107        // a path may also wrap *before* `then` (`… right\nthen up`).
1108        assert_eq!(
1109            toks("line right\nthen up"),
1110            vec![
1111                Token::Prim(Prim::Line),
1112                Token::Dir(Dir::Right),
1113                Token::Kw(Kw::Then),
1114                Token::Dir(Dir::Up),
1115                Token::Eof,
1116            ]
1117        );
1118    }
1119
1120    #[test]
1121    fn dollar_and_backslash_are_literal_text() {
1122        // `$` not before a digit/`+`, and a non-continuation `\`, are literal
1123        // text (e.g. `$\beta$` LaTeX passed unquoted as a macro argument).
1124        assert_eq!(
1125            toks("$\\beta$"),
1126            vec![
1127                Token::Dollar,
1128                Token::Backslash,
1129                Token::Name("beta".into()),
1130                Token::Dollar,
1131                Token::Eof,
1132            ]
1133        );
1134        // `$1` is still a macro argument.
1135        assert_eq!(toks("$1"), vec![Token::Arg(1), Token::Eof]);
1136    }
1137}