Skip to main content

workshop_rs/frontend/
parser.rs

1//! Native localized Workshop parser.
2//!
3//! Parses vanilla Workshop text directly into validated, locale-independent
4//! Workshop IR. Localized actions, values, events, enums, and structural
5//! keywords resolve through the canonical catalog; malformed input,
6//! unknown spellings, and recognized-but-unsupported constructs are reported
7//! as distinct structured diagnostics with source spans.
8
9pub(crate) use std::collections::HashMap;
10use std::sync::OnceLock;
11
12pub(crate) use crate::core::signatures::ExpectedDomain;
13pub(crate) use crate::core::source::{Position, SourceFile, Span};
14pub(crate) use crate::settings::table::{self, KeyKind, PathPart};
15pub(crate) use crate::settings::{Settings, SettingsListElement, SettingsNode};
16pub(crate) use crate::wir::{
17    self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode,
18};
19
20pub(crate) use super::lexer::{Token, TokenKind, tokenize};
21pub(crate) use crate::catalog::{Catalog, Kind, Locale, ParamCoercions};
22pub(crate) use crate::core::error::{Result, WorkshopError};
23
24/// Where action parsing stopped.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum Stop {
27    /// The enclosing `}` was consumed.
28    SectionClosed,
29    /// The `End` keyword is next (not consumed).
30    End,
31    /// The `Else If` keyword is next (not consumed).
32    ElseIf,
33    /// The `Else` keyword is next (not consumed).
34    Else,
35}
36
37pub(crate) enum AssignmentOperator {
38    Set,
39    Modify(ModifyOp),
40}
41
42/// Parse localized Workshop text into Workshop IR using the catalog's
43/// canonical call-signature context. Ambiguous bare enum members resolve when
44/// their enclosing call pins one matching domain; unpinned ambiguity remains a
45/// structured unsupported diagnostic. See [`parse_with_context`] when a
46/// consumer needs to provide additional signature context.
47pub fn parse(input: &str, catalog: &Catalog, locale: &Locale) -> Result<wir::Program> {
48    parse_with_context(input, catalog, locale, catalog)
49}
50
51/// Parse localized Workshop text into Workshop IR, resolving ambiguous bare
52/// enum members from the enclosing call's canonical signature context (#111).
53///
54/// When a bare member spelling matches several enum domains, the parser asks
55/// [`ExpectedDomain::expected_domain`] for the domain the enclosing call's
56/// signature expects at that argument position; the member resolves only when
57/// that expected domain is one of the matching domains (i.e. the signature
58/// pins exactly one). Without a pin the ambiguity diagnostic is unchanged.
59pub fn parse_with_context(
60    input: &str,
61    catalog: &Catalog,
62    locale: &Locale,
63    context: &dyn ExpectedDomain,
64) -> Result<wir::Program> {
65    let tokens = tokenize(input).map_err(|error| WorkshopError::Malformed {
66        message: error.message,
67        span: Some(synthetic_span(error.position)),
68    })?;
69    ParseContext {
70        tokens,
71        pos: 0,
72        catalog,
73        locale: locale.clone(),
74        context,
75        expected_domain: None,
76        call_stack: Vec::new(),
77        target: wir::Program::default(),
78        globals: HashMap::new(),
79        players: HashMap::new(),
80        subroutines: HashMap::new(),
81    }
82    .program()
83}
84
85/// A synthetic single-position span (used before a file registry exists).
86pub(crate) fn synthetic_span(position: Position) -> Span {
87    Span::new(crate::core::ids::Id::from_index(0), position, position)
88}
89
90pub(crate) struct ParseContext<'a> {
91    pub(crate) tokens: Vec<Token>,
92    pub(crate) pos: usize,
93    pub(crate) catalog: &'a Catalog,
94    pub(crate) locale: Locale,
95    /// Canonical signature context (#111): supplies the expected enum domain
96    /// for the call argument currently being parsed.
97    pub(crate) context: &'a dyn ExpectedDomain,
98    /// The expected enum domain for the value currently being parsed, set by
99    /// [`ParseContext::value_args`] from the enclosing call's signature.
100    pub(crate) expected_domain: Option<&'a str>,
101    pub(crate) call_stack: Vec<String>,
102    pub(crate) target: wir::Program,
103    pub(crate) globals: HashMap<String, wir::GlobalVarId>,
104    pub(crate) players: HashMap<String, wir::PlayerVarId>,
105    pub(crate) subroutines: HashMap<String, wir::SubroutineId>,
106}
107
108impl ParseContext<'_> {
109    pub(crate) fn resolve_entry(
110        &self,
111        kind: Kind,
112        spelling: &str,
113    ) -> Option<crate::catalog::CatalogEntry> {
114        self.catalog
115            .resolve(kind, &self.locale, spelling)
116            .cloned()
117            .or_else(|| {
118                if self.locale != *self.catalog.primary_locale() {
119                    self.catalog
120                        .resolve(kind, self.catalog.primary_locale(), spelling)
121                        .cloned()
122                } else {
123                    None
124                }
125            })
126    }
127
128    pub(crate) fn canonical_keyword(&self, spelling: &str) -> String {
129        self.catalog
130            .resolve(Kind::Structural, &self.locale, spelling)
131            .or_else(|| {
132                (self.locale != *self.catalog.primary_locale()).then(|| {
133                    self.catalog
134                        .resolve(Kind::Structural, self.catalog.primary_locale(), spelling)
135                })?
136            })
137            .map(|entry| entry.id.clone())
138            .unwrap_or_else(|| canonical_keyword(spelling).to_string())
139    }
140
141    pub(crate) fn line_has_assignment(&self) -> bool {
142        let tokens: Vec<_> = self.tokens[self.pos..]
143            .iter()
144            .take_while(|token| !matches!(token.kind, TokenKind::Semi | TokenKind::RBrace))
145            .collect();
146        tokens.iter().any(|token| {
147            matches!(&token.kind, TokenKind::Op(op) if matches!(op.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%="))
148        }) || tokens.windows(2).any(|window| {
149            matches!(&window[0].kind, TokenKind::Word(_))
150                && matches!(&window[1].kind, TokenKind::Op(op) if op == "=")
151        })
152    }
153
154    /// Read the maximal phrase of consecutive words (space-joined). Phrases
155    /// may span lines because long Workshop action arguments wrap mid-phrase.
156    pub(crate) fn phrase(&mut self) -> Result<(String, Position, Position)> {
157        let mut words = Vec::new();
158        let (start, mut end) = match self.peek() {
159            Some(Token {
160                kind: TokenKind::Word(word),
161                start,
162                end,
163            }) => {
164                words.push(word.clone());
165                (start, end)
166            }
167            Some(Token {
168                kind: TokenKind::Number { text, .. },
169                start,
170                end,
171            }) => {
172                words.push(text.clone());
173                (start, end)
174            }
175            Some(token) => return Err(self.malformed("expected an identifier", &token)),
176            None => return Err(self.malformed("expected an identifier", self.eof())),
177        };
178        self.pos += 1;
179        while let Some(token) = self.peek() {
180            match token {
181                Token {
182                    kind: TokenKind::Word(word),
183                    end: word_end,
184                    ..
185                } => {
186                    if matches!(
187                        self.peek_at(1).map(|token| token.kind),
188                        Some(TokenKind::Op(equal)) if equal == "="
189                    ) {
190                        break;
191                    }
192                    words.push(word.clone());
193                    end = word_end;
194                    self.pos += 1;
195                }
196                // Enum members embed numbers (`Team 2`, `Ability 2`); the
197                // lexer splits them from the word, so phrases join Number
198                // tokens too (#119 reference evidence).
199                Token {
200                    kind: TokenKind::Number { text, .. },
201                    end: number_end,
202                    ..
203                } => {
204                    words.push(text.clone());
205                    end = number_end;
206                    self.pos += 1;
207                }
208                _ => break,
209            }
210        }
211        Ok((words.join(" "), start, end))
212    }
213
214    /// Read a single-line phrase (stops at a line boundary). Used for names
215    /// that are structurally one per line, such as variable declarations.
216    pub(crate) fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
217        let mut words = Vec::new();
218        let (start, mut end, line) = match self.peek() {
219            Some(Token {
220                kind: TokenKind::Word(word),
221                start,
222                end,
223            }) => {
224                words.push(word.clone());
225                (start, end, start.line)
226            }
227            Some(Token {
228                kind: TokenKind::Number { text, .. },
229                start,
230                end,
231            }) => {
232                words.push(text.clone());
233                (start, end, start.line)
234            }
235            Some(token) => return Err(self.malformed("expected an identifier", &token)),
236            None => return Err(self.malformed("expected an identifier", self.eof())),
237        };
238        self.pos += 1;
239        while let Some(token) = self.peek() {
240            let (word, word_start, word_end) = match token {
241                Token {
242                    kind: TokenKind::Word(word),
243                    start,
244                    end,
245                } => (word, start, end),
246                Token {
247                    kind: TokenKind::Number { text, .. },
248                    start,
249                    end,
250                } => (text, start, end),
251                Token {
252                    kind: TokenKind::Dot,
253                    start,
254                    end,
255                } => (".".to_string(), start, end),
256                Token {
257                    kind: TokenKind::Op(op),
258                    start,
259                    end,
260                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
261                _ => break,
262            };
263            if word_start.line != line {
264                break;
265            }
266            words.push(word);
267            end = word_end;
268            self.pos += 1;
269        }
270        Ok((
271            words
272                .join(" ")
273                .replace(" .", ".")
274                .replace(". ", ".")
275                .replace(" : ", ":")
276                .replace(" %", "%"),
277            start,
278            end,
279        ))
280    }
281
282    pub(crate) fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
283        let (mut phrase, start, mut end) = self.phrase_on_line()?;
284        if matches!(
285            self.peek(),
286            Some(Token {
287                kind: TokenKind::Colon,
288                ..
289            })
290        ) {
291            let colon_pos = self.pos;
292            self.next();
293            let (rest, _, rest_end) = self.phrase_on_line()?;
294            if matches!(
295                self.peek(),
296                Some(Token {
297                    kind: TokenKind::LBrace,
298                    ..
299                })
300            ) {
301                phrase.push(':');
302                phrase.push(' ');
303                phrase.push_str(&rest);
304                end = rest_end;
305            } else {
306                self.pos = colon_pos;
307            }
308        }
309        Ok((phrase, start, end))
310    }
311
312    pub(crate) fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
313        let first = self
314            .peek()
315            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
316        let start = first.start;
317        let line = first.start.line;
318        let mut end = first.end;
319        let mut parts = Vec::new();
320        while let Some(token) = self.peek() {
321            if token.start.line != line
322                || matches!(token.kind, TokenKind::RParen | TokenKind::Comma)
323            {
324                break;
325            }
326            self.pos += 1;
327            end = token.end;
328            parts.push(raw_token_text(&token.kind));
329        }
330        if parts.is_empty() {
331            return Err(self.malformed("expected an enum member", &first));
332        }
333        Ok((
334            parts
335                .join(" ")
336                .replace(" : ", ":")
337                .replace(" .", ".")
338                .replace(". ", "."),
339            start,
340            end,
341        ))
342    }
343
344    /// Read a text line (tokens until `;`), joining words and dashes into
345    /// the literal text, and consume the terminating `;`.
346    pub(crate) fn line_text(&mut self) -> Result<String> {
347        let mut parts = Vec::new();
348        loop {
349            match self.peek() {
350                Some(Token {
351                    kind: TokenKind::Semi,
352                    ..
353                }) => {
354                    self.pos += 1;
355                    break;
356                }
357                Some(Token {
358                    kind: TokenKind::Word(word),
359                    ..
360                }) => {
361                    parts.push(word.clone());
362                    self.pos += 1;
363                }
364                Some(Token {
365                    kind: TokenKind::Op(op),
366                    ..
367                }) if op == "-" => {
368                    parts.push("-".to_string());
369                    self.pos += 1;
370                }
371                Some(Token {
372                    kind: TokenKind::Number { value, .. },
373                    ..
374                }) => {
375                    parts.push(value.to_string());
376                    self.pos += 1;
377                }
378                Some(Token {
379                    kind: TokenKind::Dot,
380                    ..
381                }) => {
382                    parts.push(".".to_string());
383                    self.pos += 1;
384                }
385                Some(Token {
386                    kind: TokenKind::Colon,
387                    ..
388                }) => {
389                    parts.push(":".to_string());
390                    self.pos += 1;
391                }
392                Some(token) => return Err(self.malformed("expected a text line", &token)),
393                None => return Err(self.malformed("unexpected end of input in line", self.eof())),
394            }
395        }
396        Ok(parts
397            .join(" ")
398            .replace(" .", ".")
399            .replace(". ", ".")
400            .replace(" : ", ":"))
401    }
402
403    /// Consume a known keyword phrase, verifying its spelling.
404    pub(crate) fn consume_phrase(&mut self, expected: &str) -> Result<()> {
405        let (phrase, _, _) = self.phrase()?;
406        if phrase != expected {
407            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
408        }
409        Ok(())
410    }
411
412    pub(crate) fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
413        match self.next() {
414            Some(Token {
415                kind: TokenKind::Word(word),
416                start,
417                ..
418            }) if self.canonical_keyword(&word) == expected => Ok(start),
419            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
420            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
421        }
422    }
423
424    pub(crate) fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
425        match self.next() {
426            Some(token) if token.kind == kind => Ok(()),
427            Some(token) => Err(self.malformed(message, &token)),
428            None => Err(self.malformed(message, self.eof())),
429        }
430    }
431
432    pub(crate) fn expect_string(&mut self, message: &str) -> Result<String> {
433        match self.next() {
434            Some(Token {
435                kind: TokenKind::String(content),
436                ..
437            }) => Ok(content),
438            Some(token) => Err(self.malformed(message, &token)),
439            None => Err(self.malformed(message, self.eof())),
440        }
441    }
442
443    pub(crate) fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
444        WorkshopError::Malformed {
445            message: message.to_string(),
446            span: Some(Span::new(self.file(), token.start, token.end)),
447        }
448    }
449
450    pub(crate) fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
451        WorkshopError::Unknown {
452            kind,
453            spelling: spelling.to_string(),
454            locale: self.locale.clone(),
455            span: None,
456        }
457    }
458
459    pub(crate) fn peek(&self) -> Option<Token> {
460        self.tokens.get(self.pos).cloned()
461    }
462
463    pub(crate) fn peek_at(&self, offset: usize) -> Option<Token> {
464        self.tokens.get(self.pos + offset).cloned()
465    }
466
467    pub(crate) fn next(&mut self) -> Option<Token> {
468        let token = self.tokens.get(self.pos).cloned();
469        if token.is_some() {
470            self.pos += 1;
471        }
472        token
473    }
474
475    pub(crate) fn previous(&self) -> &Token {
476        self.tokens
477            .get(self.pos.saturating_sub(1))
478            .unwrap_or_else(|| self.tokens.last().unwrap())
479    }
480
481    pub(crate) fn previous_span(&self) -> (Position, Position) {
482        let token = self.previous();
483        (token.start, token.end)
484    }
485
486    pub(crate) fn span_here(&self) -> (Position, Position) {
487        let token = self
488            .peek()
489            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
490        (token.start, token.end)
491    }
492
493    pub(crate) fn eof(&self) -> &Token {
494        self.tokens.last().unwrap()
495    }
496
497    pub(crate) fn file(&self) -> crate::core::ids::Id<SourceFile> {
498        crate::core::ids::Id::from_index(0)
499    }
500}
501
502pub(crate) fn is_comparison(op: &str) -> bool {
503    matches!(op, "==" | "!=" | "<" | "<=" | ">" | ">=")
504}
505
506pub(crate) fn canonical_keyword(keyword: &str) -> &str {
507    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
508    KEYWORDS
509        .get_or_init(|| {
510            serde_json::from_str(include_str!("structural_keywords.json"))
511                .expect("structural keyword data is valid JSON")
512        })
513        .get(keyword)
514        .map(String::as_str)
515        .unwrap_or(keyword)
516}
517
518pub(crate) fn raw_token_text(kind: &TokenKind) -> String {
519    match kind {
520        TokenKind::Word(value) => value.clone(),
521        TokenKind::Number { text, .. } => text.clone(),
522        TokenKind::String(value) => format!("\"{}\"", value.replace('"', "\\\"")),
523        TokenKind::Op(value) => value.clone(),
524        TokenKind::LParen => "(".to_string(),
525        TokenKind::RParen => ")".to_string(),
526        TokenKind::Comma => ",".to_string(),
527        TokenKind::Semi => ";".to_string(),
528        TokenKind::LBrace => "{".to_string(),
529        TokenKind::RBrace => "}".to_string(),
530        TokenKind::Colon => ":".to_string(),
531        TokenKind::Dot => ".".to_string(),
532        TokenKind::LBracket => "[".to_string(),
533        TokenKind::RBracket => "]".to_string(),
534        TokenKind::Eof => String::new(),
535    }
536}