Skip to main content

workshop_rs/frontend/
parser.rs

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