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 and numbers, joined
205    /// exactly as written (`1팀`, `Team 2`, #448). Phrases may span lines
206    /// because long Workshop action arguments wrap mid-phrase.
207    pub(crate) fn phrase(&mut self) -> Result<(String, Position, Position)> {
208        let mut parts: Vec<(String, Position, Position)> = Vec::new();
209        let (start, mut end) = match self.peek() {
210            Some(Token {
211                kind: TokenKind::Word(word),
212                start,
213                end,
214            }) => {
215                parts.push((word.clone(), start, end));
216                (start, end)
217            }
218            Some(Token {
219                kind: TokenKind::Number { text, .. },
220                start,
221                end,
222            }) => {
223                parts.push((text.clone(), start, end));
224                (start, end)
225            }
226            Some(token) => return Err(self.malformed("expected an identifier", &token)),
227            None => return Err(self.malformed("expected an identifier", self.eof())),
228        };
229        self.pos += 1;
230        while let Some(token) = self.peek() {
231            match token {
232                Token {
233                    kind: TokenKind::Word(word),
234                    start: word_start,
235                    end: word_end,
236                } => {
237                    if matches!(
238                        self.peek_at(1).map(|token| token.kind),
239                        Some(TokenKind::Op(equal)) if equal == "="
240                    ) {
241                        break;
242                    }
243                    parts.push((word.clone(), word_start, word_end));
244                    end = word_end;
245                    self.pos += 1;
246                }
247                // Enum members embed numbers (`Team 2`, `Ability 2`); the
248                // lexer splits them from the word, so phrases join Number
249                // tokens too (#119 reference evidence).
250                Token {
251                    kind: TokenKind::Number { text, .. },
252                    start: number_start,
253                    end: number_end,
254                } => {
255                    parts.push((text.clone(), number_start, number_end));
256                    end = number_end;
257                    self.pos += 1;
258                }
259                _ => break,
260            }
261        }
262        Ok((join_as_written(&parts), start, end))
263    }
264
265    /// Read a single-line phrase (stops at a line boundary). Used for names
266    /// that are structurally one per line, such as variable declarations.
267    pub(crate) fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
268        self.phrase_on_line_impl(false)
269    }
270
271    /// Read a settings list element name: adjacent tokens stay joined and
272    /// `:` is part of the name, preserving localized spellings such as
273    /// `66号公路` and `Watchpoint: Gibraltar`.
274    pub(crate) fn settings_list_name(&mut self) -> Result<(String, Position, Position)> {
275        self.phrase_on_line_impl(true)
276    }
277
278    fn phrase_on_line_impl(&mut self, list_name: bool) -> Result<(String, Position, Position)> {
279        let mut parts: Vec<(String, Position, Position)> = Vec::new();
280        let (start, mut end, line) = match self.peek() {
281            Some(Token {
282                kind: TokenKind::Word(word),
283                start,
284                end,
285            }) => {
286                parts.push((word.clone(), start, end));
287                (start, end, start.line)
288            }
289            Some(Token {
290                kind: TokenKind::Number { text, .. },
291                start,
292                end,
293            }) => {
294                parts.push((text.clone(), start, end));
295                (start, end, start.line)
296            }
297            Some(token) => return Err(self.malformed("expected an identifier", &token)),
298            None => return Err(self.malformed("expected an identifier", self.eof())),
299        };
300        self.pos += 1;
301        while let Some(token) = self.peek() {
302            let (text, word_start, word_end) = match token {
303                Token {
304                    kind: TokenKind::Word(word),
305                    start,
306                    end,
307                } => (word.clone(), start, end),
308                Token {
309                    kind: TokenKind::Number { text, .. },
310                    start,
311                    end,
312                } => (text.clone(), start, end),
313                Token {
314                    kind: TokenKind::Dot,
315                    start,
316                    end,
317                } => (".".to_string(), start, end),
318                Token {
319                    kind: TokenKind::Op(op),
320                    start,
321                    end,
322                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
323                Token {
324                    kind: TokenKind::Colon,
325                    start,
326                    end,
327                } if list_name => (":".to_string(), start, end),
328                _ => break,
329            };
330            if word_start.line != line {
331                break;
332            }
333            parts.push((text, word_start, word_end));
334            end = word_end;
335            self.pos += 1;
336        }
337        // Adjacent fragments stay joined exactly as written (`1. Takım`,
338        // `500%`, `Watchpoint: Gibraltar`); the candidate/acceptance layer
339        // (`names_eq`, the catalog's normalized index) answers for
340        // whitespace-insensitive spellings (#448).
341        Ok((join_as_written(&parts), start, end))
342    }
343
344    pub(crate) fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
345        let (mut phrase, start, mut end) = self.phrase_on_line()?;
346        if matches!(
347            self.peek(),
348            Some(Token {
349                kind: TokenKind::Colon,
350                ..
351            })
352        ) {
353            let colon_pos = self.pos;
354            self.next();
355            let (rest, _, rest_end) = self.phrase_on_line()?;
356            if matches!(
357                self.peek(),
358                Some(Token {
359                    kind: TokenKind::LBrace,
360                    ..
361                })
362            ) {
363                phrase.push(':');
364                phrase.push(' ');
365                phrase.push_str(&rest);
366                end = rest_end;
367            } else {
368                self.pos = colon_pos;
369            }
370        }
371        Ok((phrase, start, end))
372    }
373
374    /// The member phrase inside `Domain(...)` constructor spellings:
375    /// every token on the line joined exactly as written, so reviewed
376    /// member spellings keep their punctuation and spacing verbatim
377    /// (`Soldat : 76`, `<en>Orisa<en>`, `Orisa Dur! Bağlama Işını`, #448).
378    pub(crate) fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
379        let first = self
380            .peek()
381            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
382        let start = first.start;
383        let line = first.start.line;
384        let mut end = first.end;
385        let mut parts: Vec<(String, Position, Position)> = Vec::new();
386        while let Some(token) = self.peek() {
387            // No declared member spelling contains `(`, so a paren after the
388            // member text belongs to surrounding syntax — the enum reading
389            // must fail so an identically-spelled value call can apply,
390            // e.g. `英雄(英雄(D.Va))` for `hero(Hero.DVA)` (#448).
391            if token.start.line != line
392                || matches!(
393                    token.kind,
394                    TokenKind::RParen | TokenKind::Comma | TokenKind::LParen
395                )
396            {
397                break;
398            }
399            self.pos += 1;
400            end = token.end;
401            parts.push((raw_token_text(&token.kind), token.start, token.end));
402        }
403        if parts.is_empty() {
404            return Err(self.malformed("expected an enum member", &first));
405        }
406        Ok((join_as_written(&parts), start, end))
407    }
408
409    /// Read a text line (tokens until `;`), joining tokens into the literal
410    /// text exactly as written, and consume the terminating `;`. Returns
411    /// the text and the span covering the line's content tokens. Adjacent
412    /// fragments stay joined (`0. Yuva`, `Dur!`); only a real source gap
413    /// inserts a space (#448).
414    pub(crate) fn line_text(&mut self) -> Result<(String, Option<Span>)> {
415        let mut parts: Vec<(String, Position, Position)> = Vec::new();
416        let mut start = None;
417        let mut end = None;
418        loop {
419            match self.peek() {
420                Some(Token {
421                    kind: TokenKind::Semi,
422                    ..
423                }) => {
424                    self.pos += 1;
425                    break;
426                }
427                Some(token) => {
428                    start.get_or_insert(token.start);
429                    end = Some(token.end);
430                    match &token.kind {
431                        TokenKind::Word(_)
432                        | TokenKind::Number { .. }
433                        | TokenKind::Dot
434                        | TokenKind::Colon
435                        | TokenKind::Op(_) => {}
436                        _ => return Err(self.malformed("expected a text line", &token)),
437                    }
438                    parts.push((raw_token_text(&token.kind), token.start, token.end));
439                    self.pos += 1;
440                }
441                None => {
442                    return Err(self.malformed("unexpected end of input in line", self.eof()));
443                }
444            }
445        }
446        Ok((
447            join_as_written(&parts),
448            start
449                .zip(end)
450                .map(|(start, end)| Span::new(self.file(), start, end)),
451        ))
452    }
453
454    /// The candidate spellings at `pos`, longest first: `(token_count,
455    /// text)` for every prefix of the spelling run. Words and numbers
456    /// always continue the run; `.`, `:`, and `!` (the `not` operator
457    /// spelling) continue it only when written adjacent to the preceding
458    /// fragment — `Resurrección:`, `Maks. Can`, `Orisa Dur! …` — so a
459    /// spaced `:` or `!` keeps its syntax role (`a ? b : c`, `! x`) (#448).
460    /// `attaches_to` seeds adjacency for the first token when the run
461    /// continues an already-consumed phrase.
462    pub(crate) fn spelling_run(
463        &self,
464        pos: usize,
465        attaches_to: Option<Position>,
466    ) -> Vec<(usize, String, Position)> {
467        let mut candidates = Vec::new();
468        let mut text = String::new();
469        let mut index = pos;
470        let mut prev_end = attaches_to;
471        while let Some(token) = self.tokens.get(index) {
472            let fragment = match &token.kind {
473                TokenKind::Word(word) => word.as_str(),
474                TokenKind::Number { text, .. } => text.as_str(),
475                TokenKind::Dot => ".",
476                TokenKind::Colon => ":",
477                TokenKind::Op(op) if op == "not" => "!",
478                // A spaced dash stays inside a member spelling (`Spray -
479                // Baixo`, `Busan - Centre-ville`); miss-only probes keep
480                // `x - y` arithmetic untouched.
481                TokenKind::Op(op) if op == "-" => "-",
482                _ => break,
483            };
484            // `-` is allowed spaced (the client emits `X - Y` member
485            // spellings); `.`, `:`, `!` only continue a spelling when glued
486            // to the previous token.
487            let needs_adjacent = matches!(token.kind, TokenKind::Dot | TokenKind::Colon)
488                || matches!(&token.kind, TokenKind::Op(op) if op == "not");
489            if needs_adjacent && prev_end.is_none_or(|end| token.start != end) {
490                break;
491            }
492            if prev_end.is_some_and(|end| token.start != end) {
493                text.push(' ');
494            }
495            text.push_str(fragment);
496            prev_end = Some(token.end);
497            index += 1;
498            candidates.push((index - pos, text.clone(), token.end));
499        }
500        candidates.reverse();
501        candidates
502    }
503
504    /// Consume the longest catalog spelling at the cursor accepted by
505    /// `accept` (a `resolve`-style lookup), covering spellings with
506    /// interior `.`, `:`, `!` or a leading number (`Maks. Can`,
507    /// `Hızlanma:`, `1. Kabiliyet`, #448). A single-token run is skipped:
508    /// a plain word or number keeps the ordinary phrase path. The cursor
509    /// is unchanged on a miss.
510    pub(crate) fn catalog_spelling(
511        &mut self,
512        accept: impl Fn(&str) -> bool,
513    ) -> Option<(String, Position, Position)> {
514        let start = self.tokens.get(self.pos)?.start;
515        for (count, text, end) in self.spelling_run(self.pos, None) {
516            if count < 2 {
517                break;
518            }
519            if accept(&text) {
520                self.pos += count;
521                return Some((text, start, end));
522            }
523        }
524        None
525    }
526
527    /// Extend an already-consumed `phrase` through an adjacent `.`/`:`/`!`
528    /// tail plus any following words (`Forcer le nom de l’I.A.`,
529    /// `Bekle:`), when the joined text satisfies `accept` (#448). Consumes
530    /// the tail on a hit and returns the joined spelling and its end
531    /// position; the cursor is unchanged on a miss.
532    pub(crate) fn extend_catalog_spelling(
533        &mut self,
534        phrase: &str,
535        phrase_end: Position,
536        accept: impl Fn(&str) -> bool,
537    ) -> Option<(String, Position)> {
538        for (count, tail, end) in self.spelling_run(self.pos, Some(phrase_end)) {
539            let joined = format!("{phrase}{tail}");
540            if accept(&joined) {
541                self.pos += count;
542                return Some((joined, end));
543            }
544        }
545        None
546    }
547
548    /// `extend_catalog_spelling` specialized to catalog entry aliases of
549    /// `kinds`, honoring the `resolve_entry` primary-locale fallback (#448).
550    pub(crate) fn extend_entry_spelling(
551        &mut self,
552        phrase: &str,
553        phrase_end: Position,
554        kinds: &[Kind],
555    ) -> Option<(String, Position)> {
556        let catalog = self.catalog;
557        let locale = self.locale.clone();
558        let primary = catalog.primary_locale();
559        let kinds = kinds.to_vec();
560        self.extend_catalog_spelling(phrase, phrase_end, |spelling| {
561            kinds.iter().any(|kind| {
562                catalog.resolve(*kind, &locale, spelling).is_some()
563                    || (locale != *primary && catalog.resolve(*kind, primary, spelling).is_some())
564            })
565        })
566    }
567
568    /// Consume a known keyword phrase, verifying its spelling.
569    pub(crate) fn consume_phrase(&mut self, expected: &str) -> Result<()> {
570        let (phrase, _, _) = self.phrase()?;
571        if phrase != expected {
572            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
573        }
574        Ok(())
575    }
576
577    /// The spellings a structural keyword accepts here: catalog aliases for
578    /// the active and primary locales, plus the hardcoded fallback keys that
579    /// resolve to the same id in the primary locale.
580    fn structural_spellings(&self, id: &str) -> Vec<String> {
581        let mut spellings = Vec::new();
582        if let Some(entry) = self.catalog.entry(Kind::Structural, id) {
583            spellings.extend(entry.spellings(&self.locale).iter().cloned());
584            let primary = self.catalog.primary_locale();
585            if self.locale != *primary {
586                spellings.extend(entry.spellings(primary).iter().cloned());
587            }
588        }
589        for (spelling, english) in structural_keyword_map() {
590            if self
591                .catalog
592                .resolve(Kind::Structural, self.catalog.primary_locale(), english)
593                .is_some_and(|entry| entry.id == id)
594            {
595                spellings.push(spelling.clone());
596            }
597        }
598        spellings
599    }
600
601    /// How many tokens a structural spelling spans at the cursor: words and
602    /// numbers compare case-insensitively, a trailing `.` needs a Dot token.
603    fn match_spelling_tokens(&self, spelling: &str) -> Option<usize> {
604        let mut offset = 0;
605        for piece in spelling.split(' ') {
606            let (word, dotted) = match piece.strip_suffix('.') {
607                Some(word) => (word, true),
608                None => (piece, false),
609            };
610            if !word.is_empty() {
611                match self.peek_at(offset).map(|token| token.kind) {
612                    Some(TokenKind::Word(ref candidate)) if table::names_eq(candidate, word) => {}
613                    Some(TokenKind::Number { ref text, .. }) if text == word => {}
614                    _ => return None,
615                }
616                offset += 1;
617            }
618            if dotted {
619                match self.peek_at(offset).map(|token| token.kind) {
620                    Some(TokenKind::Dot) => offset += 1,
621                    _ => return None,
622                }
623            }
624        }
625        (offset > 0).then_some(offset)
626    }
627
628    /// Consume the longest structural keyword prefix among `ids` at the
629    /// cursor, returning its canonical id. Multi-word and dotted spellings
630    /// (tr `devre dışı`, pl `wył.`, fr `de joueur`) resolve like single-word
631    /// aliases; nothing is consumed on a miss.
632    pub(crate) fn keyword_prefix(&mut self, ids: &[&str]) -> Option<String> {
633        let mut best: Option<(String, usize)> = None;
634        for id in ids {
635            for spelling in self.structural_spellings(id) {
636                if let Some(len) = self.match_spelling_tokens(&spelling) {
637                    if best.as_ref().is_none_or(|(_, length)| len > *length) {
638                        best = Some(((*id).to_string(), len));
639                    }
640                }
641            }
642        }
643        if let Some((id, len)) = best {
644            self.pos += len;
645            Some(id)
646        } else {
647            None
648        }
649    }
650
651    /// Consume one structural keyword by canonical id, verifying spelling.
652    pub(crate) fn consume_keyword(&mut self, id: &str) -> Result<()> {
653        if self.keyword_prefix(&[id]).is_some() {
654            return Ok(());
655        }
656        match self.peek() {
657            Some(token) => Err(self.malformed(&format!("expected '{id}'"), &token)),
658            None => Err(self.malformed(&format!("expected '{id}'"), self.eof())),
659        }
660    }
661
662    pub(crate) fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
663        let start = self.peek().map(|token| token.start);
664        if self.keyword_prefix(&[expected]).is_some() {
665            return Ok(start.unwrap_or_else(|| self.previous().start));
666        }
667        match self.next() {
668            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
669            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
670        }
671    }
672
673    pub(crate) fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
674        match self.next() {
675            Some(token) if token.kind == kind => Ok(()),
676            Some(token) => Err(self.malformed(message, &token)),
677            None => Err(self.malformed(message, self.eof())),
678        }
679    }
680
681    /// Expect the `;` terminating a statement. When the token found instead
682    /// sits on a later line, the missing `;` is reported at the end of the
683    /// statement it should have closed rather than at that unrelated token
684    /// (#340); a same-line offender keeps pointing at the token itself.
685    pub(crate) fn expect_semi(&mut self, message: &str) -> Result<()> {
686        match self.peek() {
687            Some(Token {
688                kind: TokenKind::Semi,
689                ..
690            }) => {
691                self.pos += 1;
692                Ok(())
693            }
694            Some(token) if token.start.line > self.previous().end.line => {
695                let end = self.previous().end;
696                Err(WorkshopError::malformed(
697                    message.to_string(),
698                    Some(Span::new(self.file(), end, end)),
699                ))
700            }
701            Some(token) => Err(self.malformed(message, &token)),
702            None => Err(self.malformed(message, self.eof())),
703        }
704    }
705
706    pub(crate) fn expect_string(&mut self, message: &str) -> Result<String> {
707        match self.next() {
708            Some(Token {
709                kind: TokenKind::String(content),
710                ..
711            }) => Ok(content),
712            Some(token) => Err(self.malformed(message, &token)),
713            None => Err(self.malformed(message, self.eof())),
714        }
715    }
716
717    pub(crate) fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
718        WorkshopError::malformed(
719            message.to_string(),
720            Some(Span::new(self.file(), token.start, token.end)),
721        )
722    }
723
724    pub(crate) fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
725        WorkshopError::unknown(kind, spelling.to_string(), self.locale.clone(), None)
726    }
727
728    /// `unknown` carrying the span of the rejected spelling (#448: settings
729    /// diagnostics must locate their token).
730    pub(crate) fn unknown_at(
731        &self,
732        kind: &'static str,
733        spelling: &str,
734        start: Position,
735        end: Position,
736    ) -> WorkshopError {
737        WorkshopError::unknown(
738            kind,
739            spelling.to_string(),
740            self.locale.clone(),
741            Some(Span::new(self.file(), start, end)),
742        )
743    }
744
745    /// Spellings of `kind` the parse surface accepts under `self.locale`:
746    /// the locale's own spellings plus, when `include_primary`, the primary
747    /// locale's (the fallback [`ParseContext::resolve_entry`] accepts).
748    pub(crate) fn accepted_spellings(
749        &self,
750        kind: Kind,
751        include_primary: bool,
752    ) -> impl Iterator<Item = &str> {
753        let catalog = self.catalog;
754        let primary = catalog.primary_locale();
755        let include_primary = include_primary && self.locale != *primary;
756        catalog.entries_of(kind).flat_map(move |entry| {
757            let local: &[String] = entry.spellings(&self.locale);
758            let fallback: &[String] = if include_primary {
759                entry.spellings(primary)
760            } else {
761                &[]
762            };
763            local.iter().chain(fallback).map(String::as_str)
764        })
765    }
766
767    /// Member spellings of `domain` accepted under `self.locale`: the
768    /// locale's own spellings plus, when `include_primary`, the primary
769    /// locale's (the fallback `resolve_enum_member_mixed` accepts).
770    pub(crate) fn accepted_member_spellings<'b>(
771        &'b self,
772        domain: &'b str,
773        include_primary: bool,
774    ) -> impl Iterator<Item = &'b str> {
775        let catalog = self.catalog;
776        let primary = catalog.primary_locale();
777        let include_primary = include_primary && self.locale != *primary;
778        catalog
779            .enum_domain(domain)
780            .into_iter()
781            .flat_map(move |domain| {
782                domain.members.iter().flat_map(move |member| {
783                    let local: &[String] = member.spellings(&self.locale);
784                    let fallback: &[String] = if include_primary {
785                        member.spellings(primary)
786                    } else {
787                        &[]
788                    };
789                    local.iter().chain(fallback).map(String::as_str)
790                })
791            })
792    }
793
794    /// The nearest accepted `kind` spellings for a rejected parse spelling.
795    pub(crate) fn spelling_candidates(
796        &self,
797        kind: Kind,
798        spelling: &str,
799        include_primary: bool,
800    ) -> Vec<String> {
801        crate::core::suggest::nearest(
802            spelling,
803            self.accepted_spellings(kind, include_primary),
804            crate::core::suggest::CANDIDATE_LIMIT,
805        )
806    }
807
808    /// The nearest accepted `domain` member spellings for a rejected parse
809    /// spelling.
810    pub(crate) fn member_candidates(
811        &self,
812        domain: &str,
813        spelling: &str,
814        include_primary: bool,
815    ) -> Vec<String> {
816        crate::core::suggest::nearest(
817            spelling,
818            self.accepted_member_spellings(domain, include_primary),
819            crate::core::suggest::CANDIDATE_LIMIT,
820        )
821    }
822
823    pub(crate) fn peek(&self) -> Option<Token> {
824        self.tokens.get(self.pos).cloned()
825    }
826
827    pub(crate) fn peek_at(&self, offset: usize) -> Option<Token> {
828        self.tokens.get(self.pos + offset).cloned()
829    }
830
831    pub(crate) fn next(&mut self) -> Option<Token> {
832        let token = self.tokens.get(self.pos).cloned();
833        if token.is_some() {
834            self.pos += 1;
835        }
836        token
837    }
838
839    pub(crate) fn previous(&self) -> &Token {
840        self.tokens
841            .get(self.pos.saturating_sub(1))
842            .unwrap_or_else(|| self.tokens.last().unwrap())
843    }
844
845    pub(crate) fn previous_span(&self) -> (Position, Position) {
846        let token = self.previous();
847        (token.start, token.end)
848    }
849
850    pub(crate) fn span_here(&self) -> (Position, Position) {
851        let token = self
852            .peek()
853            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
854        (token.start, token.end)
855    }
856
857    pub(crate) fn eof(&self) -> &Token {
858        self.tokens.last().unwrap()
859    }
860
861    pub(crate) fn file(&self) -> crate::core::ids::Id<SourceFile> {
862        crate::core::ids::Id::from_index(0)
863    }
864}
865
866fn structural_keyword_map() -> &'static HashMap<String, String> {
867    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
868    KEYWORDS.get_or_init(|| {
869        serde_json::from_str(include_str!("structural_keywords.json"))
870            .expect("structural keyword data is valid JSON")
871    })
872}
873
874pub(crate) fn canonical_keyword(keyword: &str) -> &str {
875    structural_keyword_map()
876        .get(keyword)
877        .map(String::as_str)
878        .unwrap_or(keyword)
879}
880
881/// Join token fragments exactly as written: a real source gap inserts one
882/// space, adjacent fragments stay glued (`0.` + `Yuva` is `0. Yuva`, but
883/// `Dur` + `!` is `Dur!`, #448).
884pub(crate) fn join_as_written(parts: &[(String, Position, Position)]) -> String {
885    let mut text = String::new();
886    let mut prev_end: Option<Position> = None;
887    for (fragment, start, end) in parts {
888        if prev_end.is_some_and(|prev| prev != *start) {
889            text.push(' ');
890        }
891        text.push_str(fragment);
892        prev_end = Some(*end);
893    }
894    text
895}
896
897pub(crate) fn raw_token_text(kind: &TokenKind) -> String {
898    match kind {
899        TokenKind::Word(value) => value.clone(),
900        TokenKind::Number { text, .. } => text.clone(),
901        TokenKind::String(value) => format!(
902            "\"{}\"",
903            crate::output::emitter::escape_settings_string(value)
904        ),
905        // The lexer canonicalizes the operator spellings `!`, `&&`, `||`
906        // to `not`/`and`/`or`; the written spelling is the sigil form, so
907        // reconstruct it for source text (#448).
908        TokenKind::Op(value) => match value.as_str() {
909            "not" => "!".to_string(),
910            "and" => "&&".to_string(),
911            "or" => "||".to_string(),
912            _ => value.clone(),
913        },
914        TokenKind::LParen => "(".to_string(),
915        TokenKind::RParen => ")".to_string(),
916        TokenKind::Comma => ",".to_string(),
917        TokenKind::Semi => ";".to_string(),
918        TokenKind::LBrace => "{".to_string(),
919        TokenKind::RBrace => "}".to_string(),
920        TokenKind::Colon => ":".to_string(),
921        TokenKind::Dot => ".".to_string(),
922        TokenKind::LBracket => "[".to_string(),
923        TokenKind::RBracket => "]".to_string(),
924        TokenKind::Eof => String::new(),
925    }
926}