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