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