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