Skip to main content

workshop_rs/frontend/
parser.rs

1//! Native localized Workshop parser.
2//!
3//! Parses vanilla Workshop text directly into validated, locale-independent
4//! the canonical public [`Program`]. Localized actions, values, events, enums, and structural
5//! keywords resolve through the canonical catalog; malformed input,
6//! unknown spellings, and recognized-but-unsupported constructs are reported
7//! as distinct structured diagnostics with source spans.
8
9pub(crate) use std::collections::HashMap;
10use std::sync::OnceLock;
11
12pub(crate) use crate::core::signatures::ExpectedDomain;
13pub(crate) use crate::core::source::{Position, SourceFile, Span};
14use crate::program::Program;
15pub(crate) use crate::settings::table::KeyKind;
16pub(crate) use crate::settings::{PathPart, table};
17pub(crate) use crate::settings::{Settings, SettingsListElement, SettingsNode};
18pub(crate) use crate::wir::{
19    self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode,
20};
21
22pub(crate) use super::lexer::{Token, TokenKind, tokenize};
23pub(crate) use crate::catalog::{Catalog, Kind, Locale};
24pub(crate) use crate::core::error::{Result, WorkshopError};
25
26/// Where action parsing stopped.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(crate) enum Stop {
29    /// The enclosing `}` was consumed.
30    SectionClosed,
31    /// The `End` keyword is next (not consumed).
32    End,
33    /// The `Else If` keyword is next (not consumed).
34    ElseIf,
35    /// The `Else` keyword is next (not consumed).
36    Else,
37}
38
39pub(crate) enum AssignmentOperator {
40    Set,
41    Modify(ModifyOp),
42}
43
44/// Parse localized Workshop text into Workshop IR using the catalog's
45/// canonical call-signature context. Ambiguous bare enum members resolve when
46/// their enclosing call pins one matching domain; unpinned ambiguity remains a
47/// structured canonical value. See [`parse_with_context`] when a
48/// consumer needs to provide additional signature context.
49pub fn parse(input: &str, catalog: &Catalog, locale: &Locale) -> Result<Program> {
50    parse_with_context(input, catalog, locale, catalog)
51}
52
53#[cfg(test)]
54pub(crate) fn parse_wir(input: &str, catalog: &Catalog, locale: &Locale) -> Result<wir::Program> {
55    parse_wir_with_context(input, catalog, locale, catalog)
56}
57
58/// Compatibility entry point for crate-internal storage tests and migration
59/// checks. Ordinary consumers should use [`parse`], which returns [`Program`].
60/// Parse localized Workshop text into Workshop IR, resolving ambiguous bare
61/// enum members from the enclosing call's canonical signature context (#111).
62///
63/// When a bare member spelling matches several enum domains, the parser asks
64/// [`ExpectedDomain::expected_domain`] for the domain the enclosing call's
65/// signature expects at that argument position; the member resolves only when
66/// that expected domain is one of the matching domains (i.e. the signature
67/// pins exactly one). Without a pin all matching candidates are preserved.
68pub fn parse_with_context(
69    input: &str,
70    catalog: &Catalog,
71    locale: &Locale,
72    context: &dyn ExpectedDomain,
73) -> Result<Program> {
74    parse_wir_with_context(input, catalog, locale, context).and_then(Program::from_wir)
75}
76
77pub(crate) fn parse_wir_with_context(
78    input: &str,
79    catalog: &Catalog,
80    locale: &Locale,
81    context: &dyn ExpectedDomain,
82) -> Result<wir::Program> {
83    let tokens = tokenize(input).map_err(|error| {
84        WorkshopError::malformed(error.message, Some(synthetic_span(error.position)))
85    })?;
86    ParseContext {
87        tokens,
88        pos: 0,
89        source: input,
90        catalog,
91        locale: locale.clone(),
92        context,
93        expected_domain: None,
94        condition_pending: false,
95        in_condition: false,
96        call_stack: Vec::new(),
97        target: wir::Program::default(),
98        globals: HashMap::new(),
99        players: HashMap::new(),
100        subroutines: HashMap::new(),
101        next_global_index: 0,
102        next_player_index: 0,
103    }
104    .program()
105}
106
107/// A synthetic single-position span (used before a file registry exists).
108pub(crate) fn synthetic_span(position: Position) -> Span {
109    Span::new(crate::core::ids::Id::from_index(0), position, position)
110}
111
112pub(crate) struct ParseContext<'a> {
113    pub(crate) tokens: Vec<Token>,
114    pub(crate) pos: usize,
115    pub(crate) source: &'a str,
116    pub(crate) catalog: &'a Catalog,
117    pub(crate) locale: Locale,
118    /// Canonical signature context (#111): supplies the expected enum domain
119    /// for the call argument currently being parsed.
120    pub(crate) context: &'a dyn ExpectedDomain,
121    /// The expected enum domain for the value currently being parsed, set by
122    /// [`ParseContext::value_args`] from the enclosing call's signature.
123    pub(crate) expected_domain: Option<&'a str>,
124    /// Marks the next [`ParseContext::value`] call as a rule-level condition
125    /// (`conditions` entries, `If`/`Else If`/`While` conditions) so an action
126    /// call there is diagnosed as an action used as a condition (#340).
127    pub(crate) condition_pending: bool,
128    /// Whether the value currently being parsed is that top-level condition
129    /// expression; nested arguments always parse as ordinary values.
130    pub(crate) in_condition: bool,
131    pub(crate) call_stack: Vec<String>,
132    pub(crate) target: wir::Program,
133    pub(crate) globals: HashMap<String, wir::GlobalVarId>,
134    pub(crate) players: HashMap<String, wir::PlayerVarId>,
135    pub(crate) subroutines: HashMap<String, wir::SubroutineId>,
136    pub(crate) next_global_index: u32,
137    pub(crate) next_player_index: u32,
138}
139
140impl<'a> ParseContext<'a> {
141    /// Run `f` with the signature-pinned expected domain temporarily replaced,
142    /// restoring the enclosing domain afterwards (#111).
143    pub(crate) fn with_domain<T>(
144        &mut self,
145        domain: Option<&'a str>,
146        f: impl FnOnce(&mut Self) -> Result<T>,
147    ) -> Result<T> {
148        let saved = self.expected_domain;
149        self.expected_domain = domain;
150        let result = f(self);
151        self.expected_domain = saved;
152        result
153    }
154
155    /// Parse a value while `domain` is in effect (or `None` for value
156    /// positions a signature must not pin).
157    pub(crate) fn value_in_domain(&mut self, domain: Option<&'a str>) -> Result<wir::ValueId> {
158        self.with_domain(domain, |p| p.value())
159    }
160
161    pub(crate) fn resolve_entry(
162        &self,
163        kind: Kind,
164        spelling: &str,
165    ) -> Option<&'a crate::catalog::CatalogEntry> {
166        self.catalog
167            .resolve(kind, &self.locale, spelling)
168            .or_else(|| {
169                if self.locale != *self.catalog.primary_locale() {
170                    self.catalog
171                        .resolve(kind, self.catalog.primary_locale(), spelling)
172                } else {
173                    None
174                }
175            })
176    }
177
178    pub(crate) fn canonical_keyword(&self, spelling: &str) -> String {
179        self.catalog
180            .resolve(Kind::Structural, &self.locale, spelling)
181            .or_else(|| {
182                (self.locale != *self.catalog.primary_locale()).then(|| {
183                    self.catalog
184                        .resolve(Kind::Structural, self.catalog.primary_locale(), spelling)
185                })?
186            })
187            .map(|entry| entry.id.clone())
188            .unwrap_or_else(|| canonical_keyword(spelling).to_string())
189    }
190
191    pub(crate) fn line_has_assignment(&self) -> bool {
192        let tokens: Vec<_> = self.tokens[self.pos..]
193            .iter()
194            .take_while(|token| !matches!(token.kind, TokenKind::Semi | TokenKind::RBrace))
195            .collect();
196        tokens.iter().any(|token| {
197            matches!(&token.kind, TokenKind::Op(op) if matches!(op.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%="))
198        }) || tokens.windows(2).any(|window| {
199            matches!(&window[0].kind, TokenKind::Word(_))
200                && matches!(&window[1].kind, TokenKind::Op(op) if op == "=")
201        })
202    }
203
204    /// Read the maximal phrase of consecutive words (space-joined). Phrases
205    /// may span lines because long Workshop action arguments wrap mid-phrase.
206    pub(crate) fn phrase(&mut self) -> Result<(String, Position, Position)> {
207        let mut words = Vec::new();
208        let (start, mut end) = match self.peek() {
209            Some(Token {
210                kind: TokenKind::Word(word),
211                start,
212                end,
213            }) => {
214                words.push(word.clone());
215                (start, end)
216            }
217            Some(Token {
218                kind: TokenKind::Number { text, .. },
219                start,
220                end,
221            }) => {
222                words.push(text.clone());
223                (start, end)
224            }
225            Some(token) => return Err(self.malformed("expected an identifier", &token)),
226            None => return Err(self.malformed("expected an identifier", self.eof())),
227        };
228        self.pos += 1;
229        while let Some(token) = self.peek() {
230            match token {
231                Token {
232                    kind: TokenKind::Word(word),
233                    end: word_end,
234                    ..
235                } => {
236                    if matches!(
237                        self.peek_at(1).map(|token| token.kind),
238                        Some(TokenKind::Op(equal)) if equal == "="
239                    ) {
240                        break;
241                    }
242                    words.push(word.clone());
243                    end = word_end;
244                    self.pos += 1;
245                }
246                // Enum members embed numbers (`Team 2`, `Ability 2`); the
247                // lexer splits them from the word, so phrases join Number
248                // tokens too (#119 reference evidence).
249                Token {
250                    kind: TokenKind::Number { text, .. },
251                    end: number_end,
252                    ..
253                } => {
254                    words.push(text.clone());
255                    end = number_end;
256                    self.pos += 1;
257                }
258                _ => break,
259            }
260        }
261        Ok((words.join(" "), start, end))
262    }
263
264    /// Read a single-line phrase (stops at a line boundary). Used for names
265    /// that are structurally one per line, such as variable declarations.
266    pub(crate) fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
267        self.phrase_on_line_impl(false)
268    }
269
270    /// Read a settings list element name: adjacent tokens stay joined and
271    /// `:` is part of the name, preserving localized spellings such as
272    /// `66号公路` and `Watchpoint: Gibraltar`.
273    pub(crate) fn settings_list_name(&mut self) -> Result<(String, Position, Position)> {
274        self.phrase_on_line_impl(true)
275    }
276
277    fn phrase_on_line_impl(&mut self, list_name: bool) -> Result<(String, Position, Position)> {
278        let mut words = Vec::new();
279        let (start, mut end, line) = match self.peek() {
280            Some(Token {
281                kind: TokenKind::Word(word),
282                start,
283                end,
284            }) => {
285                words.push(word.clone());
286                (start, end, start.line)
287            }
288            Some(Token {
289                kind: TokenKind::Number { text, .. },
290                start,
291                end,
292            }) => {
293                words.push(text.clone());
294                (start, end, start.line)
295            }
296            Some(token) => return Err(self.malformed("expected an identifier", &token)),
297            None => return Err(self.malformed("expected an identifier", self.eof())),
298        };
299        self.pos += 1;
300        while let Some(token) = self.peek() {
301            let (word, word_start, word_end) = match token {
302                Token {
303                    kind: TokenKind::Word(word),
304                    start,
305                    end,
306                } => (word, start, end),
307                Token {
308                    kind: TokenKind::Number { text, .. },
309                    start,
310                    end,
311                } => (text, start, end),
312                Token {
313                    kind: TokenKind::Dot,
314                    start,
315                    end,
316                } => (".".to_string(), start, end),
317                Token {
318                    kind: TokenKind::Op(op),
319                    start,
320                    end,
321                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
322                Token {
323                    kind: TokenKind::Colon,
324                    start,
325                    end,
326                } if list_name => (":".to_string(), start, end),
327                _ => break,
328            };
329            if word_start.line != line {
330                break;
331            }
332            if list_name && word_start == end {
333                words
334                    .last_mut()
335                    .expect("phrase has a first token")
336                    .push_str(&word);
337            } else {
338                words.push(word);
339            }
340            end = word_end;
341            self.pos += 1;
342        }
343        let joined = words
344            .join(" ")
345            .replace(" .", ".")
346            .replace(". ", ".")
347            .replace(" %", "%");
348        // Canonical spellings keep the spacing around ':' (e.g. fr-FR
349        // "Observatoire : Gibraltar"); list names must not collapse it.
350        let joined = if list_name {
351            joined
352        } else {
353            joined.replace(" : ", ":")
354        };
355        Ok((joined, start, end))
356    }
357
358    pub(crate) fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
359        let (mut phrase, start, mut end) = self.phrase_on_line()?;
360        if matches!(
361            self.peek(),
362            Some(Token {
363                kind: TokenKind::Colon,
364                ..
365            })
366        ) {
367            let colon_pos = self.pos;
368            self.next();
369            let (rest, _, rest_end) = self.phrase_on_line()?;
370            if matches!(
371                self.peek(),
372                Some(Token {
373                    kind: TokenKind::LBrace,
374                    ..
375                })
376            ) {
377                phrase.push(':');
378                phrase.push(' ');
379                phrase.push_str(&rest);
380                end = rest_end;
381            } else {
382                self.pos = colon_pos;
383            }
384        }
385        Ok((phrase, start, end))
386    }
387
388    pub(crate) fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
389        let first = self
390            .peek()
391            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
392        let start = first.start;
393        let line = first.start.line;
394        let mut end = first.end;
395        let mut parts = Vec::new();
396        while let Some(token) = self.peek() {
397            if token.start.line != line
398                || matches!(token.kind, TokenKind::RParen | TokenKind::Comma)
399            {
400                break;
401            }
402            self.pos += 1;
403            end = token.end;
404            parts.push(raw_token_text(&token.kind));
405        }
406        if parts.is_empty() {
407            return Err(self.malformed("expected an enum member", &first));
408        }
409        Ok((
410            parts
411                .join(" ")
412                .replace(" : ", ":")
413                .replace(" .", ".")
414                .replace(". ", "."),
415            start,
416            end,
417        ))
418    }
419
420    /// Read a text line (tokens until `;`), joining words and dashes into
421    /// the literal text, and consume the terminating `;`. Returns the text
422    /// and the span covering the line's content tokens.
423    pub(crate) fn line_text(&mut self) -> Result<(String, Option<Span>)> {
424        let mut parts = Vec::new();
425        let mut start = None;
426        let mut end = None;
427        loop {
428            match self.peek() {
429                Some(Token {
430                    kind: TokenKind::Semi,
431                    ..
432                }) => {
433                    self.pos += 1;
434                    break;
435                }
436                Some(token) => {
437                    start.get_or_insert(token.start);
438                    end = Some(token.end);
439                    match &token.kind {
440                        TokenKind::Word(word) => parts.push(word.clone()),
441                        TokenKind::Op(op) if op == "-" => parts.push("-".to_string()),
442                        TokenKind::Number { value, .. } => parts.push(value.to_string()),
443                        TokenKind::Dot => parts.push(".".to_string()),
444                        TokenKind::Colon => parts.push(":".to_string()),
445                        _ => return Err(self.malformed("expected a text line", &token)),
446                    }
447                    self.pos += 1;
448                }
449                None => {
450                    return Err(self.malformed("unexpected end of input in line", self.eof()));
451                }
452            }
453        }
454        Ok((
455            parts
456                .join(" ")
457                .replace(" .", ".")
458                .replace(". ", ".")
459                .replace(" : ", ":"),
460            start
461                .zip(end)
462                .map(|(start, end)| Span::new(self.file(), start, end)),
463        ))
464    }
465
466    /// Consume a known keyword phrase, verifying its spelling.
467    pub(crate) fn consume_phrase(&mut self, expected: &str) -> Result<()> {
468        let (phrase, _, _) = self.phrase()?;
469        if phrase != expected {
470            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
471        }
472        Ok(())
473    }
474
475    /// The spellings a structural keyword accepts here: catalog aliases for
476    /// the active and primary locales, plus the hardcoded fallback keys that
477    /// resolve to the same id in the primary locale.
478    fn structural_spellings(&self, id: &str) -> Vec<String> {
479        let mut spellings = Vec::new();
480        if let Some(entry) = self.catalog.entry(Kind::Structural, id) {
481            spellings.extend(entry.spellings(&self.locale).iter().cloned());
482            let primary = self.catalog.primary_locale();
483            if self.locale != *primary {
484                spellings.extend(entry.spellings(primary).iter().cloned());
485            }
486        }
487        for (spelling, english) in structural_keyword_map() {
488            if self
489                .catalog
490                .resolve(Kind::Structural, self.catalog.primary_locale(), english)
491                .is_some_and(|entry| entry.id == id)
492            {
493                spellings.push(spelling.clone());
494            }
495        }
496        spellings
497    }
498
499    /// How many tokens a structural spelling spans at the cursor: words and
500    /// numbers compare case-insensitively, a trailing `.` needs a Dot token.
501    fn match_spelling_tokens(&self, spelling: &str) -> Option<usize> {
502        let mut offset = 0;
503        for piece in spelling.split(' ') {
504            let (word, dotted) = match piece.strip_suffix('.') {
505                Some(word) => (word, true),
506                None => (piece, false),
507            };
508            if !word.is_empty() {
509                match self.peek_at(offset).map(|token| token.kind) {
510                    Some(TokenKind::Word(ref candidate)) if table::names_eq(candidate, word) => {}
511                    Some(TokenKind::Number { ref text, .. }) if text == word => {}
512                    _ => return None,
513                }
514                offset += 1;
515            }
516            if dotted {
517                match self.peek_at(offset).map(|token| token.kind) {
518                    Some(TokenKind::Dot) => offset += 1,
519                    _ => return None,
520                }
521            }
522        }
523        (offset > 0).then_some(offset)
524    }
525
526    /// Consume the longest structural keyword prefix among `ids` at the
527    /// cursor, returning its canonical id. Multi-word and dotted spellings
528    /// (tr `devre dışı`, pl `wył.`, fr `de joueur`) resolve like single-word
529    /// aliases; nothing is consumed on a miss.
530    pub(crate) fn keyword_prefix(&mut self, ids: &[&str]) -> Option<String> {
531        let mut best: Option<(String, usize)> = None;
532        for id in ids {
533            for spelling in self.structural_spellings(id) {
534                if let Some(len) = self.match_spelling_tokens(&spelling) {
535                    if best.as_ref().is_none_or(|(_, length)| len > *length) {
536                        best = Some(((*id).to_string(), len));
537                    }
538                }
539            }
540        }
541        if let Some((id, len)) = best {
542            self.pos += len;
543            Some(id)
544        } else {
545            None
546        }
547    }
548
549    /// Consume one structural keyword by canonical id, verifying spelling.
550    pub(crate) fn consume_keyword(&mut self, id: &str) -> Result<()> {
551        if self.keyword_prefix(&[id]).is_some() {
552            return Ok(());
553        }
554        match self.peek() {
555            Some(token) => Err(self.malformed(&format!("expected '{id}'"), &token)),
556            None => Err(self.malformed(&format!("expected '{id}'"), self.eof())),
557        }
558    }
559
560    pub(crate) fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
561        let start = self.peek().map(|token| token.start);
562        if self.keyword_prefix(&[expected]).is_some() {
563            return Ok(start.unwrap_or_else(|| self.previous().start));
564        }
565        match self.next() {
566            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
567            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
568        }
569    }
570
571    pub(crate) fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
572        match self.next() {
573            Some(token) if token.kind == kind => Ok(()),
574            Some(token) => Err(self.malformed(message, &token)),
575            None => Err(self.malformed(message, self.eof())),
576        }
577    }
578
579    /// Expect the `;` terminating a statement. When the token found instead
580    /// sits on a later line, the missing `;` is reported at the end of the
581    /// statement it should have closed rather than at that unrelated token
582    /// (#340); a same-line offender keeps pointing at the token itself.
583    pub(crate) fn expect_semi(&mut self, message: &str) -> Result<()> {
584        match self.peek() {
585            Some(Token {
586                kind: TokenKind::Semi,
587                ..
588            }) => {
589                self.pos += 1;
590                Ok(())
591            }
592            Some(token) if token.start.line > self.previous().end.line => {
593                let end = self.previous().end;
594                Err(WorkshopError::malformed(
595                    message.to_string(),
596                    Some(Span::new(self.file(), end, end)),
597                ))
598            }
599            Some(token) => Err(self.malformed(message, &token)),
600            None => Err(self.malformed(message, self.eof())),
601        }
602    }
603
604    pub(crate) fn expect_string(&mut self, message: &str) -> Result<String> {
605        match self.next() {
606            Some(Token {
607                kind: TokenKind::String(content),
608                ..
609            }) => Ok(content),
610            Some(token) => Err(self.malformed(message, &token)),
611            None => Err(self.malformed(message, self.eof())),
612        }
613    }
614
615    pub(crate) fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
616        WorkshopError::malformed(
617            message.to_string(),
618            Some(Span::new(self.file(), token.start, token.end)),
619        )
620    }
621
622    pub(crate) fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
623        WorkshopError::unknown(kind, spelling.to_string(), self.locale.clone(), None)
624    }
625
626    /// Spellings of `kind` the parse surface accepts under `self.locale`:
627    /// the locale's own spellings plus, when `include_primary`, the primary
628    /// locale's (the fallback [`ParseContext::resolve_entry`] accepts).
629    pub(crate) fn accepted_spellings(
630        &self,
631        kind: Kind,
632        include_primary: bool,
633    ) -> impl Iterator<Item = &str> {
634        let catalog = self.catalog;
635        let primary = catalog.primary_locale();
636        let include_primary = include_primary && self.locale != *primary;
637        catalog.entries_of(kind).flat_map(move |entry| {
638            let local: &[String] = entry.spellings(&self.locale);
639            let fallback: &[String] = if include_primary {
640                entry.spellings(primary)
641            } else {
642                &[]
643            };
644            local.iter().chain(fallback).map(String::as_str)
645        })
646    }
647
648    /// Member spellings of `domain` accepted under `self.locale`: the
649    /// locale's own spellings plus, when `include_primary`, the primary
650    /// locale's (the fallback `resolve_enum_member_mixed` accepts).
651    pub(crate) fn accepted_member_spellings<'b>(
652        &'b self,
653        domain: &'b str,
654        include_primary: bool,
655    ) -> impl Iterator<Item = &'b str> {
656        let catalog = self.catalog;
657        let primary = catalog.primary_locale();
658        let include_primary = include_primary && self.locale != *primary;
659        catalog
660            .enum_domain(domain)
661            .into_iter()
662            .flat_map(move |domain| {
663                domain.members.iter().flat_map(move |member| {
664                    let local: &[String] = member.spellings(&self.locale);
665                    let fallback: &[String] = if include_primary {
666                        member.spellings(primary)
667                    } else {
668                        &[]
669                    };
670                    local.iter().chain(fallback).map(String::as_str)
671                })
672            })
673    }
674
675    /// The nearest accepted `kind` spellings for a rejected parse spelling.
676    pub(crate) fn spelling_candidates(
677        &self,
678        kind: Kind,
679        spelling: &str,
680        include_primary: bool,
681    ) -> Vec<String> {
682        crate::core::suggest::nearest(
683            spelling,
684            self.accepted_spellings(kind, include_primary),
685            crate::core::suggest::CANDIDATE_LIMIT,
686        )
687    }
688
689    /// The nearest accepted `domain` member spellings for a rejected parse
690    /// spelling.
691    pub(crate) fn member_candidates(
692        &self,
693        domain: &str,
694        spelling: &str,
695        include_primary: bool,
696    ) -> Vec<String> {
697        crate::core::suggest::nearest(
698            spelling,
699            self.accepted_member_spellings(domain, include_primary),
700            crate::core::suggest::CANDIDATE_LIMIT,
701        )
702    }
703
704    pub(crate) fn peek(&self) -> Option<Token> {
705        self.tokens.get(self.pos).cloned()
706    }
707
708    pub(crate) fn peek_at(&self, offset: usize) -> Option<Token> {
709        self.tokens.get(self.pos + offset).cloned()
710    }
711
712    pub(crate) fn next(&mut self) -> Option<Token> {
713        let token = self.tokens.get(self.pos).cloned();
714        if token.is_some() {
715            self.pos += 1;
716        }
717        token
718    }
719
720    pub(crate) fn previous(&self) -> &Token {
721        self.tokens
722            .get(self.pos.saturating_sub(1))
723            .unwrap_or_else(|| self.tokens.last().unwrap())
724    }
725
726    pub(crate) fn previous_span(&self) -> (Position, Position) {
727        let token = self.previous();
728        (token.start, token.end)
729    }
730
731    pub(crate) fn span_here(&self) -> (Position, Position) {
732        let token = self
733            .peek()
734            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
735        (token.start, token.end)
736    }
737
738    pub(crate) fn eof(&self) -> &Token {
739        self.tokens.last().unwrap()
740    }
741
742    pub(crate) fn file(&self) -> crate::core::ids::Id<SourceFile> {
743        crate::core::ids::Id::from_index(0)
744    }
745}
746
747fn structural_keyword_map() -> &'static HashMap<String, String> {
748    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
749    KEYWORDS.get_or_init(|| {
750        serde_json::from_str(include_str!("structural_keywords.json"))
751            .expect("structural keyword data is valid JSON")
752    })
753}
754
755pub(crate) fn canonical_keyword(keyword: &str) -> &str {
756    structural_keyword_map()
757        .get(keyword)
758        .map(String::as_str)
759        .unwrap_or(keyword)
760}
761
762pub(crate) fn raw_token_text(kind: &TokenKind) -> String {
763    match kind {
764        TokenKind::Word(value) => value.clone(),
765        TokenKind::Number { text, .. } => text.clone(),
766        TokenKind::String(value) => format!(
767            "\"{}\"",
768            crate::output::emitter::escape_settings_string(value)
769        ),
770        TokenKind::Op(value) => value.clone(),
771        TokenKind::LParen => "(".to_string(),
772        TokenKind::RParen => ")".to_string(),
773        TokenKind::Comma => ",".to_string(),
774        TokenKind::Semi => ";".to_string(),
775        TokenKind::LBrace => "{".to_string(),
776        TokenKind::RBrace => "}".to_string(),
777        TokenKind::Colon => ":".to_string(),
778        TokenKind::Dot => ".".to_string(),
779        TokenKind::LBracket => "[".to_string(),
780        TokenKind::RBracket => "]".to_string(),
781        TokenKind::Eof => String::new(),
782    }
783}