Skip to main content

workshop_rs/
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
9use std::collections::HashMap;
10use std::sync::OnceLock;
11
12use crate::settings::table::{self, KeyKind, PathPart};
13use crate::settings::{Settings, SettingsListElement, SettingsNode};
14use crate::signatures::{ExpectedDomain, NoExpectedDomain};
15use crate::source::{Position, SourceFile, Span};
16use crate::wir::{
17    self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode,
18};
19
20use crate::catalog::{Catalog, Kind, Locale};
21use crate::error::{Result, WorkshopError};
22use crate::lexer::{Token, TokenKind, tokenize};
23
24/// Where action parsing stopped.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum 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
37enum AssignmentOperator {
38    Set,
39    Modify(ModifyOp),
40}
41
42/// Parse localized Workshop text into Workshop IR with no signature context:
43/// ambiguous bare enum members (e.g. the `None` shared by several domains)
44/// stay rejected. See [`parse_with_context`] for the context-sensitive form.
45pub fn parse(input: &str, catalog: &Catalog, locale: &Locale) -> Result<wir::Program> {
46    parse_with_context(input, catalog, locale, &NoExpectedDomain)
47}
48
49/// Parse localized Workshop text into Workshop IR, resolving ambiguous bare
50/// enum members from the enclosing call's canonical signature context (#111).
51///
52/// When a bare member spelling matches several enum domains, the parser asks
53/// [`ExpectedDomain::expected_domain`] for the domain the enclosing call's
54/// signature expects at that argument position; the member resolves only when
55/// that expected domain is one of the matching domains (i.e. the signature
56/// pins exactly one). Without a pin the ambiguity diagnostic is unchanged.
57pub fn parse_with_context(
58    input: &str,
59    catalog: &Catalog,
60    locale: &Locale,
61    context: &dyn ExpectedDomain,
62) -> Result<wir::Program> {
63    let tokens = tokenize(input).map_err(|error| WorkshopError::Malformed {
64        message: error.message,
65        span: Some(synthetic_span(error.position)),
66    })?;
67    Parser {
68        tokens,
69        pos: 0,
70        catalog,
71        locale: locale.clone(),
72        context,
73        expected_domain: None,
74        call_stack: Vec::new(),
75        target: wir::Program::default(),
76        globals: HashMap::new(),
77        players: HashMap::new(),
78        subroutines: HashMap::new(),
79    }
80    .program()
81}
82
83/// A synthetic single-position span (used before a file registry exists).
84fn synthetic_span(position: Position) -> Span {
85    Span::new(crate::ids::Id::from_index(0), position, position)
86}
87
88struct Parser<'a> {
89    tokens: Vec<Token>,
90    pos: usize,
91    catalog: &'a Catalog,
92    locale: Locale,
93    /// Canonical signature context (#111): supplies the expected enum domain
94    /// for the call argument currently being parsed.
95    context: &'a dyn ExpectedDomain,
96    /// The expected enum domain for the value currently being parsed, set by
97    /// [`Parser::value_args`] from the enclosing call's signature.
98    expected_domain: Option<&'a str>,
99    call_stack: Vec<String>,
100    target: wir::Program,
101    globals: HashMap<String, wir::GlobalVarId>,
102    players: HashMap<String, wir::PlayerVarId>,
103    subroutines: HashMap<String, wir::SubroutineId>,
104}
105
106impl Parser<'_> {
107    fn resolve_entry(&self, kind: Kind, spelling: &str) -> Option<crate::catalog::CatalogEntry> {
108        self.catalog
109            .resolve(kind, &self.locale, spelling)
110            .cloned()
111            .or_else(|| {
112                if self.locale != *self.catalog.primary_locale() {
113                    self.catalog
114                        .resolve(kind, self.catalog.primary_locale(), spelling)
115                        .cloned()
116                } else {
117                    None
118                }
119            })
120    }
121
122    fn canonical_keyword(&self, spelling: &str) -> String {
123        self.catalog
124            .resolve(Kind::Structural, &self.locale, spelling)
125            .or_else(|| {
126                (self.locale != *self.catalog.primary_locale()).then(|| {
127                    self.catalog
128                        .resolve(Kind::Structural, self.catalog.primary_locale(), spelling)
129                })?
130            })
131            .map(|entry| entry.id.clone())
132            .unwrap_or_else(|| canonical_keyword(spelling).to_string())
133    }
134
135    fn resolve_enum_domain_mixed(&self, spelling: &str) -> Option<&str> {
136        self.catalog
137            .resolve_enum_domain(&self.locale, spelling)
138            .or_else(|| {
139                if self.locale != *self.catalog.primary_locale() {
140                    self.catalog
141                        .resolve_enum_domain(self.catalog.primary_locale(), spelling)
142                } else {
143                    None
144                }
145            })
146    }
147
148    fn resolve_enum_member_mixed(&self, domain: &str, spelling: &str) -> Option<(String, String)> {
149        let alternate = (!spelling.contains(": ") && spelling.contains(':'))
150            .then(|| spelling.replacen(':', ": ", 1));
151        self.catalog
152            .resolve_enum_member(domain, &self.locale, spelling)
153            .or_else(|| {
154                alternate.as_deref().and_then(|spelling| {
155                    self.catalog
156                        .resolve_enum_member(domain, &self.locale, spelling)
157                })
158            })
159            .or_else(|| {
160                if self.locale != *self.catalog.primary_locale() {
161                    self.catalog.resolve_enum_member(
162                        domain,
163                        self.catalog.primary_locale(),
164                        alternate.as_deref().unwrap_or(spelling),
165                    )
166                } else {
167                    None
168                }
169            })
170    }
171
172    fn program(mut self) -> Result<wir::Program> {
173        let file = self.target.files.push(SourceFile::new("workshop.txt"));
174        // Re-point synthetic spans at the real file id by keeping a helper.
175        let _ = file;
176
177        loop {
178            let phrase = match self.peek() {
179                Some(Token {
180                    kind: TokenKind::Word(word),
181                    ..
182                }) => word.clone(),
183                Some(Token {
184                    kind: TokenKind::Eof,
185                    ..
186                }) => break,
187                Some(token) => {
188                    return Err(self.malformed("expected a top-level section", &token));
189                }
190                None => break,
191            };
192            match self.canonical_keyword(&phrase).as_str() {
193                "settings" => self.settings_section()?,
194                "variables" => self.variables_section()?,
195                "subroutines" => self.subroutines_section()?,
196                "rule" => self.rule(false)?,
197                "disabled" => {
198                    self.pos += 1;
199                    self.rule(true)?;
200                }
201                other => {
202                    return Err(self.unknown("top-level section", other));
203                }
204            }
205        }
206        Ok(self.target)
207    }
208
209    fn settings_section(&mut self) -> Result<()> {
210        let start = self.expect_keyword("settings")?;
211        self.expect(TokenKind::LBrace, "expected '{' after 'settings'")?;
212        let mut children = Vec::new();
213        while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
214            let (display, child_start, _) = self.phrase()?;
215            let canonical_display = self.canonical_keyword(&display);
216            let name = match canonical_display.as_str() {
217                value
218                    if value == "extensions"
219                        || display == "扩展"
220                        || self.settings_name_matches("labels", "Extensions", &display) =>
221                {
222                    "extensions"
223                }
224                value
225                    if value == "workshop"
226                        || table::localized_name(
227                            self.locale.as_str(),
228                            "namespaces",
229                            "workshop",
230                        )
231                        .is_some_and(|name| name == display) =>
232                {
233                    "workshop"
234                }
235                value => value,
236            };
237            self.expect(TokenKind::LBrace, "expected '{' after settings group")?;
238            let node = match name {
239                "main" | "lobby" => SettingsNode::Group {
240                    name: name.to_string(),
241                    children: self.settings_members(
242                        &[PathPart::Part(if name == "main" {
243                            "main"
244                        } else {
245                            "lobby"
246                        })],
247                        None,
248                    )?,
249                    span: Some(self.settings_span(child_start)),
250                },
251                "modes" => self.settings_modes(child_start)?,
252                "heroes" => self.settings_heroes(child_start)?,
253                "extensions" => SettingsNode::Group {
254                    name: "extensions".to_string(),
255                    children: self.settings_members(&[PathPart::Part("extensions")], None)?,
256                    span: Some(self.settings_span(child_start)),
257                },
258                "workshop" => SettingsNode::Workshop {
259                    children: self.settings_opaque_members()?,
260                    span: Some(self.settings_span(child_start)),
261                },
262                _ => self.settings_opaque_group(name, child_start)?,
263            };
264            children.push(node);
265        }
266        let end = match self.next() {
267            Some(Token {
268                kind: TokenKind::RBrace,
269                end,
270                ..
271            }) => end,
272            _ => unreachable!("settings loop checks for closing brace"),
273        };
274        self.target.settings = Some(Settings {
275            span: Some(Span::new(self.file(), start, end)),
276            children,
277        });
278        Ok(())
279    }
280
281    fn settings_modes(&mut self, start: Position) -> Result<SettingsNode> {
282        let mut children = Vec::new();
283        while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
284            let mut disabled = false;
285            if let Some(Token {
286                kind: TokenKind::Word(word),
287                ..
288            }) = self.peek()
289            {
290                if self.settings_name_matches("tokens", "disabled", &word) {
291                    self.pos += 1;
292                    disabled = true;
293                }
294            }
295            let (display, mode_start, _) = self.phrase_on_line()?;
296            let mode = self
297                .resolve_settings_name_extended(
298                    table::MODE_NAMES,
299                    table::GENERATED_MODE_NAMES,
300                    "modes",
301                    &display,
302                )
303                .ok();
304            self.expect(TokenKind::LBrace, "expected '{' after game mode")?;
305            let mut mode_children = if let Some(mode) = mode {
306                self.settings_members(&[PathPart::Part("gamemodes"), PathPart::Part(mode)], None)?
307            } else {
308                self.settings_opaque_members()?
309            };
310            if disabled {
311                mode_children.insert(
312                    0,
313                    SettingsNode::Bool {
314                        name: "enabled".to_string(),
315                        value: false,
316                        span: None,
317                    },
318                );
319            }
320            children.push(SettingsNode::Group {
321                name: mode.map(str::to_string).unwrap_or(display),
322                children: mode_children,
323                span: Some(self.settings_span(mode_start)),
324            });
325        }
326        self.expect(TokenKind::RBrace, "expected '}' after modes")?;
327        Ok(SettingsNode::Group {
328            name: "gamemodes".to_string(),
329            children,
330            span: Some(self.settings_span(start)),
331        })
332    }
333
334    fn settings_heroes(&mut self, start: Position) -> Result<SettingsNode> {
335        let mut teams = Vec::new();
336        while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
337            let (team_display, team_start, _) = self.phrase_on_line_with_colon()?;
338            let team = self.resolve_settings_name(table::TEAM_NAMES, "teams", &team_display)?;
339            self.expect(TokenKind::LBrace, "expected '{' after team settings group")?;
340            let mut team_children = Vec::new();
341            while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
342                let (display, child_start, child_end) = self.phrase_on_line_with_colon()?;
343                if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LBrace))
344                    && self
345                        .resolve_settings_name_extended(
346                            table::HERO_NAMES,
347                            table::GENERATED_HERO_NAMES,
348                            "heroes",
349                            &display,
350                        )
351                        .is_ok()
352                {
353                    let hero = self.resolve_settings_name_extended(
354                        table::HERO_NAMES,
355                        table::GENERATED_HERO_NAMES,
356                        "heroes",
357                        &display,
358                    )?;
359                    self.expect(TokenKind::LBrace, "expected '{' after hero settings group")?;
360                    let children = self.settings_members(
361                        &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
362                        Some(hero),
363                    )?;
364                    team_children.push(SettingsNode::Group {
365                        name: hero.to_string(),
366                        children,
367                        span: Some(self.settings_span(child_start)),
368                    });
369                } else {
370                    team_children.push(self.settings_member_named(
371                        display,
372                        child_start,
373                        child_end,
374                        &[PathPart::Part("heroes"), PathPart::Team],
375                        None,
376                    )?);
377                }
378            }
379            self.expect(TokenKind::RBrace, "expected '}' after team settings group")?;
380            teams.push(SettingsNode::Group {
381                name: team.to_string(),
382                children: team_children,
383                span: Some(self.settings_span(team_start)),
384            });
385        }
386        self.expect(TokenKind::RBrace, "expected '}' after heroes")?;
387        Ok(SettingsNode::Group {
388            name: "heroes".to_string(),
389            children: teams,
390            span: Some(self.settings_span(start)),
391        })
392    }
393
394    fn settings_members(
395        &mut self,
396        path: &[PathPart<'static>],
397        hero: Option<&str>,
398    ) -> Result<Vec<SettingsNode>> {
399        let mut children = Vec::new();
400        while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
401            let (display, start, end) = self.phrase_on_line()?;
402            children.push(self.settings_member_named(display, start, end, path, hero)?);
403        }
404        self.expect(TokenKind::RBrace, "expected '}' after settings group")?;
405        Ok(children)
406    }
407
408    fn settings_member_named(
409        &mut self,
410        display: String,
411        start: Position,
412        _end: Position,
413        path: &[PathPart<'static>],
414        hero: Option<&str>,
415    ) -> Result<SettingsNode> {
416        let entry = table::entries().find(|candidate| {
417            candidate.path.len() == path.len() + 1
418                && candidate.path[..path.len()]
419                    .iter()
420                    .zip(path.iter())
421                    .all(|(left, right)| left == right)
422                && self.settings_name_matches_for_path(candidate, &display, hero)
423        });
424        let Some(entry) = entry else {
425            if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LBrace)) {
426                self.pos += 1;
427                return self.settings_opaque_group(&display, start);
428            }
429            return self.settings_raw_member(display, start);
430        };
431        let name = match entry.path.last() {
432            Some(PathPart::Part(name)) => *name,
433            _ => return Err(self.malformed("settings entry has no leaf key", self.previous())),
434        };
435        if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LBrace)) {
436            self.expect(TokenKind::LBrace, "expected '{' after settings list")?;
437            let mut elements = Vec::new();
438            while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
439                let (value, value_start, value_end) = self.phrase_on_line()?;
440                let canonical = match entry.kind {
441                    KeyKind::ListMap => self
442                        .resolve_settings_name_extended(
443                            table::MAP_NAMES,
444                            table::GENERATED_MAP_NAMES,
445                            "maps",
446                            &value,
447                        )
448                        .or_else(|_| {
449                            value
450                                .split_whitespace()
451                                .next()
452                                .and_then(|name| {
453                                    self.resolve_settings_name_extended(
454                                        table::MAP_NAMES,
455                                        table::GENERATED_MAP_NAMES,
456                                        "maps",
457                                        name,
458                                    )
459                                    .ok()
460                                })
461                                .ok_or_else(|| self.unknown("setting", &value))
462                        })
463                        .unwrap_or(value.as_str()),
464                    KeyKind::ListHero => self
465                        .resolve_settings_name_extended(
466                            table::HERO_NAMES,
467                            table::GENERATED_HERO_NAMES,
468                            "heroes",
469                            &value,
470                        )
471                        .unwrap_or(value.as_str()),
472                    _ => {
473                        return Err(
474                            self.malformed("only settings lists may use braces", self.previous())
475                        );
476                    }
477                };
478                elements.push(SettingsListElement {
479                    value: canonical.to_string(),
480                    span: Some(Span::new(self.file(), value_start, value_end)),
481                });
482            }
483            self.expect(TokenKind::RBrace, "expected '}' after settings list")?;
484            return Ok(SettingsNode::List {
485                name: name.to_string(),
486                elements,
487                span: Some(Span::new(self.file(), start, self.previous_span().1)),
488            });
489        }
490        if matches!(entry.kind, KeyKind::Flag) {
491            return Ok(SettingsNode::Flag {
492                name: name.to_string(),
493                span: Some(Span::new(self.file(), start, self.previous_span().1)),
494            });
495        }
496        self.expect(TokenKind::Colon, "expected ':' after settings key")?;
497        let end = self.previous_span().1;
498        let span = Some(Span::new(self.file(), start, end));
499        match entry.kind {
500            KeyKind::Flag => unreachable!("presence-only settings returned before ':'"),
501            KeyKind::String => Ok(SettingsNode::String {
502                name: name.to_string(),
503                value: self.expect_string("expected a settings string")?,
504                span,
505            }),
506            KeyKind::Number => Ok(SettingsNode::Number {
507                name: name.to_string(),
508                value: self.settings_number(false)?,
509                span,
510            }),
511            KeyKind::Percent => Ok(SettingsNode::Number {
512                name: name.to_string(),
513                value: self.settings_number_percent()?,
514                span,
515            }),
516            KeyKind::Bool => Ok(SettingsNode::Bool {
517                name: name.to_string(),
518                value: self.settings_bool()?,
519                span,
520            }),
521            KeyKind::Enum(domain) => Ok(SettingsNode::String {
522                name: name.to_string(),
523                value: self.resolve_enum_settings_name(domain)?,
524                span,
525            }),
526            KeyKind::ListMap | KeyKind::ListHero => {
527                Err(self.malformed("settings list requires a brace block", self.previous()))
528            }
529        }
530    }
531
532    fn settings_opaque_group(&mut self, name: &str, start: Position) -> Result<SettingsNode> {
533        Ok(SettingsNode::Group {
534            name: name.to_string(),
535            children: self.settings_opaque_members()?,
536            span: Some(self.settings_span(start)),
537        })
538    }
539
540    fn settings_opaque_members(&mut self) -> Result<Vec<SettingsNode>> {
541        let mut children = Vec::new();
542        while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
543            let (display, start, _) = self.opaque_name_on_line()?;
544            if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LBrace)) {
545                self.pos += 1;
546                children.push(self.settings_opaque_group(&display, start)?);
547            } else {
548                children.push(self.settings_raw_member(display, start)?);
549            }
550        }
551        self.expect(TokenKind::RBrace, "expected '}' after settings group")?;
552        Ok(children)
553    }
554
555    fn settings_raw_member(&mut self, name: String, start: Position) -> Result<SettingsNode> {
556        let mut value = String::new();
557        if matches!(self.peek().map(|token| token.kind), Some(TokenKind::Colon)) {
558            self.pos += 1;
559            value = self.raw_settings_line()?;
560        }
561        let end = self.previous_span().1;
562        Ok(SettingsNode::Raw {
563            name,
564            value,
565            span: Some(Span::new(self.file(), start, end)),
566        })
567    }
568
569    fn opaque_name_on_line(&mut self) -> Result<(String, Position, Position)> {
570        let first = self
571            .peek()
572            .ok_or_else(|| self.malformed("expected an identifier", self.eof()))?;
573        let start = first.start;
574        let line = first.start.line;
575        let mut end = first.end;
576        let mut parts = Vec::new();
577        while let Some(token) = self.peek() {
578            if token.start.line != line
579                || matches!(
580                    token.kind,
581                    TokenKind::Colon | TokenKind::LBrace | TokenKind::RBrace
582                )
583            {
584                break;
585            }
586            self.pos += 1;
587            end = token.end;
588            parts.push(raw_token_text(&token.kind));
589        }
590        if parts.is_empty() {
591            return Err(self.malformed("expected an identifier", &first));
592        }
593        Ok((
594            parts
595                .join(" ")
596                .replace(" : ", ":")
597                .replace(" .", ".")
598                .replace(". ", "."),
599            start,
600            end,
601        ))
602    }
603
604    fn raw_settings_line(&mut self) -> Result<String> {
605        let line = self.peek().map(|token| token.start.line);
606        let mut parts = Vec::new();
607        while let Some(token) = self.peek() {
608            if line.is_some_and(|line| token.start.line != line)
609                || matches!(token.kind, TokenKind::RBrace)
610            {
611                break;
612            }
613            self.pos += 1;
614            parts.push(raw_token_text(&token.kind));
615        }
616        Ok(parts.join(" "))
617    }
618
619    fn settings_number(&mut self, percent: bool) -> Result<f64> {
620        let value = match self.next() {
621            Some(Token {
622                kind: TokenKind::Number { value, .. },
623                ..
624            }) => value,
625            Some(token) => return Err(self.malformed("expected a settings number", &token)),
626            None => return Err(self.malformed("expected a settings number", self.eof())),
627        };
628        if percent {
629            self.expect(
630                TokenKind::Op("%".to_string()),
631                "expected '%' after settings percentage",
632            )?;
633        }
634        Ok(value)
635    }
636
637    fn settings_number_percent(&mut self) -> Result<f64> {
638        let value = self.settings_number(false)?;
639        if matches!(
640            self.peek(),
641            Some(Token {
642                kind: TokenKind::Op(op),
643                ..
644            }) if op == "%"
645        ) {
646            self.pos += 1;
647        }
648        Ok(value)
649    }
650
651    fn settings_bool(&mut self) -> Result<bool> {
652        let token = self
653            .next()
654            .ok_or_else(|| self.malformed("expected a settings boolean", self.eof()))?;
655        let TokenKind::Word(value) = token.kind else {
656            return Err(self.malformed("expected a settings boolean", &token));
657        };
658        if self.settings_name_matches("tokens", "On", &value)
659            || self.settings_name_matches("tokens", "Yes", &value)
660        {
661            Ok(true)
662        } else if self.settings_name_matches("tokens", "Off", &value)
663            || self.settings_name_matches("tokens", "No", &value)
664        {
665            Ok(false)
666        } else {
667            Err(self.unknown("setting boolean", &value))
668        }
669    }
670
671    fn resolve_enum_settings_name(&mut self, domain: &str) -> Result<String> {
672        let (display, _, _) = self.phrase_on_line()?;
673        table::ENUM_MEMBERS
674            .iter()
675            .find(|member| {
676                member.domain == domain
677                    && self.settings_name_matches("enums", member.name, &display)
678            })
679            .map(|member| member.member.to_string())
680            .or_else(|| {
681                table::GENERATED_ENUM_MEMBERS
682                    .iter()
683                    .find(|member| {
684                        member.domain == domain
685                            && self.settings_name_matches("enums", member.name, &display)
686                    })
687                    .map(|member| member.member.to_string())
688            })
689            .ok_or_else(|| self.unknown("settings enum", &display))
690    }
691
692    fn resolve_settings_name(
693        &self,
694        names: &[table::NameMap],
695        section: &str,
696        display: &str,
697    ) -> Result<&'static str> {
698        names
699            .iter()
700            .find(|candidate| self.settings_name_matches(section, candidate.name, display))
701            .map(|candidate| candidate.key)
702            .ok_or_else(|| self.unknown("setting", display))
703    }
704
705    fn resolve_settings_name_extended(
706        &self,
707        names: &[table::NameMap],
708        generated: &[table::NameMap],
709        section: &str,
710        display: &str,
711    ) -> Result<&'static str> {
712        names
713            .iter()
714            .chain(generated.iter())
715            .find(|candidate| self.settings_name_matches(section, candidate.name, display))
716            .map(|candidate| candidate.key)
717            .ok_or_else(|| self.unknown("setting", display))
718    }
719
720    fn settings_name_matches_for_path(
721        &self,
722        candidate: &table::TableEntry,
723        display: &str,
724        hero: Option<&str>,
725    ) -> bool {
726        if let Some(PathPart::Part(key)) = candidate.path.last() {
727            if display == *key {
728                return true;
729            }
730        }
731        if let (Some(hero), Some(PathPart::Part(key))) = (hero, candidate.path.last()) {
732            if table::hero_setting_name(hero, key, self.locale.as_str()) == Some(display) {
733                return true;
734            }
735            if table::hero_setting_alias(hero, key, self.locale.as_str(), display) {
736                return true;
737            }
738        }
739        if let (Some(hero), Some(slot)) = (hero, table::ability_slot_for_path(candidate.path)) {
740            if candidate.workshop_name.contains("%1$s")
741                || matches!(
742                    candidate.path.last(),
743                    Some(PathPart::Part("enableAbility1" | "enableAbility2"))
744                )
745            {
746                return crate::gameplay_data::builtin()
747                    .ok()
748                    .and_then(|catalog| {
749                        catalog
750                            .query()
751                            .ability_name(hero, slot, None, self.locale.as_str())
752                            .ok()
753                            .map(|name| name == display)
754                    })
755                    .unwrap_or(false);
756            }
757        }
758        self.settings_name_matches("labels", candidate.workshop_name, display)
759    }
760
761    fn settings_name_matches(&self, section: &str, english: &str, display: &str) -> bool {
762        let localized = table::localized_name(self.locale.as_str(), section, english);
763        localized
764            .is_some_and(|localized| localized == display)
765            // Real Workshop exports can mix the selected locale with
766            // primary-locale labels when a reviewed mapping is absent.
767            // Accept that source spelling for parsing, while emission
768            // still fails explicitly if the target mapping is missing.
769            || display == english
770    }
771
772    fn settings_span(&self, start: Position) -> Span {
773        Span::new(self.file(), start, self.previous_span().1)
774    }
775
776    fn variables_section(&mut self) -> Result<()> {
777        self.expect_keyword("variables")?;
778        self.expect(TokenKind::LBrace, "expected '{' after 'variables'")?;
779        let mut saw_section = false;
780        loop {
781            match self.peek() {
782                Some(Token {
783                    kind: TokenKind::RBrace,
784                    ..
785                }) => {
786                    self.pos += 1;
787                    break;
788                }
789                Some(Token {
790                    kind: TokenKind::Word(word),
791                    ..
792                }) if matches!(canonical_keyword(&word), "Global" | "global") => {
793                    self.pos += 1;
794                    self.expect(TokenKind::Colon, "expected ':' after 'global'")?;
795                    while let Some(Token {
796                        kind: TokenKind::Number { .. },
797                        ..
798                    }) = self.peek()
799                    {
800                        let variable = self.variable_line()?;
801                        let id = self.target.global_variables.push(variable);
802                        self.globals.insert(
803                            self.target.global_variables.get(id).unwrap().name.clone(),
804                            id,
805                        );
806                    }
807                    saw_section = true;
808                }
809                Some(Token {
810                    kind: TokenKind::Word(word),
811                    ..
812                }) if canonical_keyword(&word) == "player" => {
813                    self.pos += 1;
814                    self.expect(TokenKind::Colon, "expected ':' after 'player'")?;
815                    while let Some(Token {
816                        kind: TokenKind::Number { .. },
817                        ..
818                    }) = self.peek()
819                    {
820                        let variable = self.variable_line()?;
821                        let id = self.target.player_variables.push(variable);
822                        self.players.insert(
823                            self.target.player_variables.get(id).unwrap().name.clone(),
824                            id,
825                        );
826                    }
827                    saw_section = true;
828                }
829                Some(token) => {
830                    return Err(self.malformed("expected 'global', 'player', or '}'", &token));
831                }
832                None => {
833                    return Err(self.malformed("unexpected end of input in variables", self.eof()));
834                }
835            }
836        }
837        if !saw_section {
838            return Err(self.malformed("variables section is empty", self.previous()));
839        }
840        Ok(())
841    }
842
843    fn variable_line(&mut self) -> Result<wir::WorkshopVariable> {
844        let (index, span) = match self.next() {
845            Some(Token {
846                kind: TokenKind::Number { value, .. },
847                start,
848                end,
849            }) => (
850                value as u32,
851                Span::new(synthetic_span(start).file, start, end),
852            ),
853            Some(token) => return Err(self.malformed("expected a variable index", &token)),
854            None => return Err(self.malformed("expected a variable index", self.eof())),
855        };
856        self.expect(TokenKind::Colon, "expected ':' after variable index")?;
857        let (name, name_start, name_end) = self.phrase_on_line()?;
858        let name_span = Span::new(self.file(), name_start, name_end);
859        Ok(wir::WorkshopVariable {
860            name,
861            index,
862            span: Some(if span.file.index() == 0 {
863                name_span
864            } else {
865                span
866            }),
867            // Workshop-text sources carry no `.opy` identifier provenance;
868            // exact rename occurrences are only produced by the native path.
869            name_span: None,
870        })
871    }
872
873    fn subroutines_section(&mut self) -> Result<()> {
874        self.expect_keyword("subroutines")?;
875        self.expect(TokenKind::LBrace, "expected '{' after 'subroutines'")?;
876        while let Some(Token {
877            kind: TokenKind::Number { .. },
878            ..
879        }) = self.peek()
880        {
881            let index = match self.next() {
882                Some(Token {
883                    kind: TokenKind::Number { value, .. },
884                    ..
885                }) => value as u32,
886                _ => unreachable!(),
887            };
888            self.expect(TokenKind::Colon, "expected ':' after subroutine index")?;
889            let (name, start, end) = self.phrase_on_line()?;
890            let id = self.target.subroutines.push(wir::WorkshopSubroutine {
891                name,
892                index,
893                span: Some(Span::new(self.file(), start, end)),
894                name_span: None,
895            });
896            self.subroutines
897                .insert(self.target.subroutines.get(id).unwrap().name.clone(), id);
898        }
899        self.expect(TokenKind::RBrace, "expected '}' after subroutines")?;
900        Ok(())
901    }
902
903    fn rule(&mut self, disabled: bool) -> Result<()> {
904        self.expect_keyword("rule")?;
905        self.expect(TokenKind::LParen, "expected '(' after 'rule'")?;
906        let name = self.expect_string("expected a rule name string")?;
907        self.expect(TokenKind::RParen, "expected ')' after rule name")?;
908        let (rule_start, rule_end) = self.previous_span();
909        self.expect(TokenKind::LBrace, "expected '{' after rule header")?;
910
911        let mut rule = wir::Rule {
912            name,
913            span: Some(Span::new(self.file(), rule_start, rule_end)),
914            name_span: None,
915            disabled,
916            event: Event::Global,
917            conditions: Vec::new(),
918            actions: Vec::new(),
919        };
920        let mut seen_sections = Vec::new();
921        loop {
922            match self.peek() {
923                Some(Token {
924                    kind: TokenKind::RBrace,
925                    ..
926                }) => {
927                    self.pos += 1;
928                    break;
929                }
930                Some(Token {
931                    kind: TokenKind::Word(word),
932                    ..
933                }) => match self.canonical_keyword(&word).as_str() {
934                    "event" => {
935                        if seen_sections.contains(&"event") {
936                            return Err(
937                                self.malformed("duplicate 'event' section", &self.peek().unwrap())
938                            );
939                        }
940                        seen_sections.push("event");
941                        rule.event = self.event_section()?;
942                    }
943                    "conditions" => {
944                        if seen_sections.contains(&"conditions") {
945                            return Err(self.malformed(
946                                "duplicate 'conditions' section",
947                                &self.peek().unwrap(),
948                            ));
949                        }
950                        seen_sections.push("conditions");
951                        rule.conditions = self.conditions_section()?;
952                    }
953                    "actions" => {
954                        if seen_sections.contains(&"actions") {
955                            return Err(self
956                                .malformed("duplicate 'actions' section", &self.peek().unwrap()));
957                        }
958                        seen_sections.push("actions");
959                        rule.actions = self.actions_section()?;
960                    }
961                    _ => return Err(self.unknown("rule section", &word)),
962                },
963                Some(token) => {
964                    return Err(self.malformed("expected a rule section or '}'", &token));
965                }
966                None => return Err(self.malformed("unexpected end of input in rule", self.eof())),
967            }
968        }
969        self.target.rules.push(rule);
970        Ok(())
971    }
972
973    fn event_section(&mut self) -> Result<Event> {
974        self.expect_keyword("event")?;
975        self.expect(TokenKind::LBrace, "expected '{' after 'event'")?;
976        let mut lines: Vec<String> = Vec::new();
977        loop {
978            match self.peek() {
979                Some(Token {
980                    kind: TokenKind::RBrace,
981                    ..
982                }) => {
983                    self.pos += 1;
984                    break;
985                }
986                Some(Token {
987                    kind: TokenKind::Semi,
988                    ..
989                }) => {
990                    self.pos += 1;
991                    lines.push(String::new());
992                }
993                Some(_) => {
994                    let text = self.line_text()?;
995                    lines.push(text);
996                }
997                None => return Err(self.malformed("unexpected end of input in event", self.eof())),
998            }
999        }
1000        let Some(name_line) = lines.first().cloned() else {
1001            return Err(self.malformed("event section is empty", self.previous()));
1002        };
1003        let name_line = name_line.trim();
1004        let entry = self
1005            .catalog
1006            .resolve(Kind::Event, &self.locale, name_line)
1007            .ok_or_else(|| WorkshopError::Unknown {
1008                kind: "event",
1009                spelling: name_line.to_string(),
1010                locale: self.locale.clone(),
1011                span: None,
1012            })?;
1013        match entry.id.as_str() {
1014            "global" => {
1015                if lines[1..].iter().any(|line| !line.trim().is_empty()) {
1016                    return Err(self.unsupported_event_parameters("global"));
1017                }
1018                Ok(Event::Global)
1019            }
1020            "eachPlayer" => {
1021                if lines[1..].iter().all(|line| line.trim().is_empty()) {
1022                    return Ok(Event::EachPlayer);
1023                }
1024                let (team, target) = self.event_filters(&lines, "eachPlayer", true)?;
1025                Ok(Event::EachPlayerWithFilters { team, target })
1026            }
1027            "playerDealtDamage" => self.player_event(&lines, PlayerEventKind::DealtDamage),
1028            "playerDealtFinalBlow" => self.player_event(&lines, PlayerEventKind::DealtFinalBlow),
1029            "playerDealtHealing" => self.player_event(&lines, PlayerEventKind::DealtHealing),
1030            "playerDealtKnockback" => self.player_event(&lines, PlayerEventKind::DealtKnockback),
1031            "playerDied" => self.player_event(&lines, PlayerEventKind::Died),
1032            "playerEarnedElimination" => {
1033                self.player_event(&lines, PlayerEventKind::EarnedElimination)
1034            }
1035            "playerJoined" => self.player_event(&lines, PlayerEventKind::Joined),
1036            "playerLeft" => self.player_event(&lines, PlayerEventKind::Left),
1037            "playerReceivedHealing" => self.player_event(&lines, PlayerEventKind::ReceivedHealing),
1038            "playerReceivedKnockback" => {
1039                self.player_event(&lines, PlayerEventKind::ReceivedKnockback)
1040            }
1041            "playerTookDamage" => self.player_event(&lines, PlayerEventKind::TookDamage),
1042            "subroutine" => {
1043                if lines
1044                    .get(2..)
1045                    .unwrap_or(&[])
1046                    .iter()
1047                    .any(|line| !line.trim().is_empty())
1048                {
1049                    return Err(self.unsupported_event_parameters("subroutine"));
1050                }
1051                let Some(sub_name) = lines.get(1).map(|s| s.trim()) else {
1052                    return Err(self.malformed(
1053                        "subroutine event requires a subroutine name",
1054                        self.previous(),
1055                    ));
1056                };
1057                let id = self.subroutine_by_name(sub_name)?;
1058                Ok(Event::Subroutine(id))
1059            }
1060            other => Err(WorkshopError::Unsupported {
1061                message: format!("unsupported event '{other}'"),
1062                span: None,
1063            }),
1064        }
1065    }
1066
1067    fn player_event(&self, lines: &[String], kind: PlayerEventKind) -> Result<Event> {
1068        let (team, target) = self.event_filters(lines, kind.catalog_id(), false)?;
1069        Ok(Event::Player { kind, team, target })
1070    }
1071
1072    fn event_filters(
1073        &self,
1074        lines: &[String],
1075        event_id: &str,
1076        allow_empty: bool,
1077    ) -> Result<(EventTeam, EventTarget)> {
1078        let parameters: Vec<&str> = lines[1..]
1079            .iter()
1080            .map(String::as_str)
1081            .map(str::trim)
1082            .filter(|line| !line.is_empty())
1083            .collect();
1084        if parameters.is_empty() {
1085            if allow_empty {
1086                return Ok((EventTeam::All, EventTarget::All));
1087            }
1088            return Err(WorkshopError::Malformed {
1089                message: format!("event '{event_id}' requires team and player parameters"),
1090                span: None,
1091            });
1092        }
1093        if parameters.len() != 2 {
1094            if event_id == "eachPlayer" {
1095                return Err(WorkshopError::Unsupported {
1096                    message: format!("event '{event_id}' requires both team and player parameters"),
1097                    span: None,
1098                });
1099            }
1100            return Err(WorkshopError::Malformed {
1101                message: format!("event '{event_id}' requires team and player parameters"),
1102                span: None,
1103            });
1104        }
1105        let team_member = self
1106            .resolve_enum_member_mixed("EventTeam", parameters[0])
1107            .map(|(_, member)| member);
1108        let team = match team_member.as_deref() {
1109            Some("ALL") => EventTeam::All,
1110            Some("TEAM_1") => EventTeam::Team1,
1111            Some("TEAM_2") => EventTeam::Team2,
1112            _ => return Err(self.unknown("event team", parameters[0])),
1113        };
1114        let target = if let Some((_, member)) =
1115            self.resolve_enum_member_mixed("EventPlayer", parameters[1])
1116        {
1117            if member == "ALL" {
1118                EventTarget::All
1119            } else if let Some(slot) = member.strip_prefix("SLOT_") {
1120                let slot = slot
1121                    .parse::<u8>()
1122                    .map_err(|_| self.unknown("event player", parameters[1]))?;
1123                EventTarget::Slot(slot)
1124            } else {
1125                return Err(self.unknown("event player", parameters[1]));
1126            }
1127        } else if let Some((_, hero)) = self
1128            .catalog
1129            .bare_member_matches(&self.locale, parameters[1])
1130            .into_iter()
1131            .chain(
1132                self.catalog
1133                    .bare_member_matches(&self.locale, &parameters[1].replace(':', ": ")),
1134            )
1135            .find(|(domain, _)| domain == "Hero")
1136        {
1137            EventTarget::Hero(hero)
1138        } else {
1139            return Err(self.unknown("event player", parameters[1]));
1140        };
1141        Ok((team, target))
1142    }
1143
1144    fn unsupported_event_parameters(&self, event_id: &str) -> WorkshopError {
1145        WorkshopError::Unsupported {
1146            message: format!("event '{event_id}' does not accept parameters"),
1147            span: None,
1148        }
1149    }
1150
1151    fn conditions_section(&mut self) -> Result<Vec<wir::ValueId>> {
1152        self.expect_keyword("conditions")?;
1153        self.expect(TokenKind::LBrace, "expected '{' after 'conditions'")?;
1154        let mut conditions = Vec::new();
1155        loop {
1156            match self.peek() {
1157                Some(Token {
1158                    kind: TokenKind::RBrace,
1159                    ..
1160                }) => {
1161                    self.pos += 1;
1162                    break;
1163                }
1164                Some(Token {
1165                    kind: TokenKind::Semi,
1166                    ..
1167                }) => {
1168                    self.pos += 1;
1169                }
1170                Some(_) => {
1171                    if matches!(
1172                        self.peek(),
1173                        Some(Token {
1174                            kind: TokenKind::String(_),
1175                            ..
1176                        })
1177                    ) {
1178                        self.pos += 1;
1179                        continue;
1180                    }
1181                    if let Some(Token {
1182                        kind: TokenKind::Word(word),
1183                        ..
1184                    }) = self.peek()
1185                    {
1186                        if self.settings_name_matches("tokens", "disabled", &word) {
1187                            self.pos += 1;
1188                            let _disabled_condition = self.value()?;
1189                            self.expect(TokenKind::Semi, "expected ';' after condition")?;
1190                            continue;
1191                        }
1192                    }
1193                    let condition = self.value()?;
1194                    self.expect(TokenKind::Semi, "expected ';' after condition")?;
1195                    conditions.push(condition);
1196                }
1197                None => {
1198                    return Err(self.malformed("unexpected end of input in conditions", self.eof()));
1199                }
1200            }
1201        }
1202        Ok(conditions)
1203    }
1204
1205    fn actions_section(&mut self) -> Result<Vec<wir::ActionId>> {
1206        self.expect_keyword("actions")?;
1207        self.expect(TokenKind::LBrace, "expected '{' after 'actions'")?;
1208        let mut all_actions = Vec::new();
1209        loop {
1210            let (actions, stop) = self.actions_until_end()?;
1211            all_actions.extend(actions);
1212            if stop == Stop::SectionClosed {
1213                return Ok(all_actions);
1214            }
1215            // Some exported Workshop artifacts retain an unmatched structural
1216            // marker after source-side pruning. Preserve it as an opaque raw
1217            // action so the source remains visible instead of silently
1218            // discarding it or rejecting the whole project.
1219            all_actions.push(self.opaque_action()?);
1220        }
1221    }
1222
1223    /// Parse actions until a structural `else`/`elseIf`/`end` terminator
1224    /// (not consumed; the token position is preserved) or the enclosing `}`
1225    /// (consumed). Returns where the parse stopped.
1226    fn actions_until_end(&mut self) -> Result<(Vec<wir::ActionId>, Stop)> {
1227        let mut actions = Vec::new();
1228        loop {
1229            match self.peek() {
1230                Some(Token {
1231                    kind: TokenKind::RBrace,
1232                    ..
1233                }) => {
1234                    self.pos += 1;
1235                    return Ok((actions, Stop::SectionClosed));
1236                }
1237                Some(Token {
1238                    kind: TokenKind::Word(_),
1239                    ..
1240                }) => {
1241                    if let Some(action) = self.assignment_action()? {
1242                        actions.push(action);
1243                        continue;
1244                    }
1245                    let saved = self.pos;
1246                    let (phrase, start, end) = self.phrase()?;
1247                    if let Some(rest) = self.disabled_action_rest(&phrase) {
1248                        if let Some(structural) = self.resolve_entry(Kind::Structural, rest) {
1249                            match structural.id.as_str() {
1250                                "while" => {
1251                                    actions.push(self.while_group()?);
1252                                    continue;
1253                                }
1254                                "if" => {
1255                                    actions.push(self.if_group()?);
1256                                    continue;
1257                                }
1258                                _ => {}
1259                            }
1260                        }
1261                        if self.resolve_entry(Kind::Action, rest).is_some() {
1262                            let canonical = rest;
1263                            actions.push(self.action_call_from_phrase(
1264                                canonical.to_string(),
1265                                start,
1266                                end,
1267                            )?);
1268                            continue;
1269                        }
1270                    }
1271                    if canonical_keyword(&phrase) == "disabled"
1272                        && matches!(
1273                            self.peek(),
1274                            Some(Token {
1275                                kind: TokenKind::Word(word),
1276                                ..
1277                            }) if canonical_keyword(&word) == "While"
1278                        )
1279                    {
1280                        self.pos += 1;
1281                        actions.push(self.while_group()?);
1282                        continue;
1283                    }
1284                    match self.canonical_keyword(&phrase).as_str() {
1285                        "end" => {
1286                            self.pos = saved;
1287                            return Ok((actions, Stop::End));
1288                        }
1289                        "elseIf" => {
1290                            self.pos = saved;
1291                            return Ok((actions, Stop::ElseIf));
1292                        }
1293                        "else" => {
1294                            self.pos = saved;
1295                            return Ok((actions, Stop::Else));
1296                        }
1297                        "if" => actions.push(self.if_group()?),
1298                        "forGlobalVariable" => actions.push(self.for_group()?),
1299                        "forPlayerVariable" => actions.push(self.for_player_group()?),
1300                        "while" => actions.push(self.while_group()?),
1301                        "Loop" => actions.push(self.action_call_from_phrase(phrase, start, end)?),
1302                        "Loop If Condition Is True" => {
1303                            actions.push(self.action_call_from_phrase(phrase, start, end)?)
1304                        }
1305                        "Global" | "Event Player" => {
1306                            self.pos = saved;
1307                            actions.push(self.opaque_action()?);
1308                        }
1309                        _ => {
1310                            if self.line_has_assignment() {
1311                                self.pos = saved;
1312                                actions.push(self.opaque_action()?);
1313                            } else {
1314                                actions.push(self.action_call_from_phrase(phrase, start, end)?);
1315                            }
1316                        }
1317                    }
1318                }
1319                Some(Token {
1320                    kind: TokenKind::String(_),
1321                    ..
1322                }) => {
1323                    // Raw Workshop permits standalone quoted annotations in
1324                    // generated action blocks. They are inert source text,
1325                    // not executable actions.
1326                    self.pos += 1;
1327                }
1328                Some(token) => {
1329                    let saved = self.pos;
1330                    if let Some(action) = self.member_assignment_action(saved, token.start)? {
1331                        actions.push(action);
1332                    } else {
1333                        return Err(self.malformed("expected an action", &token));
1334                    }
1335                }
1336                None => {
1337                    return Err(self.malformed("unexpected end of input in actions", self.eof()));
1338                }
1339            }
1340        }
1341    }
1342
1343    /// Return the action spelling after the locale-declared disabled
1344    /// modifier. The modifier itself is settings/catalog data, not a parser
1345    /// branch for a fixed pair of client locales.
1346    fn disabled_action_rest<'a>(&self, phrase: &'a str) -> Option<&'a str> {
1347        if let Some(rest) = phrase.strip_prefix("disabled ") {
1348            return Some(rest);
1349        }
1350        let localized = table::localized_name(self.locale.as_str(), "tokens", "disabled")?;
1351        phrase.strip_prefix(localized)?.strip_prefix(' ')
1352    }
1353
1354    fn assignment_action(&mut self) -> Result<Option<wir::ActionId>> {
1355        let saved = self.pos;
1356        let Some(Token {
1357            kind: TokenKind::Word(first),
1358            start,
1359            ..
1360        }) = self.peek()
1361        else {
1362            return Ok(None);
1363        };
1364
1365        if matches!(canonical_keyword(&first), "Global" | "global") {
1366            self.pos += 1;
1367            self.expect(TokenKind::Dot, "expected '.' after 'Global'")?;
1368            let (name, _, target_end) = self.phrase()?;
1369            if matches!(
1370                self.peek().map(|token| token.kind),
1371                Some(TokenKind::LBracket)
1372            ) {
1373                self.pos += 1;
1374                let index = self.value()?;
1375                self.expect(
1376                    TokenKind::RBracket,
1377                    "expected ']' after global variable index",
1378                )?;
1379                let _operator = self.assignment_operator().ok_or_else(|| {
1380                    self.malformed(
1381                        "expected assignment after global variable index",
1382                        self.peek().as_ref().unwrap_or(self.eof()),
1383                    )
1384                })?;
1385                let variable = self.global_by_name(&name)?;
1386                let target = self.target.values.push(ValueNode::new(
1387                    Value::GlobalVariable(variable),
1388                    Some(Span::new(self.file(), start, target_end)),
1389                ));
1390                let value = self.value()?;
1391                self.expect(TokenKind::Semi, "expected ';' after indexed assignment")?;
1392                return Ok(Some(self.target.actions.push(Action::Call {
1393                    name: "setGlobalVariableAtIndex".to_string(),
1394                    args: vec![target, index, value],
1395                    span: Some(Span::new(self.file(), start, self.previous_span().1)),
1396                })));
1397            }
1398            let Some(operator) = self.assignment_operator() else {
1399                return self.member_assignment_action(saved, start);
1400            };
1401            let variable = self.global_by_name(&name)?;
1402            let value = self.value()?;
1403            self.expect(TokenKind::Semi, "expected ';' after assignment")?;
1404            let span = Some(Span::new(self.file(), start, self.previous_span().1));
1405            let target_span = Some(Span::new(self.file(), start, target_end));
1406            return Ok(Some(self.target.actions.push(match operator {
1407                AssignmentOperator::Set => Action::SetGlobalVariable {
1408                    variable,
1409                    value,
1410                    span,
1411                    target_span,
1412                },
1413                AssignmentOperator::Modify(op) => Action::ModifyGlobalVariable {
1414                    variable,
1415                    op,
1416                    value,
1417                    span,
1418                    target_span,
1419                },
1420            })));
1421        }
1422
1423        let first_canonical = canonical_keyword(&first);
1424        let is_event_player = first_canonical == "Event Player"
1425            || (matches!(first_canonical, "Event" | "event")
1426                && matches!(
1427                    self.peek_at(1).map(|token| token.kind),
1428                    Some(TokenKind::Word(word))
1429                        if matches!(canonical_keyword(&word), "Player" | "player")
1430                ));
1431        if !is_event_player {
1432            // Object/member assignments use the same value grammar as member
1433            // reads (`receiver.member` and `receiver.member[index]`). Keep
1434            // this source-level form distinct from catalog actions: the
1435            // receiver and member are dynamic Workshop values, not a builtin
1436            // identity. Global and Event Player assignments are handled by
1437            // their dedicated variable paths above and below.
1438            if !matches!(first_canonical, "Event" | "event") {
1439                return self.member_assignment_action(saved, start);
1440            }
1441            return Ok(None);
1442        }
1443
1444        if first_canonical == "Event Player" {
1445            self.pos += 1;
1446        } else {
1447            self.pos += 2;
1448        }
1449        let event_player = self.target.values.push(ValueNode::new(
1450            Value::EventPlayer,
1451            Some(Span::new(self.file(), start, self.previous_span().1)),
1452        ));
1453        self.expect(TokenKind::Dot, "expected '.' after 'Event Player'")?;
1454        let (name, target_start, target_end) = self.phrase()?;
1455        let variable = self.player_by_name(&name)?;
1456        if matches!(
1457            self.peek().map(|token| token.kind),
1458            Some(TokenKind::LBracket)
1459        ) {
1460            self.pos += 1;
1461            let index = self.value()?;
1462            self.expect(
1463                TokenKind::RBracket,
1464                "expected ']' after player variable index",
1465            )?;
1466            let _operator = self.assignment_operator().ok_or_else(|| {
1467                self.malformed(
1468                    "expected assignment after player variable index",
1469                    self.peek().as_ref().unwrap_or(self.eof()),
1470                )
1471            })?;
1472            let value = self.value()?;
1473            self.expect(TokenKind::Semi, "expected ';' after indexed assignment")?;
1474            let variable_value = self.target.values.push(ValueNode::new(
1475                Value::PlayerVariable {
1476                    player: event_player,
1477                    variable,
1478                },
1479                Some(Span::new(self.file(), target_start, target_end)),
1480            ));
1481            return Ok(Some(self.target.actions.push(Action::Call {
1482                name: "setPlayerVariableAtIndex".to_string(),
1483                args: vec![variable_value, index, value],
1484                span: Some(Span::new(self.file(), start, self.previous_span().1)),
1485            })));
1486        }
1487        let Some(operator) = self.assignment_operator() else {
1488            return self.member_assignment_action(saved, start);
1489        };
1490        let value = self.value()?;
1491        self.expect(TokenKind::Semi, "expected ';' after assignment")?;
1492        let span = Some(Span::new(self.file(), start, self.previous_span().1));
1493        let target_span = Some(Span::new(self.file(), target_start, target_end));
1494        Ok(Some(self.target.actions.push(match operator {
1495            AssignmentOperator::Set => Action::SetPlayerVariable {
1496                player: event_player,
1497                variable,
1498                value,
1499                span,
1500                target_span,
1501            },
1502            AssignmentOperator::Modify(op) => Action::ModifyPlayerVariable {
1503                player: event_player,
1504                variable,
1505                op,
1506                value,
1507                span,
1508                target_span,
1509            },
1510        })))
1511    }
1512
1513    fn member_assignment_action(
1514        &mut self,
1515        saved: usize,
1516        start: Position,
1517    ) -> Result<Option<wir::ActionId>> {
1518        self.pos = saved;
1519        if !self.line_has_assignment() {
1520            return Ok(None);
1521        }
1522        let target = self.value()?;
1523        let Some(operator) = self.assignment_operator() else {
1524            self.pos = saved;
1525            return Ok(None);
1526        };
1527        let value = self.value()?;
1528        self.expect(TokenKind::Semi, "expected ';' after member assignment")?;
1529        let op = match operator {
1530            AssignmentOperator::Set => None,
1531            AssignmentOperator::Modify(op) => Some(op),
1532        };
1533        Ok(Some(self.target.actions.push(Action::AssignMember {
1534            target,
1535            op,
1536            value,
1537            span: Some(Span::new(self.file(), start, self.previous_span().1)),
1538        })))
1539    }
1540
1541    fn assignment_operator(&mut self) -> Option<AssignmentOperator> {
1542        let operator = match self.peek()?.kind {
1543            TokenKind::Op(operator) => operator,
1544            _ => return None,
1545        };
1546        if operator == "=" {
1547            self.pos += 1;
1548            return Some(AssignmentOperator::Set);
1549        }
1550        let op = match operator.as_str() {
1551            "+" => ModifyOp::Add,
1552            "-" => ModifyOp::Subtract,
1553            "*" => ModifyOp::Multiply,
1554            "/" => ModifyOp::Divide,
1555            "%" => ModifyOp::Modulo,
1556            _ => return None,
1557        };
1558        if !matches!(self.peek_at(1).map(|token| token.kind), Some(TokenKind::Op(equal)) if equal == "=")
1559        {
1560            return None;
1561        }
1562        self.pos += 2;
1563        Some(AssignmentOperator::Modify(op))
1564    }
1565
1566    fn opaque_action(&mut self) -> Result<wir::ActionId> {
1567        let start = self
1568            .peek()
1569            .map(|token| token.start)
1570            .unwrap_or(self.eof().start);
1571        while let Some(token) = self.peek() {
1572            self.pos += 1;
1573            if matches!(token.kind, TokenKind::Semi) {
1574                break;
1575            }
1576        }
1577        Ok(self.target.actions.push(Action::Call {
1578            name: "rawWorkshopAction".to_string(),
1579            args: Vec::new(),
1580            span: Some(Span::new(self.file(), start, self.previous_span().1)),
1581        }))
1582    }
1583
1584    fn if_group(&mut self) -> Result<wir::ActionId> {
1585        let start = self.previous_span().0;
1586        self.expect(TokenKind::LParen, "expected '(' after 'If'")?;
1587        let condition = self.value()?;
1588        self.expect(TokenKind::RParen, "expected ')' after If condition")?;
1589        self.expect(TokenKind::Semi, "expected ';' after If condition")?;
1590
1591        let mut branches = Vec::new();
1592        let mut stop = {
1593            let (body, stop) = self.actions_until_end()?;
1594            branches.push(wir::IfBranch { condition, body });
1595            stop
1596        };
1597
1598        let mut else_body = None;
1599        loop {
1600            match stop {
1601                Stop::End => {
1602                    self.consume_phrase("End")?;
1603                    self.expect(TokenKind::Semi, "expected ';' after 'End'")?;
1604                    break;
1605                }
1606                Stop::ElseIf => {
1607                    self.consume_phrase("Else If")?;
1608                    self.expect(TokenKind::LParen, "expected '(' after 'Else If'")?;
1609                    let condition = self.value()?;
1610                    self.expect(TokenKind::RParen, "expected ')' after Else If condition")?;
1611                    self.expect(TokenKind::Semi, "expected ';' after Else If condition")?;
1612                    let (body, next) = self.actions_until_end()?;
1613                    branches.push(wir::IfBranch { condition, body });
1614                    stop = next;
1615                }
1616                Stop::Else => {
1617                    self.consume_phrase("Else")?;
1618                    self.expect(TokenKind::Semi, "expected ';' after 'Else'")?;
1619                    let (body, next) = self.actions_until_end()?;
1620                    else_body = Some(body);
1621                    stop = next;
1622                }
1623                Stop::SectionClosed => {
1624                    // The oracle closes a rule-final if/if-else with the
1625                    // enclosing actions-section `}` (no trailing `End;`,
1626                    // #87). Rewind so the enclosing actions section consumes
1627                    // that `}` and the rule's own `}` stays intact.
1628                    self.pos -= 1;
1629                    break;
1630                }
1631            }
1632        }
1633        let end_span = self.previous_span();
1634        let action = Action::If {
1635            branches,
1636            else_body,
1637            span: Some(Span::new(self.file(), start, end_span.1)),
1638        };
1639        Ok(self.target.actions.push(action))
1640    }
1641
1642    fn for_group(&mut self) -> Result<wir::ActionId> {
1643        let start = self.previous_span().0;
1644        self.expect(
1645            TokenKind::LParen,
1646            "expected '(' after 'For Global Variable'",
1647        )?;
1648        let (name, _, _) = self.phrase()?;
1649        let variable = self.global_by_name(&name)?;
1650        self.expect(TokenKind::Comma, "expected ',' after loop variable")?;
1651        let start_value = self.value()?;
1652        self.expect(TokenKind::Comma, "expected ',' after start")?;
1653        let stop = self.value()?;
1654        self.expect(TokenKind::Comma, "expected ',' after stop")?;
1655        let step = self.value()?;
1656        self.expect(TokenKind::RParen, "expected ')' after For bounds")?;
1657        self.expect(TokenKind::Semi, "expected ';' after For Global Variable")?;
1658        let (body, loop_stop) = self.actions_until_end()?;
1659        if loop_stop != Stop::End {
1660            return Err(self.malformed(
1661                "'For Global Variable' requires a matching 'End'",
1662                self.previous(),
1663            ));
1664        }
1665        self.consume_phrase("End")?;
1666        self.expect(TokenKind::Semi, "expected ';' after 'End'")?;
1667        let end_span = self.previous_span();
1668        let action = Action::ForGlobalVariable {
1669            variable,
1670            start: start_value,
1671            stop,
1672            step,
1673            body,
1674            span: Some(Span::new(self.file(), start, end_span.1)),
1675            target_span: None,
1676        };
1677        Ok(self.target.actions.push(action))
1678    }
1679
1680    fn while_group(&mut self) -> Result<wir::ActionId> {
1681        let start = self.previous_span().0;
1682        self.expect(TokenKind::LParen, "expected '(' after 'While'")?;
1683        let condition = self.value()?;
1684        self.expect(TokenKind::RParen, "expected ')' after While condition")?;
1685        self.expect(TokenKind::Semi, "expected ';' after While condition")?;
1686        let (body, stop) = self.actions_until_end()?;
1687        if stop != Stop::End {
1688            return Err(self.malformed("'While' requires a matching 'End'", self.previous()));
1689        }
1690        self.consume_phrase("End")?;
1691        self.expect(TokenKind::Semi, "expected ';' after 'End'")?;
1692        let end_span = self.previous_span();
1693        let action = Action::While {
1694            condition,
1695            body,
1696            span: Some(Span::new(self.file(), start, end_span.1)),
1697        };
1698        Ok(self.target.actions.push(action))
1699    }
1700
1701    /// `For Player Variable(player, name, start, stop, step)` — the
1702    /// reference's per-player loop form (parsed from pinned reference
1703    /// evidence; the differential gate normalizes it to the declared global
1704    /// form, #119).
1705    fn for_player_group(&mut self) -> Result<wir::ActionId> {
1706        let start = self.previous_span().0;
1707        self.expect(
1708            TokenKind::LParen,
1709            "expected '(' after 'For Player Variable'",
1710        )?;
1711        let player = self.value()?;
1712        self.expect(TokenKind::Comma, "expected ',' after loop player")?;
1713        let (name, _, _) = self.phrase()?;
1714        let variable = self.player_by_name(&name)?;
1715        self.expect(TokenKind::Comma, "expected ',' after loop variable")?;
1716        let start_value = self.value()?;
1717        self.expect(TokenKind::Comma, "expected ',' after start")?;
1718        let stop = self.value()?;
1719        self.expect(TokenKind::Comma, "expected ',' after stop")?;
1720        let step = self.value()?;
1721        self.expect(TokenKind::RParen, "expected ')' after For bounds")?;
1722        self.expect(TokenKind::Semi, "expected ';' after For Player Variable")?;
1723        let (body, loop_stop) = self.actions_until_end()?;
1724        if loop_stop != Stop::End {
1725            return Err(self.malformed(
1726                "'For Player Variable' requires a matching 'End'",
1727                self.previous(),
1728            ));
1729        }
1730        self.consume_phrase("End")?;
1731        self.expect(TokenKind::Semi, "expected ';' after 'End'")?;
1732        let end_span = self.previous_span();
1733        let action = Action::ForPlayerVariable {
1734            player,
1735            variable,
1736            start: start_value,
1737            stop,
1738            step,
1739            body,
1740            span: Some(Span::new(self.file(), start, end_span.1)),
1741        };
1742        Ok(self.target.actions.push(action))
1743    }
1744
1745    fn action_call_from_phrase(
1746        &mut self,
1747        phrase: String,
1748        start: Position,
1749        end: Position,
1750    ) -> Result<wir::ActionId> {
1751        match self
1752            .catalog
1753            .resolve(Kind::Structural, &self.locale, &phrase)
1754        {
1755            Some(entry) => match entry.id.as_str() {
1756                "setGlobalVariable" => {
1757                    self.expect(
1758                        TokenKind::LParen,
1759                        "expected '(' after 'Set Global Variable'",
1760                    )?;
1761                    let (name, _, _) = self.phrase()?;
1762                    let variable = self.global_by_name(&name)?;
1763                    self.expect(TokenKind::Comma, "expected ',' after variable")?;
1764                    let value = self.value()?;
1765                    self.expect(TokenKind::RParen, "expected ')'")?;
1766                    self.expect(TokenKind::Semi, "expected ';'")?;
1767                    Ok(self.target.actions.push(Action::SetGlobalVariable {
1768                        variable,
1769                        value,
1770                        span: Some(Span::new(self.file(), start, end)),
1771                        target_span: None,
1772                    }))
1773                }
1774                "modifyGlobalVariable" => {
1775                    self.expect(TokenKind::LParen, "expected '('")?;
1776                    let (name, _, _) = self.phrase()?;
1777                    let variable = self.global_by_name(&name)?;
1778                    self.expect(TokenKind::Comma, "expected ',' after variable")?;
1779                    let op = self.modify_op()?;
1780                    self.expect(TokenKind::Comma, "expected ',' after modify operator")?;
1781                    let value = self.value()?;
1782                    self.expect(TokenKind::RParen, "expected ')'")?;
1783                    self.expect(TokenKind::Semi, "expected ';'")?;
1784                    Ok(self.target.actions.push(Action::ModifyGlobalVariable {
1785                        variable,
1786                        op,
1787                        value,
1788                        span: Some(Span::new(self.file(), start, end)),
1789                        target_span: None,
1790                    }))
1791                }
1792                "setPlayerVariable" => {
1793                    self.expect(TokenKind::LParen, "expected '('")?;
1794                    let player = self.value()?;
1795                    self.expect(TokenKind::Comma, "expected ',' after player")?;
1796                    let (name, _, _) = self.phrase()?;
1797                    let variable = self.player_by_name(&name)?;
1798                    self.expect(TokenKind::Comma, "expected ',' after variable")?;
1799                    let value = self.value()?;
1800                    self.expect(TokenKind::RParen, "expected ')'")?;
1801                    self.expect(TokenKind::Semi, "expected ';'")?;
1802                    Ok(self.target.actions.push(Action::SetPlayerVariable {
1803                        player,
1804                        variable,
1805                        value,
1806                        span: Some(Span::new(self.file(), start, end)),
1807                        target_span: None,
1808                    }))
1809                }
1810                "modifyPlayerVariable" => {
1811                    self.expect(TokenKind::LParen, "expected '('")?;
1812                    let player = self.value()?;
1813                    self.expect(TokenKind::Comma, "expected ',' after player")?;
1814                    let (name, _, _) = self.phrase()?;
1815                    let variable = self.player_by_name(&name)?;
1816                    self.expect(TokenKind::Comma, "expected ',' after variable")?;
1817                    let op = self.modify_op()?;
1818                    self.expect(TokenKind::Comma, "expected ',' after modify operator")?;
1819                    let value = self.value()?;
1820                    self.expect(TokenKind::RParen, "expected ')'")?;
1821                    self.expect(TokenKind::Semi, "expected ';'")?;
1822                    Ok(self.target.actions.push(Action::ModifyPlayerVariable {
1823                        player,
1824                        variable,
1825                        op,
1826                        value,
1827                        span: Some(Span::new(self.file(), start, end)),
1828                        target_span: None,
1829                    }))
1830                }
1831                "forGlobalVariable" => self.for_group(),
1832                "forPlayerVariable" => self.for_player_group(),
1833                "callSubroutine" => {
1834                    self.expect(TokenKind::LParen, "expected '('")?;
1835                    let (name, _, _) = self.phrase()?;
1836                    let subroutine = self.subroutine_by_name(&name)?;
1837                    self.expect(TokenKind::RParen, "expected ')'")?;
1838                    self.expect(TokenKind::Semi, "expected ';'")?;
1839                    Ok(self.target.actions.push(Action::CallSubroutine {
1840                        subroutine,
1841                        span: Some(Span::new(self.file(), start, end)),
1842                        callee_span: None,
1843                    }))
1844                }
1845                other => Err(WorkshopError::Unsupported {
1846                    message: format!(
1847                        "structural action '{other}' is not supported in action position"
1848                    ),
1849                    span: Some(Span::new(self.file(), start, end)),
1850                }),
1851            },
1852            None => {
1853                // Generic action call; the argument list is optional.
1854                let Some(action) = self
1855                    .resolve_entry(Kind::Action, &phrase)
1856                    .or_else(|| self.resolve_entry(Kind::Action, &format!("{phrase} ")))
1857                    .or_else(|| {
1858                        let alias = match phrase.as_str() {
1859                            "Set Player Allowed Heroes" => "Set Allowed Heroes",
1860                            "设置技能充能" => "设置终极技能充能",
1861                            _ => return None,
1862                        };
1863                        self.resolve_entry(Kind::Action, alias)
1864                    })
1865                else {
1866                    return Err(WorkshopError::Unknown {
1867                        kind: "action",
1868                        spelling: phrase,
1869                        locale: self.locale.clone(),
1870                        span: Some(Span::new(self.file(), start, end)),
1871                    });
1872                };
1873                // The player-variable chase forms lay the variable out as
1874                // `player, name` leading arguments (the pinned oracle's
1875                // spelling, #110); the name is not a value, so the action is
1876                // parsed like `Set Player Variable` and reconstructed as the
1877                // canonical `chaseAtRate`/`chaseOverTime` call with a
1878                // player-variable first argument (the shape the emitter
1879                // dispatches on).
1880                match action.id.as_str() {
1881                    "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime" => {
1882                        self.expect(TokenKind::LParen, "expected '('")?;
1883                        let player = self.value()?;
1884                        self.expect(TokenKind::Comma, "expected ',' after player")?;
1885                        let (name, _, _) = self.phrase()?;
1886                        let variable = self.player_by_name(&name)?;
1887                        let mut args = Vec::with_capacity(4);
1888                        args.push(self.target.values.push(wir::ValueNode::new(
1889                            wir::Value::PlayerVariable { player, variable },
1890                            None,
1891                        )));
1892                        // The remaining arguments sit at overall argument
1893                        // indexes 2.. (player and name consumed indexes 0-1),
1894                        // so the signature context resolves their expected
1895                        // domains at the shifted positions.
1896                        let mut arg_index = 2usize;
1897                        loop {
1898                            match self.peek() {
1899                                Some(Token {
1900                                    kind: TokenKind::RParen,
1901                                    ..
1902                                }) => break,
1903                                Some(Token {
1904                                    kind: TokenKind::Comma,
1905                                    ..
1906                                }) => {
1907                                    self.pos += 1;
1908                                }
1909                                _ => {}
1910                            }
1911                            let saved = self.expected_domain;
1912                            self.expected_domain =
1913                                self.context.expected_domain(action.id.as_str(), arg_index);
1914                            let arg = self.value()?;
1915                            self.expected_domain = saved;
1916                            args.push(arg);
1917                            arg_index += 1;
1918                        }
1919                        self.expect(TokenKind::RParen, "expected ')'")?;
1920                        self.expect(TokenKind::Semi, "expected ';' after action")?;
1921                        let canonical = if action.id == "chasePlayerVariableAtRate" {
1922                            "chaseAtRate"
1923                        } else {
1924                            "chaseOverTime"
1925                        };
1926                        return Ok(self.target.actions.push(Action::Call {
1927                            name: canonical.to_string(),
1928                            args,
1929                            span: Some(Span::new(self.file(), start, end)),
1930                        }));
1931                    }
1932                    "startRule" => {
1933                        self.expect(TokenKind::LParen, "expected '('")?;
1934                        let (name, _, _) = self.phrase()?;
1935                        let subroutine = self.subroutine_by_name(&name)?;
1936                        self.expect(TokenKind::Comma, "expected ',' after subroutine")?;
1937                        let saved = self.expected_domain;
1938                        self.expected_domain = self.context.expected_domain(action.id.as_str(), 1);
1939                        let behavior = self.value()?;
1940                        self.expected_domain = saved;
1941                        self.expect(TokenKind::RParen, "expected ')'")?;
1942                        self.expect(TokenKind::Semi, "expected ';' after action")?;
1943                        let subroutine_value = self
1944                            .target
1945                            .values
1946                            .push(ValueNode::new(Value::Subroutine(subroutine), None));
1947                        return Ok(self.target.actions.push(Action::Call {
1948                            name: action.id.clone(),
1949                            args: vec![subroutine_value, behavior],
1950                            span: Some(Span::new(self.file(), start, end)),
1951                        }));
1952                    }
1953                    "stopChasingPlayerVariable" => {
1954                        self.expect(TokenKind::LParen, "expected '('")?;
1955                        let player = self.value()?;
1956                        self.expect(TokenKind::Comma, "expected ',' after player")?;
1957                        let (name, _, _) = self.phrase()?;
1958                        let variable = self.player_by_name(&name)?;
1959                        self.expect(TokenKind::RParen, "expected ')'")?;
1960                        self.expect(TokenKind::Semi, "expected ';' after action")?;
1961                        let player_variable = self.target.values.push(ValueNode::new(
1962                            Value::PlayerVariable { player, variable },
1963                            None,
1964                        ));
1965                        return Ok(self.target.actions.push(Action::Call {
1966                            name: action.id.clone(),
1967                            args: vec![player_variable],
1968                            span: Some(Span::new(self.file(), start, end)),
1969                        }));
1970                    }
1971                    _ => {}
1972                }
1973                let args = if let Some(Token {
1974                    kind: TokenKind::LParen,
1975                    ..
1976                }) = self.peek()
1977                {
1978                    self.pos += 1;
1979                    let args = self.value_args(action.id.as_str())?;
1980                    self.expect(TokenKind::RParen, "expected ')'")?;
1981                    args
1982                } else {
1983                    Vec::new()
1984                };
1985                self.expect(TokenKind::Semi, "expected ';' after action")?;
1986                Ok(self.target.actions.push(Action::Call {
1987                    name: action.id.clone(),
1988                    args,
1989                    span: Some(Span::new(self.file(), start, end)),
1990                }))
1991            }
1992        }
1993    }
1994
1995    fn modify_op(&mut self) -> Result<ModifyOp> {
1996        let (phrase, start, end) = self.phrase()?;
1997        if phrase == "根据值从数组中移除" {
1998            return Ok(ModifyOp::RemoveFromArray);
1999        }
2000        if phrase == "根据索引从数组中移除" {
2001            return Ok(ModifyOp::RemoveFromArrayByIndex);
2002        }
2003        let entry = self
2004            .catalog
2005            .resolve(Kind::Operator, &self.locale, &phrase)
2006            .ok_or_else(|| WorkshopError::Unknown {
2007                kind: "modify operator",
2008                spelling: phrase.clone(),
2009                locale: self.locale.clone(),
2010                span: Some(Span::new(self.file(), start, end)),
2011            })?;
2012        let op = match entry.id.as_str() {
2013            "add" => ModifyOp::Add,
2014            "subtract" => ModifyOp::Subtract,
2015            "multiply" => ModifyOp::Multiply,
2016            "divide" => ModifyOp::Divide,
2017            "modulo" => ModifyOp::Modulo,
2018            "raiseToPower" => ModifyOp::RaiseToPower,
2019            "appendToArray" => ModifyOp::AppendToArray,
2020            "removeFromArray" | "removeFromArrayByValue" => ModifyOp::RemoveFromArray,
2021            "removeFromArrayByIndex" => ModifyOp::RemoveFromArrayByIndex,
2022            other => {
2023                return Err(WorkshopError::Unsupported {
2024                    message: format!("unsupported modify operator '{other}'"),
2025                    span: Some(Span::new(self.file(), start, end)),
2026                });
2027            }
2028        };
2029        Ok(op)
2030    }
2031
2032    fn value(&mut self) -> Result<wir::ValueId> {
2033        let mut value = self.primary()?;
2034        loop {
2035            if let Some(Token {
2036                kind: TokenKind::LBracket,
2037                start,
2038                ..
2039            }) = self.peek()
2040            {
2041                self.pos += 1;
2042                let index = self.value()?;
2043                let end = self.peek().map(|token| token.end).unwrap_or(start);
2044                self.expect(TokenKind::RBracket, "expected ']' after array index")?;
2045                value = self.target.values.push(ValueNode::new(
2046                    Value::Call {
2047                        name: "valueInArray".to_string(),
2048                        args: vec![value, index],
2049                    },
2050                    Some(Span::new(self.file(), start, end)),
2051                ));
2052                continue;
2053            }
2054            if matches!(
2055                self.peek(),
2056                Some(Token {
2057                    kind: TokenKind::Dot,
2058                    ..
2059                })
2060            ) {
2061                self.pos += 1;
2062                let (name, _, _) = self.phrase()?;
2063                let member = self
2064                    .target
2065                    .values
2066                    .push(ValueNode::new(Value::String(name), None));
2067                let mut args = vec![value, member];
2068                if matches!(
2069                    self.peek(),
2070                    Some(Token {
2071                        kind: TokenKind::LBracket,
2072                        ..
2073                    })
2074                ) {
2075                    self.pos += 1;
2076                    args.push(self.value()?);
2077                    self.expect(TokenKind::RBracket, "expected ']' after member index")?;
2078                }
2079                value = self.target.values.push(ValueNode::new(
2080                    Value::Call {
2081                        name: "memberAccess".to_string(),
2082                        args,
2083                    },
2084                    None,
2085                ));
2086                continue;
2087            }
2088            if let Some(Token {
2089                kind: TokenKind::Op(op),
2090                start,
2091                end,
2092            }) = self.peek()
2093            {
2094                if op == "?" {
2095                    self.pos += 1;
2096                    let when_true = self.value()?;
2097                    self.expect(TokenKind::Colon, "expected ':' in conditional value")?;
2098                    let when_false = self.value()?;
2099                    value = self.target.values.push(ValueNode::new(
2100                        Value::Call {
2101                            name: "ifThenElse".to_string(),
2102                            args: vec![value, when_true, when_false],
2103                        },
2104                        Some(Span::new(self.file(), start, end)),
2105                    ));
2106                    continue;
2107                }
2108                let compound_assignment = matches!(
2109                    self.peek_at(1).map(|token| token.kind),
2110                    Some(TokenKind::Op(equal)) if equal == "="
2111                );
2112                if !compound_assignment
2113                    && (is_comparison(&op)
2114                        || matches!(op.as_str(), "and" | "or" | "+" | "-" | "*" | "/" | "%"))
2115                {
2116                    self.pos += 1;
2117                    let right = self.primary()?;
2118                    let name = match op.as_str() {
2119                        "+" => "add",
2120                        "-" => "subtract",
2121                        "*" => "multiply",
2122                        "/" => "divide",
2123                        "%" => "modulo",
2124                        _ => op.as_str(),
2125                    };
2126                    value = self.target.values.push(ValueNode::new(
2127                        Value::Call {
2128                            name: name.to_string(),
2129                            args: vec![value, right],
2130                        },
2131                        Some(Span::new(self.file(), start, end)),
2132                    ));
2133                    continue;
2134                }
2135            }
2136            break;
2137        }
2138        Ok(value)
2139    }
2140
2141    fn primary(&mut self) -> Result<wir::ValueId> {
2142        match self.peek() {
2143            Some(Token {
2144                kind: TokenKind::Op(op),
2145                start,
2146                ..
2147            }) if op == "not" => {
2148                self.pos += 1;
2149                let value = self.primary()?;
2150                Ok(self.target.values.push(ValueNode::new(
2151                    Value::Call {
2152                        name: "not".to_string(),
2153                        args: vec![value],
2154                    },
2155                    Some(Span::new(self.file(), start, self.previous_span().1)),
2156                )))
2157            }
2158            Some(Token {
2159                kind: TokenKind::Number { value, text },
2160                start,
2161                end,
2162            }) => {
2163                let span = Some(Span::new(self.file(), start, end));
2164                self.pos += 1;
2165                Ok(self
2166                    .target
2167                    .values
2168                    .push(ValueNode::new(Value::Number { value, text }, span)))
2169            }
2170            Some(Token {
2171                kind: TokenKind::Op(op),
2172                start,
2173                ..
2174            }) if op == "-" => {
2175                if let Some(Token {
2176                    kind: TokenKind::Number { value, text },
2177                    end: number_end,
2178                    ..
2179                }) = self.peek_at(1)
2180                {
2181                    let span = Some(Span::new(self.file(), start, number_end));
2182                    self.pos += 2;
2183                    Ok(self.target.values.push(ValueNode::new(
2184                        Value::Number {
2185                            value: -value,
2186                            text: format!("-{text}"),
2187                        },
2188                        span,
2189                    )))
2190                } else {
2191                    Err(self.malformed("expected a number after '-'", &self.peek().unwrap()))
2192                }
2193            }
2194            Some(Token {
2195                kind: TokenKind::String(content),
2196                start,
2197                end,
2198            }) => {
2199                let span = Some(Span::new(self.file(), start, end));
2200                self.pos += 1;
2201                Ok(self
2202                    .target
2203                    .values
2204                    .push(ValueNode::new(Value::String(content), span)))
2205            }
2206            Some(Token {
2207                kind: TokenKind::Word(word),
2208                ..
2209            }) if matches!(canonical_keyword(&word), "Global" | "global") => {
2210                let (start, end) = self.span_here();
2211                self.pos += 1;
2212                // The reference's value spelling `Global Variable(name)`
2213                // (#119 differential evidence); the OPY form `Global.name`
2214                // stays supported.
2215                if let Some(Token {
2216                    kind: TokenKind::Word(next),
2217                    ..
2218                }) = self.peek()
2219                {
2220                    if canonical_keyword(&next) == "Variable"
2221                        || canonical_keyword(&next) == "variable"
2222                    {
2223                        self.pos += 1;
2224                        self.expect(TokenKind::LParen, "expected '(' after 'Global Variable'")?;
2225                        let (name, _, _) = self.phrase()?;
2226                        let variable = self.global_by_name(&name)?;
2227                        self.expect(TokenKind::RParen, "expected ')' after Global Variable")?;
2228                        let span = Some(Span::new(self.file(), start, end));
2229                        return Ok(self
2230                            .target
2231                            .values
2232                            .push(ValueNode::new(Value::GlobalVariable(variable), span)));
2233                    }
2234                }
2235                self.expect(TokenKind::Dot, "expected '.' after 'Global'")?;
2236                let (name, _, _) = self.phrase()?;
2237                let variable = self.global_by_name(&name)?;
2238                let span = Some(Span::new(self.file(), start, end));
2239                Ok(self
2240                    .target
2241                    .values
2242                    .push(ValueNode::new(Value::GlobalVariable(variable), span)))
2243            }
2244            Some(Token {
2245                kind: TokenKind::Word(word),
2246                ..
2247            }) if matches!(canonical_keyword(&word), "Player" | "player")
2248                && matches!(
2249                    self.peek_at(1),
2250                    Some(Token {
2251                        kind: TokenKind::Word(next),
2252                        ..
2253                    }) if matches!(canonical_keyword(&next), "Variable" | "variable")
2254                ) =>
2255            {
2256                // The reference's playervar-read spelling
2257                // `Player Variable(player, name)` (#119 differential
2258                // evidence).
2259                let (start, end) = self.span_here();
2260                self.pos += 1;
2261                let saved = self.expected_domain;
2262                self.expected_domain = None;
2263                let result = (|| {
2264                    self.consume_phrase("Variable")?;
2265                    self.expect(TokenKind::LParen, "expected '(' after 'Player Variable'")?;
2266                    let player = self.value()?;
2267                    self.expect(TokenKind::Comma, "expected ',' after player")?;
2268                    let (name, _, _) = self.phrase()?;
2269                    let variable = self.player_by_name(&name)?;
2270                    self.expect(TokenKind::RParen, "expected ')' after Player Variable")?;
2271                    Ok(self.target.values.push(ValueNode::new(
2272                        Value::PlayerVariable { player, variable },
2273                        Some(Span::new(self.file(), start, end)),
2274                    )))
2275                })();
2276                self.expected_domain = saved;
2277                result
2278            }
2279            Some(Token {
2280                kind: TokenKind::Word(word),
2281                ..
2282            }) if matches!(canonical_keyword(&word), "Event" | "event" | "Event Player") => {
2283                let (start, _) = self.span_here();
2284                self.pos += 1;
2285                if canonical_keyword(&word) == "Event Player" {
2286                    let player = self.target.values.push(ValueNode::new(
2287                        Value::EventPlayer,
2288                        Some(Span::new(self.file(), start, self.previous_span().1)),
2289                    ));
2290                    if matches!(
2291                        self.peek(),
2292                        Some(Token {
2293                            kind: TokenKind::Dot,
2294                            ..
2295                        })
2296                    ) {
2297                        self.pos += 1;
2298                        let (name, name_start, name_end) = self.phrase()?;
2299                        let variable = self.player_by_name(&name)?;
2300                        return Ok(self.target.values.push(ValueNode::new(
2301                            Value::PlayerVariable { player, variable },
2302                            Some(Span::new(self.file(), name_start, name_end)),
2303                        )));
2304                    }
2305                    return Ok(player);
2306                }
2307                if canonical_keyword(&word) != "Event Player"
2308                    && matches!(self.peek(), Some(Token { kind: TokenKind::Word(next), .. }) if matches!(canonical_keyword(&next), "Player" | "player"))
2309                {
2310                    self.pos += 1;
2311                    let player = self.target.values.push(ValueNode::new(
2312                        Value::EventPlayer,
2313                        Some(Span::new(self.file(), start, self.previous_span().1)),
2314                    ));
2315                    if matches!(
2316                        self.peek(),
2317                        Some(Token {
2318                            kind: TokenKind::Dot,
2319                            ..
2320                        })
2321                    ) {
2322                        self.pos += 1;
2323                        let (name, name_start, name_end) = self.phrase()?;
2324                        let variable = self.player_by_name(&name)?;
2325                        return Ok(self.target.values.push(ValueNode::new(
2326                            Value::PlayerVariable { player, variable },
2327                            Some(Span::new(self.file(), name_start, name_end)),
2328                        )));
2329                    }
2330                    return Ok(player);
2331                }
2332                self.pos -= 1;
2333                let (phrase, start, end) = self.phrase()?;
2334                if matches!(
2335                    self.peek(),
2336                    Some(Token {
2337                        kind: TokenKind::LParen,
2338                        ..
2339                    })
2340                ) {
2341                    self.call_or_enum(&phrase, start, end)
2342                } else {
2343                    self.bare_member(&phrase, start, end)
2344                }
2345            }
2346            Some(Token {
2347                kind: TokenKind::LParen,
2348                ..
2349            }) => {
2350                // The oracle's playervar-read spelling parenthesizes the
2351                // receiver: `(Event Player).p` (#87).
2352                self.pos += 1;
2353                let inner = self.value()?;
2354                if matches!(
2355                    self.peek(),
2356                    Some(Token {
2357                        kind: TokenKind::Dot,
2358                        ..
2359                    })
2360                ) {
2361                    self.pos += 1;
2362                    let (name, _, _) = self.phrase()?;
2363                    let member = self
2364                        .target
2365                        .values
2366                        .push(ValueNode::new(Value::String(name), None));
2367                    let mut args = vec![inner, member];
2368                    if matches!(
2369                        self.peek(),
2370                        Some(Token {
2371                            kind: TokenKind::LBracket,
2372                            ..
2373                        })
2374                    ) {
2375                        self.pos += 1;
2376                        args.push(self.value()?);
2377                        self.expect(TokenKind::RBracket, "expected ']' after member index")?;
2378                    }
2379                    let mut accessed = self.target.values.push(ValueNode::new(
2380                        Value::Call {
2381                            name: "memberAccess".to_string(),
2382                            args,
2383                        },
2384                        None,
2385                    ));
2386                    if matches!(self.peek(), Some(Token { kind: TokenKind::Op(op), .. }) if op == "?")
2387                    {
2388                        self.pos += 1;
2389                        let when_true = self.value()?;
2390                        self.expect(TokenKind::Colon, "expected ':' in conditional value")?;
2391                        let when_false = self.value()?;
2392                        accessed = self.target.values.push(ValueNode::new(
2393                            Value::Call {
2394                                name: "ifThenElse".to_string(),
2395                                args: vec![accessed, when_true, when_false],
2396                            },
2397                            None,
2398                        ));
2399                    }
2400                    self.expect(TokenKind::RParen, "expected ')' after member access")?;
2401                    return Ok(accessed);
2402                }
2403                self.expect(TokenKind::RParen, "expected ')' after parenthesized value")?;
2404                if let Some(Token {
2405                    kind: TokenKind::Dot,
2406                    ..
2407                }) = self.peek()
2408                {
2409                    self.pos += 1;
2410                    let (name, _, _) = self.phrase()?;
2411                    if matches!(
2412                        self.target.values.get(inner),
2413                        Some(ValueNode {
2414                            value: Value::EventPlayer,
2415                            ..
2416                        })
2417                    ) || self.players.contains_key(&name)
2418                    {
2419                        let variable = self
2420                            .players
2421                            .get(&name)
2422                            .copied()
2423                            .unwrap_or(self.player_by_name(&name)?);
2424                        Ok(self.target.values.push(ValueNode::new(
2425                            Value::PlayerVariable {
2426                                player: inner,
2427                                variable,
2428                            },
2429                            None,
2430                        )))
2431                    } else {
2432                        let member = self
2433                            .target
2434                            .values
2435                            .push(ValueNode::new(Value::String(name), None));
2436                        let mut args = vec![inner, member];
2437                        if matches!(
2438                            self.peek(),
2439                            Some(Token {
2440                                kind: TokenKind::LBracket,
2441                                ..
2442                            })
2443                        ) {
2444                            self.pos += 1;
2445                            args.push(self.value()?);
2446                            self.expect(TokenKind::RBracket, "expected ']' after member index")?;
2447                        }
2448                        Ok(self.target.values.push(ValueNode::new(
2449                            Value::Call {
2450                                name: "memberAccess".to_string(),
2451                                args,
2452                            },
2453                            None,
2454                        )))
2455                    }
2456                } else {
2457                    Ok(inner)
2458                }
2459            }
2460            _ => {
2461                let (phrase, start, end) = self.phrase()?;
2462                match canonical_keyword(&phrase) {
2463                    "True" | "真" => Ok(self.push_bool(true, start, end)),
2464                    "False" | "假" => Ok(self.push_bool(false, start, end)),
2465                    "Event Player" => Ok(self.target.values.push(ValueNode::new(
2466                        Value::EventPlayer,
2467                        Some(Span::new(self.file(), start, end)),
2468                    ))),
2469                    "Null" => Ok(self.target.values.push(ValueNode::new(
2470                        Value::Null,
2471                        Some(Span::new(self.file(), start, end)),
2472                    ))),
2473                    _ => {
2474                        if let Some(Token {
2475                            kind: TokenKind::LParen,
2476                            ..
2477                        }) = self.peek()
2478                        {
2479                            self.call_or_enum(&phrase, start, end)
2480                        } else {
2481                            self.bare_member(&phrase, start, end)
2482                        }
2483                    }
2484                }
2485            }
2486        }
2487    }
2488
2489    fn call_or_enum(
2490        &mut self,
2491        phrase: &str,
2492        start: Position,
2493        end: Position,
2494    ) -> Result<wir::ValueId> {
2495        // A value function wins over an enum domain of the same spelling
2496        // (e.g. `Vector(x, y, z)` is the value function; `Vector` as an enum
2497        // domain only appears through bare members like `Up`).
2498        let prefer_enum = self.catalog.enum_domain("Hero").is_some()
2499            && (canonical_keyword(phrase) == "Hero"
2500                || self.resolve_enum_domain_mixed(phrase) == Some("Hero"));
2501        if !prefer_enum {
2502            if let Some(entry) = self.resolve_entry(Kind::Value, phrase) {
2503                self.expect(TokenKind::LParen, "expected '(' after value name")?;
2504                if entry.id == "compare" {
2505                    // Compare(a, op, b) -> Call(op, [a, b]). The operands are
2506                    // value positions, not signature-pinned arguments, so the
2507                    // enclosing expected domain must not leak in (#111).
2508                    let saved = self.expected_domain;
2509                    self.expected_domain = None;
2510                    let left = self.value();
2511                    self.expected_domain = saved;
2512                    let left = left?;
2513                    self.expect(TokenKind::Comma, "expected ',' after Compare operand")?;
2514                    let (op, op_start, op_end) = match self.next() {
2515                        Some(Token {
2516                            kind: TokenKind::Op(op),
2517                            start,
2518                            end,
2519                        }) => (op, start, end),
2520                        Some(token) => {
2521                            return Err(
2522                                self.malformed("expected a comparison operator in Compare", &token)
2523                            );
2524                        }
2525                        None => {
2526                            return Err(self.malformed(
2527                                "expected a comparison operator in Compare",
2528                                self.eof(),
2529                            ));
2530                        }
2531                    };
2532                    self.expect(TokenKind::Comma, "expected ',' after Compare operator")?;
2533                    let saved = self.expected_domain;
2534                    self.expected_domain = None;
2535                    let right = self.value();
2536                    self.expected_domain = saved;
2537                    let right = right?;
2538                    self.expect(TokenKind::RParen, "expected ')'")?;
2539                    let span = Some(Span::new(self.file(), op_start, op_end));
2540                    return Ok(self.target.values.push(ValueNode::new(
2541                        Value::Call {
2542                            name: op,
2543                            args: vec![left, right],
2544                        },
2545                        span,
2546                    )));
2547                }
2548                let args = self.value_args(entry.id.as_str())?;
2549                self.expect(TokenKind::RParen, "expected ')'")?;
2550                return Ok(self.target.values.push(ValueNode::new(
2551                    Value::Call {
2552                        name: entry.id.clone(),
2553                        args,
2554                    },
2555                    Some(Span::new(self.file(), start, end)),
2556                )));
2557            }
2558        }
2559        if let Some(domain_name) = self
2560            .resolve_enum_domain_mixed(phrase)
2561            .or_else(|| self.resolve_enum_domain_mixed(canonical_keyword(phrase)))
2562        {
2563            let domain = self
2564                .catalog
2565                .enum_domain(domain_name)
2566                .expect("resolved enum domain must exist");
2567            // Enum call: `Color(Yellow)`.
2568            self.expect(TokenKind::LParen, "expected '('")?;
2569            let (member_phrase, _, _) = self.enum_member_phrase()?;
2570            let member = self
2571                .resolve_enum_member_mixed(&domain.domain, &member_phrase)
2572                .unwrap_or_else(|| (domain.domain.clone(), member_phrase.clone()));
2573            self.expect(TokenKind::RParen, "expected ')' after enum member")?;
2574            return Ok(self.target.values.push(ValueNode::new(
2575                Value::Enum {
2576                    value_type: member.0,
2577                    value: member.1,
2578                },
2579                Some(Span::new(self.file(), start, end)),
2580            )));
2581        }
2582        if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LParen)) {
2583            self.pos += 1;
2584            self.call_stack.push(phrase.to_string());
2585            let args = self.opaque_value_args()?;
2586            self.call_stack.pop();
2587            self.expect(TokenKind::RParen, "expected ')' after value call")?;
2588            return Ok(self.target.values.push(ValueNode::new(
2589                Value::Call {
2590                    name: phrase.to_string(),
2591                    args,
2592                },
2593                Some(Span::new(self.file(), start, end)),
2594            )));
2595        }
2596        Ok(self.target.values.push(ValueNode::new(
2597            Value::Call {
2598                name: phrase.to_string(),
2599                args: Vec::new(),
2600            },
2601            Some(Span::new(self.file(), start, end)),
2602        )))
2603    }
2604
2605    fn bare_member(
2606        &mut self,
2607        phrase: &str,
2608        start: Position,
2609        end: Position,
2610    ) -> Result<wir::ValueId> {
2611        // Some enum members use a colon in their Workshop spelling (for
2612        // example `Arrow: Up`). `phrase()` intentionally stops at the colon
2613        // for ordinary identifiers, so complete the member only when the
2614        // enclosing signature has already declared an enum domain.
2615        if !matches!(phrase, "None" | "无" | "True" | "真" | "False" | "假")
2616            && matches!(self.peek().map(|token| token.kind), Some(TokenKind::Colon))
2617        {
2618            let saved = self.pos;
2619            self.pos += 1;
2620            let (suffix, _, suffix_end) = self.phrase()?;
2621            let owned_phrase = format!("{phrase}: {suffix}");
2622            let recognized = self
2623                .expected_domain
2624                .and_then(|domain| self.resolve_enum_member_mixed(domain, &owned_phrase))
2625                .or_else(|| self.resolve_enum_member_mixed("Hero", &owned_phrase));
2626            if recognized.is_some() {
2627                return self.bare_member_resolved(&owned_phrase, start, suffix_end);
2628            }
2629            self.pos = saved;
2630        }
2631        self.bare_member_resolved(phrase, start, end)
2632    }
2633
2634    fn bare_member_resolved(
2635        &mut self,
2636        phrase: &str,
2637        start: Position,
2638        end: Position,
2639    ) -> Result<wir::ValueId> {
2640        match (matches!(phrase, "None" | "无"), self.expected_domain) {
2641            (true, Some(expected))
2642                if matches!(
2643                    expected,
2644                    "ChaseTimeReeval"
2645                        | "ChaseRateReeval"
2646                        | "Invis"
2647                        | "ThrottleReeval"
2648                        | "EffectReeval"
2649                ) =>
2650            {
2651                return Ok(self.target.values.push(ValueNode::new(
2652                    Value::Enum {
2653                        value_type: expected.to_string(),
2654                        value: "NONE".to_string(),
2655                    },
2656                    Some(Span::new(self.file(), start, end)),
2657                )));
2658            }
2659            _ => {}
2660        }
2661        if let Some(variable) = self.globals.get(phrase).copied() {
2662            return Ok(self.target.values.push(ValueNode::new(
2663                Value::GlobalVariable(variable),
2664                Some(Span::new(self.file(), start, end)),
2665            )));
2666        }
2667        if let Some(expected) = self.expected_domain {
2668            if let Some((value_type, value)) = self.resolve_enum_member_mixed(expected, phrase) {
2669                return Ok(self.target.values.push(ValueNode::new(
2670                    Value::Enum { value_type, value },
2671                    Some(Span::new(self.file(), start, end)),
2672                )));
2673            }
2674        }
2675        if let Some((value_type, value)) = self.resolve_enum_member_mixed("Team", phrase) {
2676            return Ok(self.target.values.push(ValueNode::new(
2677                Value::Enum { value_type, value },
2678                Some(Span::new(self.file(), start, end)),
2679            )));
2680        }
2681        if phrase == "Visible To and String" {
2682            return Ok(self.target.values.push(ValueNode::new(
2683                Value::Enum {
2684                    value_type: "HudReeval".to_string(),
2685                    value: "VISIBILITY_AND_STRING".to_string(),
2686                },
2687                Some(Span::new(self.file(), start, end)),
2688            )));
2689        }
2690        if self
2691            .call_stack
2692            .last()
2693            .is_some_and(|call| call == "createHudText")
2694        {
2695            if let Some((value_type, value)) = self.resolve_enum_member_mixed("HudPosition", phrase)
2696            {
2697                return Ok(self.target.values.push(ValueNode::new(
2698                    Value::Enum { value_type, value },
2699                    Some(Span::new(self.file(), start, end)),
2700                )));
2701            }
2702        }
2703        if (self.expected_domain.is_none()
2704            && matches!(
2705                phrase,
2706                "Up" | "上" | "Down" | "下" | "Left" | "左" | "Right" | "右"
2707            )
2708            && (self
2709                .call_stack
2710                .last()
2711                .is_some_and(|call| matches!(call.as_str(), "multiply" | "add"))
2712                || self.call_stack.is_empty()
2713                || self
2714                    .call_stack
2715                    .iter()
2716                    .any(|call| call == "startAcceleration")
2717                || self.call_stack.iter().any(|call| {
2718                    call == "raycastHitPosition"
2719                        || call == "Direction Towards"
2720                        || call == "directionTowards"
2721                })))
2722            || (matches!(self.expected_domain, Some("Position")) && matches!(phrase, "Up" | "上"))
2723        {
2724            let value = match phrase {
2725                "Left" | "左" => "LEFT",
2726                "Right" | "右" => "RIGHT",
2727                "Down" | "下" => "DOWN",
2728                _ => "UP",
2729            };
2730            return Ok(self.target.values.push(ValueNode::new(
2731                Value::Enum {
2732                    value_type: "Vector".to_string(),
2733                    value: value.to_string(),
2734                },
2735                Some(Span::new(self.file(), start, end)),
2736            )));
2737        }
2738        // Event filter domains are resolved by the event parser and are not
2739        // value-argument domains. Excluding them here keeps their spellings
2740        // from making unrelated bare value arguments ambiguous (for example,
2741        // `All` in `Set Invisible(..., All)`).
2742        let matches: Vec<(String, String)> = self
2743            .catalog
2744            .bare_member_matches(&self.locale, phrase)
2745            .into_iter()
2746            .filter(|(domain, _)| domain != "EventTeam" && domain != "EventPlayer")
2747            .collect();
2748        if matches.len() == 1 {
2749            return Ok(self.target.values.push(ValueNode::new(
2750                Value::Enum {
2751                    value_type: matches[0].0.clone(),
2752                    value: matches[0].1.clone(),
2753                },
2754                Some(Span::new(self.file(), start, end)),
2755            )));
2756        }
2757        if matches.len() > 1 {
2758            // #111: a bare spelling shared by several enum domains resolves
2759            // only when the enclosing call's canonical signature pins exactly
2760            // one of the matching domains. No pin keeps the deterministic
2761            // ambiguity diagnostic — no guessing, no global precedence.
2762            if let Some(expected) = self.expected_domain {
2763                let pinned: Vec<&(String, String)> = matches
2764                    .iter()
2765                    .filter(|(domain, _)| domain == expected)
2766                    .collect();
2767                if pinned.len() == 1 {
2768                    return Ok(self.target.values.push(ValueNode::new(
2769                        Value::Enum {
2770                            value_type: pinned[0].0.clone(),
2771                            value: pinned[0].1.clone(),
2772                        },
2773                        Some(Span::new(self.file(), start, end)),
2774                    )));
2775                }
2776            }
2777            return Err(WorkshopError::Unsupported {
2778                message: format!("ambiguous enum member '{phrase}' (multiple domains match)"),
2779                span: Some(Span::new(self.file(), start, end)),
2780            });
2781        }
2782        // A bare value constant (e.g. Empty Array).
2783        if let Some(entry) = self.resolve_entry(Kind::Value, phrase) {
2784            if entry.id == "null" {
2785                return Ok(self.target.values.push(ValueNode::new(
2786                    Value::Null,
2787                    Some(Span::new(self.file(), start, end)),
2788                )));
2789            }
2790            return Ok(self.target.values.push(ValueNode::new(
2791                Value::Call {
2792                    name: entry.id.clone(),
2793                    args: Vec::new(),
2794                },
2795                Some(Span::new(self.file(), start, end)),
2796            )));
2797        }
2798        Ok(self.target.values.push(ValueNode::new(
2799            Value::Call {
2800                name: phrase.to_string(),
2801                args: Vec::new(),
2802            },
2803            Some(Span::new(self.file(), start, end)),
2804        )))
2805    }
2806
2807    fn value_args(&mut self, call_id: &str) -> Result<Vec<wir::ValueId>> {
2808        let mut args = Vec::new();
2809        if let Some(Token {
2810            kind: TokenKind::RParen,
2811            ..
2812        }) = self.peek()
2813        {
2814            return Ok(args);
2815        }
2816        self.call_stack.push(call_id.to_string());
2817        let mut arg_index = 0usize;
2818        let mut raw_modify_operator = false;
2819        loop {
2820            // Indexed variable actions carry a project-local variable name,
2821            // not a Workshop value expression. Resolve it through the symbol
2822            // table so names such as `Brigitte` remain variable identities.
2823            if matches!(
2824                call_id,
2825                "setGlobalVariableAtIndex" | "modifyGlobalVariableAtIndex"
2826            ) && arg_index == 0
2827            {
2828                let (name, start, end) = self.phrase()?;
2829                let variable = self.global_by_name(&name)?;
2830                args.push(self.target.values.push(ValueNode::new(
2831                    Value::GlobalVariable(variable),
2832                    Some(Span::new(self.file(), start, end)),
2833                )));
2834            } else if matches!(
2835                call_id,
2836                "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex"
2837            ) && arg_index == 1
2838            {
2839                let saved = self.pos;
2840                let (name, start, end) = self.phrase()?;
2841                if let Some(variable) = self.players.get(&name).copied() {
2842                    let player = args[0];
2843                    args[0] = self.target.values.push(ValueNode::new(
2844                        Value::PlayerVariable { player, variable },
2845                        Some(Span::new(self.file(), start, end)),
2846                    ));
2847                } else {
2848                    self.pos = saved;
2849                    let saved_domain = self.expected_domain;
2850                    self.expected_domain = self.context.expected_domain(call_id, arg_index);
2851                    let arg = self.value();
2852                    self.expected_domain = saved_domain;
2853                    args.push(arg?);
2854                }
2855            } else if matches!(
2856                call_id,
2857                "modifyGlobalVariableAtIndex" | "modifyPlayerVariableAtIndex"
2858            ) && {
2859                let operator_position = arg_index == 2 || arg_index == 3 && raw_modify_operator;
2860                let saved = self.pos;
2861                let is_operator = operator_position && self.modify_op().is_ok();
2862                self.pos = saved;
2863                is_operator
2864            } {
2865                let operator = self.modify_op()?;
2866                let name = match operator {
2867                    ModifyOp::Add => "add",
2868                    ModifyOp::Subtract => "subtract",
2869                    ModifyOp::Multiply => "multiply",
2870                    ModifyOp::Divide => "divide",
2871                    ModifyOp::Modulo => "modulo",
2872                    ModifyOp::RaiseToPower => "raiseToPower",
2873                    ModifyOp::AppendToArray => "appendToArray",
2874                    ModifyOp::RemoveFromArray => "removeFromArray",
2875                    ModifyOp::RemoveFromArrayByIndex => "removeFromArrayByIndex",
2876                };
2877                args.push(self.target.values.push(ValueNode::new(
2878                    Value::Call {
2879                        name: name.to_string(),
2880                        args: Vec::new(),
2881                    },
2882                    None,
2883                )));
2884            } else {
2885                if matches!(
2886                    call_id,
2887                    "modifyGlobalVariableAtIndex" | "modifyPlayerVariableAtIndex"
2888                ) && arg_index == 2
2889                {
2890                    let saved = self.pos;
2891                    raw_modify_operator = self.modify_op().is_err();
2892                    self.pos = saved;
2893                }
2894                // Each argument is parsed with the domain its position expects
2895                // per the enclosing call's canonical signature (#111); nested
2896                // calls override the expectation for their own arguments.
2897                let saved = self.expected_domain;
2898                self.expected_domain =
2899                    self.context
2900                        .expected_domain(call_id, arg_index)
2901                        .or_else(|| {
2902                            (matches!(call_id, "array" | "randomValueInArray"))
2903                                .then_some(saved)
2904                                .flatten()
2905                        });
2906                let arg = self.value();
2907                self.expected_domain = saved;
2908                args.push(arg?);
2909            }
2910            arg_index += 1;
2911            match self.peek() {
2912                Some(Token {
2913                    kind: TokenKind::Comma,
2914                    ..
2915                }) => {
2916                    self.pos += 1;
2917                }
2918                Some(Token {
2919                    kind: TokenKind::Colon,
2920                    ..
2921                }) => {
2922                    self.pos += 1;
2923                    if !matches!(
2924                        self.peek(),
2925                        Some(Token {
2926                            kind: TokenKind::RParen,
2927                            ..
2928                        })
2929                    ) {
2930                        let _ = self.phrase()?;
2931                    }
2932                    match self.peek() {
2933                        Some(Token {
2934                            kind: TokenKind::Comma,
2935                            ..
2936                        }) => self.pos += 1,
2937                        Some(Token {
2938                            kind: TokenKind::RParen,
2939                            ..
2940                        }) => break,
2941                        Some(token) => return Err(self.malformed("expected ',' or ')'", &token)),
2942                        None => {
2943                            return Err(self.malformed("unexpected end of value call", self.eof()));
2944                        }
2945                    }
2946                }
2947                _ => break,
2948            }
2949        }
2950        self.call_stack.pop();
2951        Ok(args)
2952    }
2953
2954    fn opaque_value_args(&mut self) -> Result<Vec<wir::ValueId>> {
2955        let mut args = Vec::new();
2956        if matches!(self.peek().map(|token| token.kind), Some(TokenKind::RParen)) {
2957            return Ok(args);
2958        }
2959        loop {
2960            args.push(self.value()?);
2961            match self.peek() {
2962                Some(Token {
2963                    kind: TokenKind::Colon,
2964                    ..
2965                }) => {
2966                    self.pos += 1;
2967                    if !matches!(
2968                        self.peek(),
2969                        Some(Token {
2970                            kind: TokenKind::RParen,
2971                            ..
2972                        })
2973                    ) {
2974                        let _ = self.phrase()?;
2975                    }
2976                    match self.peek() {
2977                        Some(Token {
2978                            kind: TokenKind::Comma,
2979                            ..
2980                        }) => self.pos += 1,
2981                        Some(Token {
2982                            kind: TokenKind::RParen,
2983                            ..
2984                        }) => break,
2985                        Some(token) => return Err(self.malformed("expected ',' or ')'", &token)),
2986                        None => {
2987                            return Err(self.malformed("unexpected end of value call", self.eof()));
2988                        }
2989                    }
2990                }
2991                Some(Token {
2992                    kind: TokenKind::Comma,
2993                    ..
2994                }) => self.pos += 1,
2995                Some(Token {
2996                    kind: TokenKind::RParen,
2997                    ..
2998                }) => break,
2999                Some(token) => return Err(self.malformed("expected ',' or ')'", &token)),
3000                None => return Err(self.malformed("unexpected end of value call", self.eof())),
3001            }
3002        }
3003        Ok(args)
3004    }
3005
3006    fn line_has_assignment(&self) -> bool {
3007        self.tokens[self.pos..]
3008            .iter()
3009            .take_while(|token| !matches!(token.kind, TokenKind::Semi | TokenKind::RBrace))
3010            .any(|token| matches!(&token.kind, TokenKind::Op(op) if matches!(op.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%=")))
3011    }
3012
3013    fn push_bool(&mut self, value: bool, start: Position, end: Position) -> wir::ValueId {
3014        self.target.values.push(ValueNode::new(
3015            Value::Bool(value),
3016            Some(Span::new(self.file(), start, end)),
3017        ))
3018    }
3019
3020    fn global_by_name(&mut self, name: &str) -> Result<wir::GlobalVarId> {
3021        if let Some(id) = self.globals.get(name).copied() {
3022            return Ok(id);
3023        }
3024        let index = self.next_variable_index(false);
3025        let id = self.target.global_variables.push(wir::WorkshopVariable {
3026            name: name.to_string(),
3027            index,
3028            span: None,
3029            name_span: None,
3030        });
3031        self.globals.insert(name.to_string(), id);
3032        Ok(id)
3033    }
3034
3035    fn player_by_name(&mut self, name: &str) -> Result<wir::PlayerVarId> {
3036        if let Some(id) = self.players.get(name).copied() {
3037            return Ok(id);
3038        }
3039        let index = self.next_variable_index(true);
3040        let id = self.target.player_variables.push(wir::WorkshopVariable {
3041            name: name.to_string(),
3042            index,
3043            span: None,
3044            name_span: None,
3045        });
3046        self.players.insert(name.to_string(), id);
3047        Ok(id)
3048    }
3049
3050    fn next_variable_index(&self, player: bool) -> u32 {
3051        let variables = if player {
3052            &self.target.player_variables
3053        } else {
3054            &self.target.global_variables
3055        };
3056        variables
3057            .iter()
3058            .map(|variable| variable.index)
3059            .max()
3060            .map_or(0, |index| index.saturating_add(1))
3061    }
3062
3063    fn subroutine_by_name(&self, name: &str) -> Result<wir::SubroutineId> {
3064        self.subroutines
3065            .get(name)
3066            .copied()
3067            .ok_or_else(|| WorkshopError::Unknown {
3068                kind: "subroutine",
3069                spelling: name.to_string(),
3070                locale: self.locale.clone(),
3071                span: None,
3072            })
3073    }
3074
3075    /// Read the maximal phrase of consecutive words (space-joined). Phrases
3076    /// may span lines because long Workshop action arguments wrap mid-phrase.
3077    fn phrase(&mut self) -> Result<(String, Position, Position)> {
3078        let mut words = Vec::new();
3079        let (start, mut end) = match self.peek() {
3080            Some(Token {
3081                kind: TokenKind::Word(word),
3082                start,
3083                end,
3084            }) => {
3085                words.push(word.clone());
3086                (start, end)
3087            }
3088            Some(Token {
3089                kind: TokenKind::Number { text, .. },
3090                start,
3091                end,
3092            }) => {
3093                words.push(text.clone());
3094                (start, end)
3095            }
3096            Some(token) => return Err(self.malformed("expected an identifier", &token)),
3097            None => return Err(self.malformed("expected an identifier", self.eof())),
3098        };
3099        self.pos += 1;
3100        while let Some(token) = self.peek() {
3101            match token {
3102                Token {
3103                    kind: TokenKind::Word(word),
3104                    end: word_end,
3105                    ..
3106                } => {
3107                    words.push(word.clone());
3108                    end = word_end;
3109                    self.pos += 1;
3110                }
3111                // Enum members embed numbers (`Team 2`, `Ability 2`); the
3112                // lexer splits them from the word, so phrases join Number
3113                // tokens too (#119 reference evidence).
3114                Token {
3115                    kind: TokenKind::Number { text, .. },
3116                    end: number_end,
3117                    ..
3118                } => {
3119                    words.push(text.clone());
3120                    end = number_end;
3121                    self.pos += 1;
3122                }
3123                _ => break,
3124            }
3125        }
3126        Ok((words.join(" "), start, end))
3127    }
3128
3129    /// Read a single-line phrase (stops at a line boundary). Used for names
3130    /// that are structurally one per line, such as variable declarations.
3131    fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
3132        let mut words = Vec::new();
3133        let (start, mut end, line) = match self.peek() {
3134            Some(Token {
3135                kind: TokenKind::Word(word),
3136                start,
3137                end,
3138            }) => {
3139                words.push(word.clone());
3140                (start, end, start.line)
3141            }
3142            Some(Token {
3143                kind: TokenKind::Number { text, .. },
3144                start,
3145                end,
3146            }) => {
3147                words.push(text.clone());
3148                (start, end, start.line)
3149            }
3150            Some(token) => return Err(self.malformed("expected an identifier", &token)),
3151            None => return Err(self.malformed("expected an identifier", self.eof())),
3152        };
3153        self.pos += 1;
3154        while let Some(token) = self.peek() {
3155            let (word, word_start, word_end) = match token {
3156                Token {
3157                    kind: TokenKind::Word(word),
3158                    start,
3159                    end,
3160                } => (word, start, end),
3161                Token {
3162                    kind: TokenKind::Number { text, .. },
3163                    start,
3164                    end,
3165                } => (text, start, end),
3166                Token {
3167                    kind: TokenKind::Dot,
3168                    start,
3169                    end,
3170                } => (".".to_string(), start, end),
3171                Token {
3172                    kind: TokenKind::Op(op),
3173                    start,
3174                    end,
3175                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
3176                _ => break,
3177            };
3178            if word_start.line != line {
3179                break;
3180            }
3181            words.push(word);
3182            end = word_end;
3183            self.pos += 1;
3184        }
3185        Ok((
3186            words
3187                .join(" ")
3188                .replace(" .", ".")
3189                .replace(". ", ".")
3190                .replace(" : ", ":")
3191                .replace(" %", "%"),
3192            start,
3193            end,
3194        ))
3195    }
3196
3197    fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
3198        let (mut phrase, start, mut end) = self.phrase_on_line()?;
3199        if matches!(
3200            self.peek(),
3201            Some(Token {
3202                kind: TokenKind::Colon,
3203                ..
3204            })
3205        ) {
3206            let colon_pos = self.pos;
3207            self.next();
3208            let (rest, _, rest_end) = self.phrase_on_line()?;
3209            if matches!(
3210                self.peek(),
3211                Some(Token {
3212                    kind: TokenKind::LBrace,
3213                    ..
3214                })
3215            ) {
3216                phrase.push(':');
3217                phrase.push(' ');
3218                phrase.push_str(&rest);
3219                end = rest_end;
3220            } else {
3221                self.pos = colon_pos;
3222            }
3223        }
3224        Ok((phrase, start, end))
3225    }
3226
3227    fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
3228        let first = self
3229            .peek()
3230            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
3231        let start = first.start;
3232        let line = first.start.line;
3233        let mut end = first.end;
3234        let mut parts = Vec::new();
3235        while let Some(token) = self.peek() {
3236            if token.start.line != line
3237                || matches!(token.kind, TokenKind::RParen | TokenKind::Comma)
3238            {
3239                break;
3240            }
3241            self.pos += 1;
3242            end = token.end;
3243            parts.push(raw_token_text(&token.kind));
3244        }
3245        if parts.is_empty() {
3246            return Err(self.malformed("expected an enum member", &first));
3247        }
3248        Ok((
3249            parts
3250                .join(" ")
3251                .replace(" : ", ":")
3252                .replace(" .", ".")
3253                .replace(". ", "."),
3254            start,
3255            end,
3256        ))
3257    }
3258
3259    /// Read a text line (tokens until `;`), joining words and dashes into
3260    /// the literal text, and consume the terminating `;`.
3261    fn line_text(&mut self) -> Result<String> {
3262        let mut parts = Vec::new();
3263        loop {
3264            match self.peek() {
3265                Some(Token {
3266                    kind: TokenKind::Semi,
3267                    ..
3268                }) => {
3269                    self.pos += 1;
3270                    break;
3271                }
3272                Some(Token {
3273                    kind: TokenKind::Word(word),
3274                    ..
3275                }) => {
3276                    parts.push(word.clone());
3277                    self.pos += 1;
3278                }
3279                Some(Token {
3280                    kind: TokenKind::Op(op),
3281                    ..
3282                }) if op == "-" => {
3283                    parts.push("-".to_string());
3284                    self.pos += 1;
3285                }
3286                Some(Token {
3287                    kind: TokenKind::Number { value, .. },
3288                    ..
3289                }) => {
3290                    parts.push(value.to_string());
3291                    self.pos += 1;
3292                }
3293                Some(Token {
3294                    kind: TokenKind::Dot,
3295                    ..
3296                }) => {
3297                    parts.push(".".to_string());
3298                    self.pos += 1;
3299                }
3300                Some(Token {
3301                    kind: TokenKind::Colon,
3302                    ..
3303                }) => {
3304                    parts.push(":".to_string());
3305                    self.pos += 1;
3306                }
3307                Some(token) => return Err(self.malformed("expected a text line", &token)),
3308                None => return Err(self.malformed("unexpected end of input in line", self.eof())),
3309            }
3310        }
3311        Ok(parts
3312            .join(" ")
3313            .replace(" .", ".")
3314            .replace(". ", ".")
3315            .replace(" : ", ":"))
3316    }
3317
3318    /// Consume a known keyword phrase, verifying its spelling.
3319    fn consume_phrase(&mut self, expected: &str) -> Result<()> {
3320        let (phrase, _, _) = self.phrase()?;
3321        if phrase != expected {
3322            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
3323        }
3324        Ok(())
3325    }
3326
3327    fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
3328        match self.next() {
3329            Some(Token {
3330                kind: TokenKind::Word(word),
3331                start,
3332                ..
3333            }) if self.canonical_keyword(&word) == expected => Ok(start),
3334            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
3335            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
3336        }
3337    }
3338
3339    fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
3340        match self.next() {
3341            Some(token) if token.kind == kind => Ok(()),
3342            Some(token) => Err(self.malformed(message, &token)),
3343            None => Err(self.malformed(message, self.eof())),
3344        }
3345    }
3346
3347    fn expect_string(&mut self, message: &str) -> Result<String> {
3348        match self.next() {
3349            Some(Token {
3350                kind: TokenKind::String(content),
3351                ..
3352            }) => Ok(content),
3353            Some(token) => Err(self.malformed(message, &token)),
3354            None => Err(self.malformed(message, self.eof())),
3355        }
3356    }
3357
3358    fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
3359        WorkshopError::Malformed {
3360            message: message.to_string(),
3361            span: Some(Span::new(self.file(), token.start, token.end)),
3362        }
3363    }
3364
3365    fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
3366        WorkshopError::Unknown {
3367            kind,
3368            spelling: spelling.to_string(),
3369            locale: self.locale.clone(),
3370            span: None,
3371        }
3372    }
3373
3374    fn peek(&self) -> Option<Token> {
3375        self.tokens.get(self.pos).cloned()
3376    }
3377
3378    fn peek_at(&self, offset: usize) -> Option<Token> {
3379        self.tokens.get(self.pos + offset).cloned()
3380    }
3381
3382    fn next(&mut self) -> Option<Token> {
3383        let token = self.tokens.get(self.pos).cloned();
3384        if token.is_some() {
3385            self.pos += 1;
3386        }
3387        token
3388    }
3389
3390    fn previous(&self) -> &Token {
3391        self.tokens
3392            .get(self.pos.saturating_sub(1))
3393            .unwrap_or_else(|| self.tokens.last().unwrap())
3394    }
3395
3396    fn previous_span(&self) -> (Position, Position) {
3397        let token = self.previous();
3398        (token.start, token.end)
3399    }
3400
3401    fn span_here(&self) -> (Position, Position) {
3402        let token = self
3403            .peek()
3404            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
3405        (token.start, token.end)
3406    }
3407
3408    fn eof(&self) -> &Token {
3409        self.tokens.last().unwrap()
3410    }
3411
3412    fn file(&self) -> crate::ids::Id<SourceFile> {
3413        crate::ids::Id::from_index(0)
3414    }
3415}
3416
3417fn is_comparison(op: &str) -> bool {
3418    matches!(op, "==" | "!=" | "<" | "<=" | ">" | ">=")
3419}
3420
3421fn canonical_keyword(keyword: &str) -> &str {
3422    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
3423    KEYWORDS
3424        .get_or_init(|| {
3425            serde_json::from_str(include_str!("structural_keywords.json"))
3426                .expect("structural keyword data is valid JSON")
3427        })
3428        .get(keyword)
3429        .map(String::as_str)
3430        .unwrap_or(keyword)
3431}
3432
3433fn raw_token_text(kind: &TokenKind) -> String {
3434    match kind {
3435        TokenKind::Word(value) => value.clone(),
3436        TokenKind::Number { text, .. } => text.clone(),
3437        TokenKind::String(value) => format!("\"{}\"", value.replace('"', "\\\"")),
3438        TokenKind::Op(value) => value.clone(),
3439        TokenKind::LParen => "(".to_string(),
3440        TokenKind::RParen => ")".to_string(),
3441        TokenKind::Comma => ",".to_string(),
3442        TokenKind::Semi => ";".to_string(),
3443        TokenKind::LBrace => "{".to_string(),
3444        TokenKind::RBrace => "}".to_string(),
3445        TokenKind::Colon => ":".to_string(),
3446        TokenKind::Dot => ".".to_string(),
3447        TokenKind::LBracket => "[".to_string(),
3448        TokenKind::RBracket => "]".to_string(),
3449        TokenKind::Eof => String::new(),
3450    }
3451}