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