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