Skip to main content

workshop_rs/frontend/
parser.rs

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