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 unsupported diagnostic. 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 the ambiguity diagnostic is unchanged.
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    }
101    .program()
102}
103
104/// A synthetic single-position span (used before a file registry exists).
105pub(crate) fn synthetic_span(position: Position) -> Span {
106    Span::new(crate::core::ids::Id::from_index(0), position, position)
107}
108
109pub(crate) struct ParseContext<'a> {
110    pub(crate) tokens: Vec<Token>,
111    pub(crate) pos: usize,
112    pub(crate) source: &'a str,
113    pub(crate) catalog: &'a Catalog,
114    pub(crate) locale: Locale,
115    /// Canonical signature context (#111): supplies the expected enum domain
116    /// for the call argument currently being parsed.
117    pub(crate) context: &'a dyn ExpectedDomain,
118    /// The expected enum domain for the value currently being parsed, set by
119    /// [`ParseContext::value_args`] from the enclosing call's signature.
120    pub(crate) expected_domain: Option<&'a str>,
121    pub(crate) call_stack: Vec<String>,
122    pub(crate) target: wir::Program,
123    pub(crate) globals: HashMap<String, wir::GlobalVarId>,
124    pub(crate) players: HashMap<String, wir::PlayerVarId>,
125    pub(crate) subroutines: HashMap<String, wir::SubroutineId>,
126}
127
128impl ParseContext<'_> {
129    pub(crate) fn resolve_entry(
130        &self,
131        kind: Kind,
132        spelling: &str,
133    ) -> Option<crate::catalog::CatalogEntry> {
134        self.catalog
135            .resolve(kind, &self.locale, spelling)
136            .cloned()
137            .or_else(|| {
138                if self.locale != *self.catalog.primary_locale() {
139                    self.catalog
140                        .resolve(kind, self.catalog.primary_locale(), spelling)
141                        .cloned()
142                } else {
143                    None
144                }
145            })
146    }
147
148    pub(crate) fn canonical_keyword(&self, spelling: &str) -> String {
149        self.catalog
150            .resolve(Kind::Structural, &self.locale, spelling)
151            .or_else(|| {
152                (self.locale != *self.catalog.primary_locale()).then(|| {
153                    self.catalog
154                        .resolve(Kind::Structural, self.catalog.primary_locale(), spelling)
155                })?
156            })
157            .map(|entry| entry.id.clone())
158            .unwrap_or_else(|| canonical_keyword(spelling).to_string())
159    }
160
161    pub(crate) fn line_has_assignment(&self) -> bool {
162        let tokens: Vec<_> = self.tokens[self.pos..]
163            .iter()
164            .take_while(|token| !matches!(token.kind, TokenKind::Semi | TokenKind::RBrace))
165            .collect();
166        tokens.iter().any(|token| {
167            matches!(&token.kind, TokenKind::Op(op) if matches!(op.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%="))
168        }) || tokens.windows(2).any(|window| {
169            matches!(&window[0].kind, TokenKind::Word(_))
170                && matches!(&window[1].kind, TokenKind::Op(op) if op == "=")
171        })
172    }
173
174    /// Read the maximal phrase of consecutive words (space-joined). Phrases
175    /// may span lines because long Workshop action arguments wrap mid-phrase.
176    pub(crate) fn phrase(&mut self) -> Result<(String, Position, Position)> {
177        let mut words = Vec::new();
178        let (start, mut end) = match self.peek() {
179            Some(Token {
180                kind: TokenKind::Word(word),
181                start,
182                end,
183            }) => {
184                words.push(word.clone());
185                (start, end)
186            }
187            Some(Token {
188                kind: TokenKind::Number { text, .. },
189                start,
190                end,
191            }) => {
192                words.push(text.clone());
193                (start, end)
194            }
195            Some(token) => return Err(self.malformed("expected an identifier", &token)),
196            None => return Err(self.malformed("expected an identifier", self.eof())),
197        };
198        self.pos += 1;
199        while let Some(token) = self.peek() {
200            match token {
201                Token {
202                    kind: TokenKind::Word(word),
203                    end: word_end,
204                    ..
205                } => {
206                    if matches!(
207                        self.peek_at(1).map(|token| token.kind),
208                        Some(TokenKind::Op(equal)) if equal == "="
209                    ) {
210                        break;
211                    }
212                    words.push(word.clone());
213                    end = word_end;
214                    self.pos += 1;
215                }
216                // Enum members embed numbers (`Team 2`, `Ability 2`); the
217                // lexer splits them from the word, so phrases join Number
218                // tokens too (#119 reference evidence).
219                Token {
220                    kind: TokenKind::Number { text, .. },
221                    end: number_end,
222                    ..
223                } => {
224                    words.push(text.clone());
225                    end = number_end;
226                    self.pos += 1;
227                }
228                _ => break,
229            }
230        }
231        Ok((words.join(" "), start, end))
232    }
233
234    /// Read a single-line phrase (stops at a line boundary). Used for names
235    /// that are structurally one per line, such as variable declarations.
236    pub(crate) fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
237        let mut words = Vec::new();
238        let (start, mut end, line) = match self.peek() {
239            Some(Token {
240                kind: TokenKind::Word(word),
241                start,
242                end,
243            }) => {
244                words.push(word.clone());
245                (start, end, start.line)
246            }
247            Some(Token {
248                kind: TokenKind::Number { text, .. },
249                start,
250                end,
251            }) => {
252                words.push(text.clone());
253                (start, end, start.line)
254            }
255            Some(token) => return Err(self.malformed("expected an identifier", &token)),
256            None => return Err(self.malformed("expected an identifier", self.eof())),
257        };
258        self.pos += 1;
259        while let Some(token) = self.peek() {
260            let (word, word_start, word_end) = match token {
261                Token {
262                    kind: TokenKind::Word(word),
263                    start,
264                    end,
265                } => (word, start, end),
266                Token {
267                    kind: TokenKind::Number { text, .. },
268                    start,
269                    end,
270                } => (text, start, end),
271                Token {
272                    kind: TokenKind::Dot,
273                    start,
274                    end,
275                } => (".".to_string(), start, end),
276                Token {
277                    kind: TokenKind::Op(op),
278                    start,
279                    end,
280                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
281                _ => break,
282            };
283            if word_start.line != line {
284                break;
285            }
286            words.push(word);
287            end = word_end;
288            self.pos += 1;
289        }
290        Ok((
291            words
292                .join(" ")
293                .replace(" .", ".")
294                .replace(". ", ".")
295                .replace(" : ", ":")
296                .replace(" %", "%"),
297            start,
298            end,
299        ))
300    }
301
302    pub(crate) fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
303        let (mut phrase, start, mut end) = self.phrase_on_line()?;
304        if matches!(
305            self.peek(),
306            Some(Token {
307                kind: TokenKind::Colon,
308                ..
309            })
310        ) {
311            let colon_pos = self.pos;
312            self.next();
313            let (rest, _, rest_end) = self.phrase_on_line()?;
314            if matches!(
315                self.peek(),
316                Some(Token {
317                    kind: TokenKind::LBrace,
318                    ..
319                })
320            ) {
321                phrase.push(':');
322                phrase.push(' ');
323                phrase.push_str(&rest);
324                end = rest_end;
325            } else {
326                self.pos = colon_pos;
327            }
328        }
329        Ok((phrase, start, end))
330    }
331
332    pub(crate) fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
333        let first = self
334            .peek()
335            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
336        let start = first.start;
337        let line = first.start.line;
338        let mut end = first.end;
339        let mut parts = Vec::new();
340        while let Some(token) = self.peek() {
341            if token.start.line != line
342                || matches!(token.kind, TokenKind::RParen | TokenKind::Comma)
343            {
344                break;
345            }
346            self.pos += 1;
347            end = token.end;
348            parts.push(raw_token_text(&token.kind));
349        }
350        if parts.is_empty() {
351            return Err(self.malformed("expected an enum member", &first));
352        }
353        Ok((
354            parts
355                .join(" ")
356                .replace(" : ", ":")
357                .replace(" .", ".")
358                .replace(". ", "."),
359            start,
360            end,
361        ))
362    }
363
364    /// Read a text line (tokens until `;`), joining words and dashes into
365    /// the literal text, and consume the terminating `;`.
366    pub(crate) fn line_text(&mut self) -> Result<String> {
367        let mut parts = Vec::new();
368        loop {
369            match self.peek() {
370                Some(Token {
371                    kind: TokenKind::Semi,
372                    ..
373                }) => {
374                    self.pos += 1;
375                    break;
376                }
377                Some(Token {
378                    kind: TokenKind::Word(word),
379                    ..
380                }) => {
381                    parts.push(word.clone());
382                    self.pos += 1;
383                }
384                Some(Token {
385                    kind: TokenKind::Op(op),
386                    ..
387                }) if op == "-" => {
388                    parts.push("-".to_string());
389                    self.pos += 1;
390                }
391                Some(Token {
392                    kind: TokenKind::Number { value, .. },
393                    ..
394                }) => {
395                    parts.push(value.to_string());
396                    self.pos += 1;
397                }
398                Some(Token {
399                    kind: TokenKind::Dot,
400                    ..
401                }) => {
402                    parts.push(".".to_string());
403                    self.pos += 1;
404                }
405                Some(Token {
406                    kind: TokenKind::Colon,
407                    ..
408                }) => {
409                    parts.push(":".to_string());
410                    self.pos += 1;
411                }
412                Some(token) => return Err(self.malformed("expected a text line", &token)),
413                None => return Err(self.malformed("unexpected end of input in line", self.eof())),
414            }
415        }
416        Ok(parts
417            .join(" ")
418            .replace(" .", ".")
419            .replace(". ", ".")
420            .replace(" : ", ":"))
421    }
422
423    /// Consume a known keyword phrase, verifying its spelling.
424    pub(crate) fn consume_phrase(&mut self, expected: &str) -> Result<()> {
425        let (phrase, _, _) = self.phrase()?;
426        if phrase != expected {
427            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
428        }
429        Ok(())
430    }
431
432    pub(crate) fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
433        match self.next() {
434            Some(Token {
435                kind: TokenKind::Word(word),
436                start,
437                ..
438            }) if self.canonical_keyword(&word) == expected => Ok(start),
439            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
440            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
441        }
442    }
443
444    pub(crate) fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
445        match self.next() {
446            Some(token) if token.kind == kind => Ok(()),
447            Some(token) => Err(self.malformed(message, &token)),
448            None => Err(self.malformed(message, self.eof())),
449        }
450    }
451
452    pub(crate) fn expect_string(&mut self, message: &str) -> Result<String> {
453        match self.next() {
454            Some(Token {
455                kind: TokenKind::String(content),
456                ..
457            }) => Ok(content),
458            Some(token) => Err(self.malformed(message, &token)),
459            None => Err(self.malformed(message, self.eof())),
460        }
461    }
462
463    pub(crate) fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
464        WorkshopError::Malformed {
465            message: message.to_string(),
466            span: Some(Span::new(self.file(), token.start, token.end)),
467        }
468    }
469
470    pub(crate) fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
471        WorkshopError::Unknown {
472            kind,
473            spelling: spelling.to_string(),
474            locale: self.locale.clone(),
475            span: None,
476        }
477    }
478
479    pub(crate) fn peek(&self) -> Option<Token> {
480        self.tokens.get(self.pos).cloned()
481    }
482
483    pub(crate) fn peek_at(&self, offset: usize) -> Option<Token> {
484        self.tokens.get(self.pos + offset).cloned()
485    }
486
487    pub(crate) fn next(&mut self) -> Option<Token> {
488        let token = self.tokens.get(self.pos).cloned();
489        if token.is_some() {
490            self.pos += 1;
491        }
492        token
493    }
494
495    pub(crate) fn previous(&self) -> &Token {
496        self.tokens
497            .get(self.pos.saturating_sub(1))
498            .unwrap_or_else(|| self.tokens.last().unwrap())
499    }
500
501    pub(crate) fn previous_span(&self) -> (Position, Position) {
502        let token = self.previous();
503        (token.start, token.end)
504    }
505
506    pub(crate) fn span_here(&self) -> (Position, Position) {
507        let token = self
508            .peek()
509            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
510        (token.start, token.end)
511    }
512
513    pub(crate) fn eof(&self) -> &Token {
514        self.tokens.last().unwrap()
515    }
516
517    pub(crate) fn file(&self) -> crate::core::ids::Id<SourceFile> {
518        crate::core::ids::Id::from_index(0)
519    }
520}
521
522pub(crate) fn is_comparison(op: &str) -> bool {
523    matches!(op, "==" | "!=" | "<" | "<=" | ">" | ">=")
524}
525
526pub(crate) fn canonical_keyword(keyword: &str) -> &str {
527    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
528    KEYWORDS
529        .get_or_init(|| {
530            serde_json::from_str(include_str!("structural_keywords.json"))
531                .expect("structural keyword data is valid JSON")
532        })
533        .get(keyword)
534        .map(String::as_str)
535        .unwrap_or(keyword)
536}
537
538pub(crate) fn raw_token_text(kind: &TokenKind) -> String {
539    match kind {
540        TokenKind::Word(value) => value.clone(),
541        TokenKind::Number { text, .. } => text.clone(),
542        TokenKind::String(value) => format!("\"{}\"", value.replace('"', "\\\"")),
543        TokenKind::Op(value) => value.clone(),
544        TokenKind::LParen => "(".to_string(),
545        TokenKind::RParen => ")".to_string(),
546        TokenKind::Comma => ",".to_string(),
547        TokenKind::Semi => ";".to_string(),
548        TokenKind::LBrace => "{".to_string(),
549        TokenKind::RBrace => "}".to_string(),
550        TokenKind::Colon => ":".to_string(),
551        TokenKind::Dot => ".".to_string(),
552        TokenKind::LBracket => "[".to_string(),
553        TokenKind::RBracket => "]".to_string(),
554        TokenKind::Eof => String::new(),
555    }
556}