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