Skip to main content

opy_rs/
lower.rs

1//! Semantic resolution and HIR lowering (#45).
2//!
3//! Resolves the parsed CST into the opy-rs-owned Opy HIR contract
4//! ([`crate::hir::Program`]): declarations and references resolve to typed
5//! HIR nodes, custom enums fold to constants, `vect` becomes a vector,
6//! `.format()` becomes a format node, `wait` default arguments are filled,
7//! and subroutine calls become `CallSubroutine` statements. Semantic errors
8//! (unknown identifiers, unknown custom-enum members, invalid `vect` arity)
9//! are structured and source-located.
10//!
11//! Builtin action/value/member identity, action/value position, signatures
12//! and arity, receiver categories, parameter enum-domain identities, and
13//! non-contextual source aliases resolve through the OPY semantic
14//! compatibility manifest ([`crate::manifest`], issue #109) before Workshop
15//! emission: unknown or misplaced builtins fail here with structured,
16//! source-located diagnostics instead of surfacing as emitter catalog
17//! misses.
18//!
19//! Ownership boundary: enum *domains* are catalog-identity links carried by
20//! the manifest signatures, but enum *member lists* are Workshop-owned
21//! catalog content. A member access on a declared domain identity resolves
22//! as an opaque `Enum` node and member-existence/domain checks
23//! (`unknown-enum-member` for Workshop enums, `enum-domain-mismatch`) are
24//! `lowering-dependent` (issue #8) — they are not approximated here. Custom
25//! (user-declared) enum member checks are OPY-level source semantics and
26//! stay in this frontend.
27
28use std::collections::{HashMap, HashSet};
29
30use crate::hir::types::{
31    Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define,
32    DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, PROTOCOL_VERSION,
33    Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry,
34    Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan,
35    Stmt as HirStmt, SwitchArm as HirSwitchArm, default_var_index,
36};
37
38use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt, TopLevel};
39use crate::diag::{OpyError, OpyResult, Span};
40use crate::manifest::{Function, FunctionKind, Manifest, Param, ParamDefault};
41use workshop_rs::catalog::Catalog;
42
43/// The protocol envelope this frontend produces.
44const PROTOCOL_NAME: &str = "wright/opy-hir";
45
46/// The call-position context of an expression being lowered; builtin
47/// resolution checks action/value identity against this context.
48#[derive(Clone, Copy, PartialEq, Eq)]
49enum CallPosition {
50    /// A statement position (a bare expression statement).
51    Statement,
52    /// A value position (conditions, assignments, call arguments, …).
53    Value,
54    /// A `for ... in` iterable (only `range` is a valid builtin here).
55    ForIterable,
56    /// An expression occupying a signature-approved lambda argument slot.
57    LambdaArgument,
58    /// An expression in a macro body, whose final call position is determined
59    /// when the macro is expanded.
60    MacroBody,
61}
62
63/// The lowerer's symbol context, built from the CST declarations.
64struct Lowerer {
65    global_declarations: HashMap<String, usize>,
66    player_declarations: HashMap<String, usize>,
67    subroutine_declarations: HashMap<String, usize>,
68    subroutine_definitions: Vec<(String, usize)>,
69    constant_declarations: HashMap<String, usize>,
70    macro_declarations: HashMap<String, usize>,
71    enums: HashMap<String, Vec<String>>,
72    enum_declarations: HashMap<String, usize>,
73    locals: Vec<String>,
74    current_order: usize,
75    allow_dict_literal: bool,
76    /// The authoritative builtin semantic table (issue #109).
77    manifest: &'static Manifest,
78    /// The canonical Workshop catalog linked by the manifest.
79    catalog: Catalog,
80    texture_used: bool,
81    setup_tags: bool,
82    errors: Vec<OpyError>,
83}
84
85impl Lowerer {
86    fn new(manifest: &'static Manifest, catalog: Catalog) -> Self {
87        Self {
88            global_declarations: HashMap::new(),
89            player_declarations: HashMap::new(),
90            subroutine_declarations: HashMap::new(),
91            subroutine_definitions: Vec::new(),
92            constant_declarations: HashMap::new(),
93            macro_declarations: HashMap::new(),
94            enums: HashMap::new(),
95            enum_declarations: HashMap::new(),
96            locals: Vec::new(),
97            current_order: 0,
98            allow_dict_literal: false,
99            manifest,
100            catalog,
101            texture_used: false,
102            setup_tags: false,
103            errors: Vec::new(),
104        }
105    }
106}
107
108mod declarations;
109mod expressions;
110pub(crate) mod policy;
111pub(crate) mod special_forms;
112mod statements;
113mod textures;
114
115pub(crate) fn compressed_component_mode(values: &[Vec<f64>]) -> Option<(bool, f64)> {
116    let component_count = values.first()?.len();
117    let is_vector = component_count == 3;
118    (matches!(component_count, 1 | 3) && values.iter().all(|value| value.len() == component_count))
119        .then_some((is_vector, if is_vector { 4999.0 } else { 49999.0 }))
120}
121
122/// Lower a parsed program into the Opy HIR contract.
123pub fn lower(
124    program: &cst::Program,
125    files: Vec<SourceFile>,
126    defines: Vec<Define>,
127) -> OpyResult<HirProgram> {
128    lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
129}
130
131pub fn lower_with_preprocessing(
132    program: &cst::Program,
133    files: Vec<SourceFile>,
134    defines: Vec<Define>,
135    preprocessing: &PreprocessingState,
136) -> OpyResult<HirProgram> {
137    let manifest = match Manifest::builtin() {
138        Ok(manifest) => manifest,
139        Err(error) => {
140            return Err(OpyError::new(
141                "manifest-error",
142                format!("cannot load the OPY semantic compatibility manifest: {error}"),
143            ));
144        }
145    };
146    let catalog = match Catalog::builtin() {
147        Ok(catalog) => catalog,
148        Err(error) => {
149            return Err(OpyError::new(
150                "catalog-error",
151                format!("cannot load the Workshop catalog: {error}"),
152            ));
153        }
154    };
155    lower_program(program, files, defines, preprocessing, manifest, catalog)
156}
157
158fn lower_program(
159    program: &cst::Program,
160    files: Vec<SourceFile>,
161    defines: Vec<Define>,
162    preprocessing: &PreprocessingState,
163    manifest: &'static Manifest,
164    catalog: Catalog,
165) -> OpyResult<HirProgram> {
166    let mut lowerer = Lowerer::new(manifest, catalog);
167    lowerer.setup_tags = preprocessing
168        .directives
169        .iter()
170        .any(|directive| matches!(directive.name.as_str(), "setupTags" | "setupTx"));
171    lowerer.collect_symbols(program);
172
173    let mut declarations = Vec::new();
174    let mut rules = Vec::new();
175    let mut implicit_subroutines = HashSet::new();
176    for (order, item) in program.top_level.iter().enumerate() {
177        lowerer.current_order = order;
178        match item {
179            TopLevel::Declaration(decl) => {
180                if let Some(declaration) = lowerer.lower_declaration(decl) {
181                    declarations.push(declaration);
182                }
183            }
184            TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
185                lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
186            )),
187            TopLevel::Rule(CstRuleEntry::SubroutineDef {
188                name,
189                presentation_name,
190                span,
191                name_span,
192                body,
193                annotations,
194                rule_prefix,
195            }) => {
196                if !lowerer.subroutine_declarations.contains_key(name)
197                    && implicit_subroutines.insert(name.clone())
198                {
199                    declarations.push(Declaration::Subroutine {
200                        name: name.clone(),
201                        index: None,
202                        span: Some(span.into()),
203                        name_span: Some(name_span.into()),
204                    });
205                }
206                let base_name = presentation_name
207                    .as_deref()
208                    .map(str::to_string)
209                    .unwrap_or_else(|| name.clone());
210                let generated_name = render_rule_name(
211                    &base_name,
212                    rule_prefix.as_deref(),
213                    false,
214                    *span,
215                    files.as_slice(),
216                    preprocessing,
217                )?;
218                rules.push(RuleEntry::SubroutineDef {
219                    kind: "subroutineDef".to_string(),
220                    name: generated_name,
221                    source_name: name.clone(),
222                    span: Some(span.into()),
223                    name_span: Some(name_span.into()),
224                    body: lowerer.lower_block(body, &[], false, true, false),
225                    annotations: lower_annotations(annotations),
226                });
227            }
228        }
229    }
230
231    if !lowerer.errors.is_empty() {
232        return Err(lowerer.errors.swap_remove(0));
233    }
234
235    if lowerer.texture_used || lowerer.setup_tags {
236        declarations.insert(
237            0,
238            Declaration::GlobalVariable {
239                name: "__holygrail__".to_string(),
240                index: Some(127),
241                span: None,
242                name_span: None,
243                initializer: None,
244            },
245        );
246        rules.insert(0, texture_setup_rule());
247    }
248
249    Ok(HirProgram {
250        protocol: Protocol {
251            name: PROTOCOL_NAME.to_string(),
252            version: PROTOCOL_VERSION.to_string(),
253        },
254        generator: Generator {
255            name: crate::LANGUAGE_NAME.to_string(),
256            version: crate::LANGUAGE_VERSION.to_string(),
257            frontend: crate::LANGUAGE_NAME.to_string(),
258        },
259        files,
260        defines,
261        declarations,
262        rules,
263        settings: program.settings.as_ref().map(lower_settings),
264        preprocessing: preprocessing.clone(),
265    })
266}
267
268fn texture_setup_rule() -> RuleEntry {
269    RuleEntry::Rule(Rule {
270        name: format!("OverPy <{}tx> / <{}fg> setup code", '\u{00ad}', '\u{00ad}'),
271        span: None,
272        name_span: None,
273        disabled: false,
274        delimiter: false,
275        new_page: None,
276        annotations: Vec::new(),
277        event: Event {
278            name: "global".to_string(),
279            args: Vec::new(),
280            span: None,
281        },
282        conditions: Vec::new(),
283        actions: vec![
284            synthetic_statement(synthetic_call(
285                "createDummy",
286                vec![
287                    synthetic_call("getAllHeroes", Vec::new()),
288                    texture_dummy_team(),
289                    synthetic_boolean(false),
290                    HirExpr::Null { span: None },
291                    HirExpr::Null { span: None },
292                ],
293            )),
294            synthetic_statement(synthetic_method(
295                texture_dummy_player(),
296                "startForcingName",
297                vec![texture_marker('\u{303c}')],
298            )),
299            texture_state_assignment(synthetic_method(
300                texture_dummy_first_value(),
301                "split",
302                vec![HirExpr::Array {
303                    elements: Vec::new(),
304                    span: None,
305                }],
306            )),
307            synthetic_statement(synthetic_method(
308                texture_dummy_player(),
309                "startForcingName",
310                vec![texture_marker('\u{840}')],
311            )),
312            texture_state_assignment(synthetic_method(
313                synthetic_method(
314                    texture_marker('\u{303c}'),
315                    "replace",
316                    vec![texture_state_variable(), texture_dummy_first_value()],
317                ),
318                "substring",
319                vec![synthetic_number(126.0), synthetic_boolean(true)],
320            )),
321            synthetic_statement(synthetic_call("destroyAllDummies", Vec::new())),
322        ],
323    })
324}
325
326fn synthetic_call(name: &str, args: Vec<HirExpr>) -> HirExpr {
327    HirExpr::Call {
328        name: name.to_string(),
329        args,
330        debug_source: None,
331        span: None,
332    }
333}
334
335fn synthetic_method(receiver: HirExpr, name: &str, args: Vec<HirExpr>) -> HirExpr {
336    HirExpr::ReceiverCall {
337        receiver: Box::new(receiver),
338        name: name.to_string(),
339        args,
340        span: None,
341    }
342}
343
344fn synthetic_enum(value_type: &str, value: &str) -> HirExpr {
345    HirExpr::Enum {
346        value_type: value_type.to_string(),
347        value: value.to_string(),
348        literal: false,
349        span: None,
350    }
351}
352
353fn synthetic_number(value: f64) -> HirExpr {
354    HirExpr::Number {
355        value,
356        text: value.to_string(),
357        span: None,
358    }
359}
360
361fn synthetic_boolean(value: bool) -> HirExpr {
362    HirExpr::Bool { value, span: None }
363}
364
365fn synthetic_index(array: HirExpr, index: HirExpr) -> HirExpr {
366    HirExpr::Index {
367        array: Box::new(array),
368        index: Box::new(index),
369        span: None,
370    }
371}
372
373fn synthetic_conditional(then_value: HirExpr, condition: HirExpr, else_value: HirExpr) -> HirExpr {
374    HirExpr::Conditional {
375        then_value: Box::new(then_value),
376        condition: Box::new(condition),
377        else_value: Box::new(else_value),
378        span: None,
379    }
380}
381
382fn synthetic_statement(expr: HirExpr) -> HirStmt {
383    HirStmt::Expr {
384        expr: Box::new(expr),
385        span: None,
386    }
387}
388
389fn texture_state_variable() -> HirExpr {
390    HirExpr::GlobalVar {
391        name: "__holygrail__".to_string(),
392        span: None,
393    }
394}
395
396fn texture_state_assignment(value: HirExpr) -> HirStmt {
397    HirStmt::Assign {
398        target: Box::new(texture_state_variable()),
399        value: Box::new(value),
400        span: None,
401    }
402}
403
404fn texture_dummy_player() -> HirExpr {
405    synthetic_call("getLastCreatedEntity", Vec::new())
406}
407
408fn texture_dummy_first_value() -> HirExpr {
409    synthetic_index(texture_dummy_player(), synthetic_number(0.0))
410}
411
412fn texture_dummy_team() -> HirExpr {
413    let team_1 = synthetic_enum("Team", "TEAM_1");
414    let team_2 = synthetic_enum("Team", "TEAM_2");
415    synthetic_conditional(
416        team_1.clone(),
417        synthetic_call("getNumberOfSlots", vec![team_1]),
418        synthetic_conditional(
419            team_2.clone(),
420            synthetic_call("getNumberOfSlots", vec![team_2]),
421            synthetic_boolean(true),
422        ),
423    )
424}
425
426fn texture_marker(suffix: char) -> HirExpr {
427    HirExpr::String {
428        value: format!("{}{}", "_".repeat(126), suffix),
429        span: None,
430    }
431}
432
433/// A `#!settings` raw-value lowerer reused across a settings block: the
434/// manifest, catalog, and program symbol tables are built once instead of
435/// once per value. Values lower through the same CST-to-HIR semantic path as
436/// ordinary OPY expressions.
437pub(crate) struct SettingsLowerer {
438    lowerer: Lowerer,
439}
440
441impl SettingsLowerer {
442    pub(crate) fn new(program: &cst::Program) -> OpyResult<Self> {
443        let manifest = Manifest::builtin().map_err(|error| {
444            OpyError::new(
445                "manifest-error",
446                format!("cannot load the OPY semantic compatibility manifest: {error}"),
447            )
448        })?;
449        let catalog = Catalog::builtin().map_err(|error| {
450            OpyError::new(
451                "catalog-error",
452                format!("cannot load the Workshop catalog: {error}"),
453            )
454        })?;
455        let mut lowerer = Lowerer::new(manifest, catalog);
456        lowerer.current_order = program.top_level.len();
457        lowerer.allow_dict_literal = true;
458        // Bare `Infinity` resolves as a constant so settings evaluation can
459        // fold it like the pinned JavaScript global (#512); `NaN`, `null`,
460        // and `undefined` stay unresolved as they are upstream.
461        lowerer
462            .constant_declarations
463            .insert("Infinity".to_string(), 0);
464        lowerer.collect_symbols(program);
465        Ok(Self { lowerer })
466    }
467
468    /// Lower one raw settings value through the shared expression path;
469    /// per-node state is reset so each value observes a fresh context.
470    pub(crate) fn lower(
471        &mut self,
472        text: &str,
473        file: u32,
474        origin: crate::diag::Position,
475    ) -> OpyResult<HirExpr> {
476        let expression = crate::parser::parse_expression_fragment(text, file, origin, None)?;
477        self.lowerer.errors.clear();
478        self.lowerer.texture_used = false;
479        let lowered = self
480            .lowerer
481            .lower_expr(&expression, &[], CallPosition::Value);
482        self.lowerer
483            .errors
484            .first()
485            .cloned()
486            .map_or(Ok(lowered), Err)
487    }
488}
489
490fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
491    match prefix {
492        Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
493            format!("[{prefix}] {name}")
494        }
495        _ => name.to_string(),
496    }
497}
498
499pub(crate) fn strip_rule_name_formatting(text: &str) -> impl Iterator<Item = char> + '_ {
500    text.chars().filter(|character| {
501        !matches!(
502            character,
503            '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}' | '\u{061C}'
504        )
505    })
506}
507
508#[derive(Clone, Debug)]
509enum TemplateValue {
510    String(String),
511    Bool(bool),
512}
513
514fn rule_template_values(
515    name: &str,
516    prefix: &str,
517    file: &str,
518    path: &str,
519    delimiter: bool,
520) -> [(&'static str, TemplateValue); 14] {
521    [
522        ("$rule", TemplateValue::String(name.to_string())),
523        ("$prefix", TemplateValue::String(prefix.to_string())),
524        ("$file", TemplateValue::String(file.to_string())),
525        ("$path", TemplateValue::String(path.to_string())),
526        ("$isDelimiter", TemplateValue::Bool(delimiter)),
527        ("$prefixTitle", TemplateValue::String(title_case(prefix))),
528        ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
529        ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
530        ("$fileTitle", TemplateValue::String(title_case(file))),
531        ("$fileUpper", TemplateValue::String(file.to_uppercase())),
532        ("$fileLower", TemplateValue::String(file.to_lowercase())),
533        ("$pathTitle", TemplateValue::String(title_case(path))),
534        ("$pathUpper", TemplateValue::String(path.to_uppercase())),
535        ("$pathLower", TemplateValue::String(path.to_lowercase())),
536    ]
537}
538
539fn render_rule_name(
540    name: &str,
541    prefix: Option<&str>,
542    delimiter: bool,
543    span: Span,
544    files: &[SourceFile],
545    preprocessing: &PreprocessingState,
546) -> OpyResult<String> {
547    let Some(template) = preprocessing
548        .rule_prefix_template
549        .as_ref()
550        .map(|value| value.value.as_str())
551    else {
552        return Ok(
553            strip_rule_name_formatting(&prefixed_rule_name(name, prefix, delimiter)).collect(),
554        );
555    };
556    let (file, path) = rule_file_parts(span.file, files);
557    let prefix = prefix.unwrap_or_default();
558    let values = rule_template_values(name, prefix, &file, &path, delimiter);
559    evaluate_template(template, &values)
560        .map(|name| strip_rule_name_formatting(&name).collect())
561        .map_err(|message| {
562            OpyError::at(
563                "rule-prefix-template-invalid",
564                format!("could not resolve rule prefix template: {message}"),
565                span,
566            )
567        })
568}
569
570/// The name of a compiler-generated rule under the active rule prefix
571/// template: it has no prefix, no file, and is not a delimiter.
572pub(crate) fn render_generated_rule_name(name: &str, preprocessing: &PreprocessingState) -> String {
573    let Some(template) = preprocessing
574        .rule_prefix_template
575        .as_ref()
576        .map(|value| value.value.as_str())
577    else {
578        return name.to_string();
579    };
580    let values = rule_template_values(name, "", "", "", false);
581    evaluate_template(template, &values)
582        .map(|rendered| strip_rule_name_formatting(&rendered).collect())
583        .unwrap_or_else(|_| name.to_string())
584}
585
586fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
587    let path = files
588        .iter()
589        .find(|file| file.id == file_id)
590        .map(|file| file.path.replace('\\', "/"))
591        .unwrap_or_default();
592    let without_extension = path
593        .strip_suffix(".opy")
594        .or_else(|| path.strip_suffix(".OPY"))
595        .unwrap_or(&path)
596        .to_string();
597    let file = without_extension
598        .rsplit('/')
599        .next()
600        .unwrap_or_default()
601        .to_string();
602    (file, without_extension)
603}
604
605fn title_case(value: &str) -> String {
606    let mut result = String::with_capacity(value.len());
607    let mut capitalize = true;
608    for ch in value.chars() {
609        if ch == '_' {
610            result.push(' ');
611            capitalize = true;
612        } else if capitalize && ch.is_ascii_alphabetic() {
613            result.push(ch.to_ascii_uppercase());
614            capitalize = false;
615        } else {
616            result.push(ch);
617            if !ch.is_whitespace() && ch != '/' {
618                capitalize = false;
619            }
620        }
621        if ch == '/' || ch.is_whitespace() {
622            capitalize = true;
623        }
624    }
625    result
626}
627
628fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
629    if let Some((then_value, condition, else_value)) = split_conditional(template) {
630        let branch = if evaluate_condition(condition, values)? {
631            then_value
632        } else {
633            else_value
634        };
635        return evaluate_string(branch, values);
636    }
637    evaluate_string(template, values)
638}
639
640fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
641    let mut quote = None;
642    let mut depth = 0usize;
643    let mut if_start = None;
644    let mut else_start = None;
645    for (index, ch) in value.char_indices() {
646        match (ch, quote) {
647            ('"' | '\'', None) => quote = Some(ch),
648            (ch, Some(current)) if ch == current => quote = None,
649            ('{', None) => depth += 1,
650            ('}', None) => depth = depth.saturating_sub(1),
651            _ => {}
652        }
653        if quote.is_none() && depth == 0 {
654            if value[index..].starts_with(" if ") && if_start.is_none() {
655                if_start = Some(index);
656            } else if value[index..].starts_with(" else ") && else_start.is_none() {
657                else_start = Some(index);
658            }
659        }
660    }
661    let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
662        return None;
663    };
664    Some((
665        value[..if_start].trim(),
666        value[if_start + 4..else_start].trim(),
667        value[else_start + 6..].trim(),
668    ))
669}
670
671fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
672    let value = value.trim();
673    if let Some(rest) = value.strip_prefix("not ") {
674        return Ok(!evaluate_condition(rest, values)?);
675    }
676    if let Some((left, right)) = value.split_once(" or ") {
677        return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
678    }
679    if let Some((left, right)) = value.split_once(" and ") {
680        return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
681    }
682    match lookup_template_value(value, values)? {
683        TemplateValue::Bool(value) => Ok(value),
684        TemplateValue::String(value) => Ok(!value.is_empty()),
685    }
686}
687
688fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
689    let value = value.trim();
690    if let Some(body) = value
691        .strip_prefix("f\"")
692        .and_then(|body| body.strip_suffix('"'))
693    {
694        return interpolate_fstring(body, values);
695    }
696    if let Some(body) = value
697        .strip_prefix("f'")
698        .and_then(|body| body.strip_suffix('\''))
699    {
700        return interpolate_fstring(body, values);
701    }
702    if value.len() >= 2
703        && ((value.starts_with('"') && value.ends_with('"'))
704            || (value.starts_with('\'') && value.ends_with('\'')))
705    {
706        return Ok(value[1..value.len() - 1].to_string());
707    }
708    match lookup_template_value(value, values)? {
709        TemplateValue::String(value) => Ok(value),
710        TemplateValue::Bool(value) => Ok(value.to_string()),
711    }
712}
713
714fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
715    let mut result = String::new();
716    let mut remaining = body;
717    while let Some(start) = remaining.find('{') {
718        result.push_str(&remaining[..start]);
719        let end = remaining[start + 1..]
720            .find('}')
721            .ok_or_else(|| "unterminated interpolation".to_string())?
722            + start
723            + 1;
724        result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
725        remaining = &remaining[end + 1..];
726    }
727    result.push_str(remaining);
728    Ok(result)
729}
730
731fn lookup_template_value(
732    value: &str,
733    values: &[(&str, TemplateValue)],
734) -> Result<TemplateValue, String> {
735    let value = value.trim();
736    let (base, mut methods) = value
737        .split_once('.')
738        .map_or((value, ""), |(base, methods)| (base, methods));
739    let mut result = values
740        .iter()
741        .find(|(name, _)| *name == base)
742        .map(|(_, value)| value.clone())
743        .ok_or_else(|| format!("unsupported expression '{value}'"))?;
744    while !methods.is_empty() {
745        let (method, rest) = methods
746            .split_once('.')
747            .map_or((methods, ""), |(method, rest)| (method, rest));
748        if method == "upper()" {
749            result = TemplateValue::String(as_string(&result).to_uppercase());
750        } else if method == "lower()" {
751            result = TemplateValue::String(as_string(&result).to_lowercase());
752        } else if let Some(args) = method
753            .strip_prefix("replace(")
754            .and_then(|v| v.strip_suffix(')'))
755        {
756            let (from, to) = args
757                .split_once(',')
758                .ok_or_else(|| "replace expects two arguments".to_string())?;
759            let from = unquote_template_arg(from.trim())?;
760            let to = unquote_template_arg(to.trim())?;
761            result = TemplateValue::String(as_string(&result).replace(&from, &to));
762        } else {
763            return Err(format!("unsupported method '{method}'"));
764        }
765        methods = rest;
766    }
767    Ok(result)
768}
769
770fn as_string(value: &TemplateValue) -> String {
771    match value {
772        TemplateValue::String(value) => value.clone(),
773        TemplateValue::Bool(value) => value.to_string(),
774    }
775}
776
777fn unquote_template_arg(value: &str) -> Result<String, String> {
778    if value.len() >= 2
779        && ((value.starts_with('"') && value.ends_with('"'))
780            || (value.starts_with('\'') && value.ends_with('\'')))
781    {
782        Ok(value[1..value.len() - 1].to_string())
783    } else {
784        Err(format!("expected a quoted string argument, got '{value}'"))
785    }
786}
787
788fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
789    annotations
790        .iter()
791        .map(|annotation| HirAnnotation {
792            name: annotation.name.clone(),
793            args: annotation
794                .args
795                .iter()
796                .map(|arg| HirAnnotationArg {
797                    text: arg.text.clone(),
798                    span: Some(arg.span.into()),
799                })
800                .collect(),
801            span: Some(annotation.span.into()),
802        })
803        .collect()
804}
805
806/// Map a parsed CST settings block onto the protocol settings tree (#86).
807fn lower_settings(settings: &cst::Settings) -> HirSettings {
808    HirSettings {
809        span: Some(settings.span.into()),
810        children: settings.children.iter().map(lower_settings_node).collect(),
811    }
812}
813
814fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
815    match node {
816        cst::SettingsNode::Group {
817            name,
818            children,
819            span,
820        } => HirSettingsNode::Group {
821            name: name.clone(),
822            children: children.iter().map(lower_settings_node).collect(),
823            span: Some((*span).into()),
824        },
825        cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
826            name: name.clone(),
827            value: *value,
828            span: Some((*span).into()),
829        },
830        cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
831            name: name.clone(),
832            value: *value,
833            span: Some((*span).into()),
834        },
835        cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
836            name: name.clone(),
837            value: value.clone(),
838            span: Some((*span).into()),
839        },
840        cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
841            name: name.clone(),
842            value: value.clone(),
843            span: Some((*span).into()),
844        },
845        cst::SettingsNode::List {
846            name,
847            elements,
848            span,
849        } => HirSettingsNode::List {
850            name: name.clone(),
851            elements: elements
852                .iter()
853                .map(|element| crate::hir::types::SettingsListElement {
854                    value: element.value.clone(),
855                    evaluated: (!element.expr).then(|| element.value.clone()),
856                    span: Some(element.span.into()),
857                })
858                .collect(),
859            span: Some((*span).into()),
860        },
861    }
862}
863
864impl Lowerer {
865    fn error_at(&mut self, code: &str, message: String, span: Span) {
866        self.errors.push(OpyError::at(code, message, span));
867    }
868
869    /// An error whose message names the valid spellings nearest `rejected`
870    /// in a `(did you mean …?)` suffix. `crate::matcher` ranks the pool —
871    /// the same data the resolution just failed on — so the suggestion
872    /// cannot drift from what the source accepts (issue #469).
873    fn error_at_candidates(
874        &mut self,
875        code: &str,
876        message: String,
877        span: Span,
878        rejected: &str,
879        pool: &[crate::matcher::MatchCandidate],
880    ) {
881        let ranked = crate::matcher::rank(rejected, pool);
882        self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
883    }
884
885    /// Like [`Lowerer::error_at_candidates`] for a closed candidate space
886    /// (an enum domain): when nothing is near, the members themselves are
887    /// the candidates, spelled out sorted and bounded.
888    fn error_at_closed_candidates(
889        &mut self,
890        code: &str,
891        message: String,
892        span: Span,
893        rejected: &str,
894        pool: &[crate::matcher::MatchCandidate],
895    ) {
896        let ranked = crate::matcher::rank_closed(rejected, pool);
897        self.error_at(code, crate::matcher::did_you_mean(message, &ranked), span);
898    }
899}
900
901/// The keyword spellings a parameter accepts (its name plus alternates).
902fn keyword_spellings(param: &Param) -> Vec<String> {
903    let mut spellings = vec![param.name.clone()];
904    spellings.extend(param.alternate_names.iter().cloned());
905    spellings
906}
907
908/// The source span covering a call's argument list (the start of the first
909/// argument through the last argument).
910fn arg_span(args: &[CallArg]) -> Span {
911    args.first().map(CallArg::span).unwrap_or_else(|| {
912        Span::new(
913            0,
914            crate::diag::Position::new(1, 1),
915            crate::diag::Position::new(1, 1),
916        )
917    })
918}
919
920/// Lower a source-level player context value without adding a new HIR node
921/// kind. `attacker` and `victim` are catalog-backed zero-argument values;
922/// using the existing `Call` node keeps their source identity and span while
923/// leaving canonical value ownership with Workshop.
924fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
925    match name {
926        "eventPlayer" => Some(HirExpr::EventPlayer {
927            span: span.map(Into::into),
928        }),
929        "localPlayer" | "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
930            name: name.to_string(),
931            args: Vec::new(),
932            debug_source: None,
933            span: span.map(Into::into),
934        }),
935        "hostPlayer" => Some(HirExpr::HostPlayer {
936            span: span.map(Into::into),
937        }),
938        _ => None,
939    }
940}
941
942/// The declaration namespace an identifier would occupy: `globalvar`,
943/// `playervar`, or `subroutine`/`def`. The pinned upstream compiler reserves
944/// a different name set per namespace, so a name refused for a `globalvar`
945/// can still be a legal `playervar` name.
946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
947pub enum NameNamespace {
948    /// `globalvar` declarations — upstream `reservedNames`.
949    Global,
950    /// `playervar` declarations — upstream `reservedMemberNames`.
951    Player,
952    /// `subroutine`/`def` declarations — upstream `reservedSubroutineNames`.
953    Subroutine,
954}
955
956/// Whether the pinned upstream compiler (OverPy 9.7.10) reserves `name` for
957/// `namespace` declarations — such a declaration is never valid source.
958///
959/// Mirrors the upstream reservation rules: globals reserve the grammar
960/// keywords, the type lattice (`typeMatrix`), `AsyncBehavior`, and the
961/// bare-spelled builtins (`funcKw` entries with `args === null`: context
962/// names, literals, `ruleCondition`, …); subroutines reserve the keywords
963/// plus builtins callable with an empty argument list (`args.length === 0`,
964/// mirrored here by manifest functions that declare no parameters); player
965/// variables reserve only the member axis names `x`, `y`, `z`.
966pub fn is_reserved_name(name: &str, namespace: NameNamespace) -> bool {
967    match namespace {
968        NameNamespace::Player => matches!(name, "x" | "y" | "z"),
969        NameNamespace::Global => {
970            RESERVED_KEYWORDS.contains(&name) || RESERVED_GLOBAL_NAMES.contains(&name)
971        }
972        NameNamespace::Subroutine => {
973            RESERVED_KEYWORDS.contains(&name)
974                || Manifest::builtin().ok().is_some_and(|manifest| {
975                    manifest
976                        .resolve_function(name)
977                        .is_some_and(|function| function.params.is_empty())
978                })
979        }
980    }
981}
982
983/// The upstream `opyKeywords` set (OverPy 9.7.10): grammar keywords reserved
984/// in every declaration namespace.
985const RESERVED_KEYWORDS: &[&str] = &[
986    "and",
987    "bool",
988    "case",
989    "def",
990    "default",
991    "del",
992    "elif",
993    "else",
994    "enum",
995    "float",
996    "for",
997    "globalvar",
998    "goto",
999    "if",
1000    "in",
1001    "int",
1002    "lambda",
1003    "loc",
1004    "macro",
1005    "not",
1006    "or",
1007    "playervar",
1008    "rule",
1009    "self",
1010    "settings",
1011    "signed",
1012    "subroutine",
1013    "switch",
1014    "unsigned",
1015    "while",
1016];
1017
1018/// Names upstream reserves for `globalvar` beyond the keyword set (OverPy
1019/// 9.7.10 `reservedNames`): the type lattice (`typeMatrix` keys), and the
1020/// bare-spelled builtins (`funcKw` entries with `args === null`) — context
1021/// names, literals, and `ruleCondition`.
1022const RESERVED_GLOBAL_NAMES: &[&str] = &[
1023    "AccelReeval",
1024    "Array",
1025    "AssistId",
1026    "AssistReeval",
1027    "AsyncBehavior",
1028    "BarrierLos",
1029    "Beam",
1030    "BoolLiteral",
1031    "Button",
1032    "ButtonLiteral",
1033    "ChaseRateReeval",
1034    "ChaseTimeReeval",
1035    "Clip",
1036    "Color",
1037    "ColorLiteral",
1038    "Comms",
1039    "CustomStringLiteral",
1040    "DamageModificationId",
1041    "DamageReeval",
1042    "Dict",
1043    "DictElem",
1044    "DictKey",
1045    "Direction",
1046    "DotId",
1047    "DynamicEffect",
1048    "Effect",
1049    "EffectReeval",
1050    "EntityId",
1051    "FacingReeval",
1052    "FloatLiteral",
1053    "Gamemode",
1054    "GamemodeLiteral",
1055    "GlobalVariable",
1056    "HealingModificationId",
1057    "HealingReeval",
1058    "Health",
1059    "HealthPoolId",
1060    "Hero",
1061    "HeroLiteral",
1062    "HeroStat",
1063    "HotId",
1064    "HudPosition",
1065    "HudReeval",
1066    "Icon",
1067    "IconReeval",
1068    "Impulse",
1069    "IntLiteral",
1070    "Invis",
1071    "Label",
1072    "Lambda",
1073    "LocalizedStringLiteral",
1074    "LosCheck",
1075    "Map",
1076    "MapLiteral",
1077    "ModifyHealth",
1078    "Object",
1079    "OutlineVisibility",
1080    "Player",
1081    "PlayerVariable",
1082    "Position",
1083    "ProgressHudReeval",
1084    "ProgressWorldTextReeval",
1085    "Projectile",
1086    "ProjectileEffectReeval",
1087    "Raycast",
1088    "Relativity",
1089    "SignedFloatLiteral",
1090    "SignedIntLiteral",
1091    "SpecVisibility",
1092    "StartRuleBehavior",
1093    "Stat",
1094    "Status",
1095    "String",
1096    "StringLiteral",
1097    "Subroutine",
1098    "Team",
1099    "TeamLiteral",
1100    "TextId",
1101    "Throttle",
1102    "ThrottleReeval",
1103    "Transform",
1104    "Type",
1105    "UnsignedFloatLiteral",
1106    "UnsignedIntLiteral",
1107    "Value",
1108    "Vector",
1109    "Velocity",
1110    "Wait",
1111    "WorldTextReeval",
1112    "__ChaseReeval__",
1113    "__Operation__",
1114    "__Operator__",
1115    "__Rounding__",
1116    "__global__",
1117    "attacker",
1118    "break",
1119    "continue",
1120    "eventAbility",
1121    "eventDamage",
1122    "eventDirection",
1123    "eventHealing",
1124    "eventPlayer",
1125    "eventWasCriticalHit",
1126    "eventWasEnvironment",
1127    "eventWasHealthPack",
1128    "false",
1129    "healee",
1130    "healer",
1131    "hostPlayer",
1132    "localPlayer",
1133    "null",
1134    "pass",
1135    "return",
1136    "ruleCondition",
1137    "true",
1138    "victim",
1139    "void",
1140];
1141
1142/// Whether a CST receiver is assignable (the `.append` receiver rule): a
1143/// variable name (including macro parameters), an array literal, or an index
1144/// expression — matching the pinned reference, which rejects constant and
1145/// function receivers ("Cannot modify or assign to …").
1146fn assignable_receiver(receiver: &Expr) -> bool {
1147    match receiver {
1148        Expr::Name { name, .. } => !matches!(
1149            name.as_str(),
1150            "eventPlayer" | "hostPlayer" | "attacker" | "victim"
1151        ),
1152        Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
1153        _ => false,
1154    }
1155}
1156
1157fn expr_identifier(expr: &Expr) -> Option<&str> {
1158    match expr {
1159        Expr::Name { name, .. } => Some(name.as_str()),
1160        _ => None,
1161    }
1162}
1163
1164fn indexed_expr_depth(expr: &Expr) -> usize {
1165    match expr {
1166        Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
1167        _ => 0,
1168    }
1169}
1170
1171impl From<Span> for HirSpan {
1172    fn from(span: Span) -> HirSpan {
1173        HirSpan {
1174            file: span.file,
1175            start: Position {
1176                line: span.start.line,
1177                col: span.start.col,
1178            },
1179            end: Position {
1180                line: span.end.line,
1181                col: span.end.col,
1182            },
1183        }
1184    }
1185}
1186
1187impl From<&Span> for HirSpan {
1188    fn from(span: &Span) -> HirSpan {
1189        (*span).into()
1190    }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196    use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1197    use crate::lexer::{LexInput, lex};
1198    use crate::parser::parse;
1199
1200    fn lower_ok(text: &str) -> HirProgram {
1201        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1202        let output = parse(&tokens);
1203        assert!(
1204            output.errors.is_empty(),
1205            "unexpected parse errors: {:?}",
1206            output.errors
1207        );
1208        let program = output.program.expect("parse produces a program");
1209        lower(&program, vec![], vec![]).expect("lowers without errors")
1210    }
1211
1212    fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1213        let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1214            panic!("expected a rule");
1215        };
1216        (&rule.conditions, &rule.actions)
1217    }
1218
1219    #[test]
1220    fn producer_emits_the_v2_ordered_switch_contract() {
1221        let hir = lower_ok(
1222            "globalvar value\nrule \"r\":\n    @Event global\n    switch value:\n        default:\n            value = 1\n        case 2:\n            value = 2\n",
1223        );
1224        assert_eq!(hir.protocol.name, "wright/opy-hir");
1225        assert_eq!(hir.protocol.version, "2.0.0");
1226        let value = serde_json::to_value(&hir).expect("HIR must serialize");
1227        let switch = &value["rules"][0]["actions"][0];
1228        assert!(switch.get("arms").is_some());
1229        assert!(switch.get("cases").is_none());
1230        assert!(switch.get("default").is_none());
1231    }
1232
1233    #[test]
1234    fn receiver_calls_lower_to_receiver_call_hir() {
1235        // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the
1236        // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a
1237        // global-variable receiver (#104).
1238        let hir = lower_ok(
1239            "globalvar target\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n    target.setMoveSpeed(50)\n",
1240        );
1241        let (_, actions) = rule_conditions_and_actions(&hir);
1242        assert_eq!(actions.len(), 2);
1243
1244        let HirStmt::Expr { expr, .. } = &actions[0] else {
1245            panic!("expected expression statement");
1246        };
1247        let HirExpr::ReceiverCall {
1248            receiver,
1249            name,
1250            args,
1251            ..
1252        } = expr.as_ref()
1253        else {
1254            panic!("expected receiver call, got {expr:?}");
1255        };
1256        assert_eq!(name, "setMoveSpeed");
1257        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1258        assert_eq!(args.len(), 1);
1259        assert!(matches!(&args[0], HirExpr::Number { .. }));
1260
1261        let HirStmt::Expr { expr, .. } = &actions[1] else {
1262            panic!("expected expression statement");
1263        };
1264        let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1265            panic!("expected receiver call, got {expr:?}");
1266        };
1267        assert_eq!(name, "setMoveSpeed");
1268        assert!(
1269            matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1270            "globalvar receiver must resolve to a GlobalVar"
1271        );
1272    }
1273
1274    #[test]
1275    fn bare_variable_member_expression_preserves_receiver_and_member() {
1276        let hir = lower_ok(
1277            "globalvar A\nplayervar B\nrule \"receiver\":\n    @Event eachPlayer\n    A = B.C\n",
1278        );
1279        let HirStmt::Assign { value, .. } = &hir
1280            .rules
1281            .iter()
1282            .find_map(|entry| {
1283                let RuleEntry::Rule(rule) = entry else {
1284                    return None;
1285                };
1286                rule.actions.first()
1287            })
1288            .expect("assignment")
1289        else {
1290            panic!("expected assignment");
1291        };
1292        let HirExpr::Member {
1293            receiver, member, ..
1294        } = value.as_ref()
1295        else {
1296            panic!("expected opaque member expression, got {value:?}");
1297        };
1298        assert_eq!(member, "C");
1299        assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1300    }
1301
1302    #[test]
1303    fn context_player_components_lower_to_member_expressions() {
1304        // `x`/`y`/`z` are reserved member names in the pinned reference: on a
1305        // context-player receiver they resolve unconditionally to the vector
1306        // component path instead of a player-variable lookup (#414).
1307        for (receiver_name, event) in [
1308            ("eventPlayer", "eachPlayer"),
1309            ("hostPlayer", "eachPlayer"),
1310            ("localPlayer", "eachPlayer"),
1311            ("attacker", "playerDied"),
1312            ("victim", "playerDied"),
1313        ] {
1314            for axis in ["x", "y", "z"] {
1315                let hir = lower_ok(&format!(
1316                    "globalvar g\nrule \"r\":\n    @Event {event}\n    g = {receiver_name}.{axis}\n"
1317                ));
1318                let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1319                    panic!("expected a rule");
1320                };
1321                let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1322                    panic!("expected an assign statement");
1323                };
1324                let HirExpr::Member {
1325                    receiver, member, ..
1326                } = value.as_ref()
1327                else {
1328                    panic!("{receiver_name}.{axis}: expected member expression, got {value:?}");
1329                };
1330                assert_eq!(member, axis, "{receiver_name}.{axis}");
1331                match receiver_name {
1332                    "eventPlayer" => {
1333                        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }))
1334                    }
1335                    "hostPlayer" => {
1336                        assert!(matches!(receiver.as_ref(), HirExpr::HostPlayer { .. }))
1337                    }
1338                    _ => assert!(
1339                        matches!(receiver.as_ref(), HirExpr::Call { name, .. } if name == receiver_name),
1340                        "{receiver_name}.{axis}: receiver must keep its source identity"
1341                    ),
1342                }
1343            }
1344        }
1345    }
1346
1347    #[test]
1348    fn context_player_non_component_members_stay_player_variables() {
1349        // A declared player variable still resolves as a player-variable
1350        // reference, an uppercase default var is not a component access, and
1351        // an undeclared member keeps the unknown-member diagnostic.
1352        let value = lowered_value(
1353            "globalvar g\nplayervar pos\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.pos\n",
1354        );
1355        assert!(
1356            matches!(&value, HirExpr::PlayerVar { name, .. } if name == "pos"),
1357            "declared playervar member must stay a player-variable reference, got {value:?}"
1358        );
1359
1360        let value = lowered_value(
1361            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.X\n",
1362        );
1363        assert!(
1364            matches!(&value, HirExpr::PlayerVar { name, .. } if name == "X"),
1365            "default-var member must stay a player-variable reference, got {value:?}"
1366        );
1367
1368        let error = compile_error(&action_source("g = eventPlayer.pos"), 4);
1369        assert_eq!(error.code, "unknown-member");
1370    }
1371
1372    #[test]
1373    fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1374        let text = "rule \"before\":\n    pass\ndef source_name():\n    @Name \"Friendly\"\n    pass\nrule \"after\":\n    pass\n";
1375        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1376        let output = parse(&tokens);
1377        assert!(
1378            output.errors.is_empty(),
1379            "unexpected parse errors: {:?}",
1380            output.errors
1381        );
1382        let program = output.program.expect("program");
1383        let preprocessing = PreprocessingState {
1384            rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1385                value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1386                span: None,
1387            }),
1388            ..PreprocessingState::default()
1389        };
1390        let hir = lower_with_preprocessing(
1391            &program,
1392            vec![SourceFile {
1393                id: 0,
1394                path: "main.opy".to_string(),
1395            }],
1396            vec![],
1397            &preprocessing,
1398        )
1399        .expect("lowers");
1400        let names: Vec<_> = hir
1401            .rules
1402            .iter()
1403            .map(|entry| match entry {
1404                HirRuleEntry::Rule(rule) => rule.name.clone(),
1405                HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1406            })
1407            .collect();
1408        assert_eq!(
1409            names,
1410            vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1411        );
1412        let HirRuleEntry::SubroutineDef {
1413            name, source_name, ..
1414        } = &hir.rules[1]
1415        else {
1416            panic!("expected subroutine definition");
1417        };
1418        assert_eq!(name, "[Main] Friendly");
1419        assert_eq!(source_name, "source_name");
1420    }
1421
1422    #[test]
1423    fn receiver_call_values_lower_in_conditions() {
1424        // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value;
1425        // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver
1426        // call inside another receiver call's arguments (#104).
1427        let hir = lower_ok(
1428            "rule \"r\":\n    @Event eachPlayer\n    @Condition eventPlayer.isAlive()\n    eventPlayer.teleport(eventPlayer.getPosition())\n",
1429        );
1430        let (conditions, actions) = rule_conditions_and_actions(&hir);
1431        assert_eq!(conditions.len(), 1);
1432        let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1433            panic!("expected receiver call condition, got {:?}", conditions[0]);
1434        };
1435        assert_eq!(name, "isAlive");
1436        assert_eq!(args.len(), 0);
1437
1438        let HirStmt::Expr { expr, .. } = &actions[0] else {
1439            panic!("expected expression statement");
1440        };
1441        let HirExpr::ReceiverCall {
1442            name,
1443            args,
1444            receiver,
1445            ..
1446        } = expr.as_ref()
1447        else {
1448            panic!("expected receiver call, got {expr:?}");
1449        };
1450        assert_eq!(name, "teleport");
1451        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1452        assert_eq!(args.len(), 1);
1453        assert!(matches!(
1454            &args[0],
1455            HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1456        ));
1457    }
1458
1459    #[test]
1460    fn format_string_receiver_stays_a_format_node() {
1461        // `.format()` on a string receiver is unaffected by the receiver-call
1462        // path (existing supported form).
1463        let hir = lower_ok(
1464            "rule \"r\":\n    @Event global\n    print(\"{} points\".format(len([1, 2])))\n",
1465        );
1466        let (_, actions) = rule_conditions_and_actions(&hir);
1467        let HirStmt::Expr { expr, .. } = &actions[0] else {
1468            panic!("expected expression statement");
1469        };
1470        assert!(
1471            has_format(expr),
1472            "string `.format()` must lower to a Format node"
1473        );
1474    }
1475
1476    fn has_format(expr: &HirExpr) -> bool {
1477        match expr {
1478            HirExpr::Format { .. } => true,
1479            HirExpr::Call { args, .. } => args.iter().any(has_format),
1480            HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1481            _ => false,
1482        }
1483    }
1484
1485    /// Lower one rule action and return the assignment's value expression.
1486    fn lowered_value(source: &str) -> HirExpr {
1487        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1488            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1489        let RuleEntry::Rule(rule) = &program.rules[0] else {
1490            panic!("expected a rule");
1491        };
1492        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1493            panic!("expected an assign statement");
1494        };
1495        (**value).clone()
1496    }
1497
1498    #[test]
1499    fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1500        let value = lowered_value(
1501            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NONE\n",
1502        );
1503        assert_enum(&value, "ChaseTimeReeval", "NONE");
1504    }
1505
1506    #[test]
1507    fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1508        let value = lowered_value(
1509            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1510        );
1511        assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1512    }
1513
1514    #[test]
1515    fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1516        for member in ["NONE", "DESTINATION_AND_RATE"] {
1517            let source = format!(
1518                "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseRateReeval.{member}\n"
1519            );
1520            assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1521        }
1522    }
1523
1524    /// Assert the expression is the catalog enum `(domain, member)` node,
1525    /// ignoring its source span (the span is frontend-internal provenance).
1526    fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1527        match value {
1528            HirExpr::Enum {
1529                value_type, value, ..
1530            } => {
1531                assert_eq!(value_type, domain);
1532                assert_eq!(value, member);
1533            }
1534            other => panic!("expected enum {domain}.{member}, got {other:?}"),
1535        }
1536    }
1537
1538    #[test]
1539    fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1540        let error = crate::compile(
1541            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NOPE\n",
1542            "test.opy",
1543            std::path::Path::new(""),
1544        )
1545        .expect_err("unknown catalog member must be rejected");
1546        assert_eq!(error.code, "unknown-enum-member");
1547    }
1548
1549    #[test]
1550    fn unknown_enum_receiver_is_an_unsupported_member_error() {
1551        let error = crate::compile(
1552            "globalvar g\nrule \"r\":\n    @Event global\n    g = NotARealEnum.MEMBER\n",
1553            "test.opy",
1554            std::path::Path::new(""),
1555        )
1556        .expect_err("an unknown enum type must fail");
1557        assert_eq!(error.code, "unsupported-member");
1558        let span = error.span.expect("the error is source-located");
1559        assert_eq!(span.start.line, 4);
1560    }
1561
1562    #[test]
1563    fn unknown_texture_member_is_rejected_by_the_source_surface() {
1564        let error = crate::compile(
1565            "globalvar g\nrule \"r\":\n    @Event global\n    g = Texture.NOT_A_TEXTURE\n",
1566            "test.opy",
1567            std::path::Path::new(""),
1568        )
1569        .expect_err("unknown texture member must be rejected");
1570        assert_eq!(error.code, "unknown-texture-member");
1571    }
1572
1573    // --- Builtin semantic manifest coverage (#109) ---
1574
1575    /// Assert a compile failure has the given code at the given line.
1576    fn compile_error(source: &str, line: u32) -> OpyError {
1577        let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1578            .expect_err("expected a compile failure");
1579        let span = error.span.expect("the error is source-located");
1580        assert_eq!(span.start.line, line, "code '{}'", error.code);
1581        error
1582    }
1583
1584    fn action_source(statement: &str) -> String {
1585        format!("globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n")
1586    }
1587
1588    // --- Shared did-you-mean candidates on rejected names (#469) ---
1589
1590    #[test]
1591    fn unknown_action_names_the_nearest_spellings_in_message() {
1592        let error = compile_error(&action_source("createHudText(g, \"t\")"), 4);
1593        assert_eq!(error.code, "unknown-action");
1594        // "Create HUD Text" is the catalog display name of `hudText`.
1595        assert!(
1596            error.message.contains("(did you mean 'hudText'"),
1597            "message: {}",
1598            error.message
1599        );
1600    }
1601
1602    #[test]
1603    fn unknown_action_finds_member_action_names() {
1604        let error = compile_error(&action_source("startForcingPlayerToBeHero(g)"), 4);
1605        assert_eq!(error.code, "unknown-action");
1606        assert!(
1607            error.message.contains("(did you mean 'startForcingHero'"),
1608            "message: {}",
1609            error.message
1610        );
1611    }
1612
1613    #[test]
1614    fn unknown_member_names_the_nearest_member_spellings() {
1615        let error = compile_error(&action_source("eventPlayer.teleprt(g)"), 4);
1616        assert_eq!(error.code, "unknown-member");
1617        assert_eq!(
1618            error.message,
1619            "unknown member 'teleprt' (did you mean 'teleport'?)"
1620        );
1621    }
1622
1623    #[test]
1624    fn unknown_value_includes_the_by_name_special_calls() {
1625        let error = compile_error(&action_source("g = sroted([1])"), 4);
1626        assert_eq!(error.code, "unknown-value");
1627        assert!(
1628            error.message.contains("'sorted'"),
1629            "message: {}",
1630            error.message
1631        );
1632    }
1633
1634    #[test]
1635    fn unknown_enum_member_names_the_domain_surface() {
1636        // `Hero.SOLDIER` is an accepted source spelling naming `SOLDIER_76`;
1637        // both must be candidates. (Strict upstream spellings land with
1638        // #466; the pool then keeps yielding `SOLDIER`.)
1639        let error = compile_error(&action_source("g = Hero.SOLDIER76"), 4);
1640        assert_eq!(error.code, "unknown-enum-member");
1641        assert!(
1642            error.message.contains("'SOLDIER'"),
1643            "message: {}",
1644            error.message
1645        );
1646    }
1647
1648    #[test]
1649    fn hud_reeval_string_and_color_alias_resolves_and_ranks() {
1650        // `VISIBILITY_STRING_AND_COLOR` is the OverPy spelling of the
1651        // catalog member `VISIBLE_TO_STRING_AND_COLOR`; it lives in
1652        // `crate::enums` so the candidate pool names it too.
1653        let hir = crate::compile(
1654            &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLOR"),
1655            "test.opy",
1656            std::path::Path::new(""),
1657        )
1658        .expect("the accepted alternate resolves");
1659        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1660            panic!("expected a rule");
1661        };
1662        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1663            panic!("expected an assignment");
1664        };
1665        assert!(matches!(
1666            value.as_ref(),
1667            HirExpr::Enum { value_type, value, .. }
1668                if value_type == "HudReeval" && value == "VISIBLE_TO_STRING_AND_COLOR"
1669        ));
1670
1671        let error = compile_error(
1672            &action_source("g = HudReeval.VISIBILITY_STRING_AND_COLO"),
1673            4,
1674        );
1675        assert_eq!(error.code, "unknown-enum-member");
1676        assert!(
1677            error.message.starts_with(
1678                "enum 'HudReeval' has no member 'VISIBILITY_STRING_AND_COLO' \
1679                              (did you mean 'VISIBILITY_STRING_AND_COLOR'"
1680            ),
1681            "message: {}",
1682            error.message
1683        );
1684    }
1685
1686    #[test]
1687    fn unknown_custom_enum_member_names_the_declared_members() {
1688        let error = compile_error(
1689            "globalvar g\nenum Dir:\n    NORTH\n    SOUTH\nrule \"r\":\n    @Event global\n    g = Dir.NROTH\n",
1690            7,
1691        );
1692        assert_eq!(error.code, "unknown-enum-member");
1693        assert_eq!(
1694            error.message,
1695            "enum 'Dir' has no member 'NROTH' (did you mean 'NORTH'?)"
1696        );
1697    }
1698
1699    #[test]
1700    fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1701        // 4-argument form with an explicit reevaluation member (#106).
1702        let hir = crate::compile(
1703            &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1704            "test.opy",
1705            std::path::Path::new(""),
1706        )
1707        .expect("reference-supported chaseOverTime compiles");
1708        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1709            panic!("expected a rule");
1710        };
1711        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1712            panic!("expected expression statement");
1713        };
1714        let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1715            panic!("expected a call, got {expr:?}");
1716        };
1717        assert_eq!(name, "chaseOverTime");
1718        assert_eq!(args.len(), 4);
1719        assert!(matches!(
1720            &args[3],
1721            HirExpr::Enum { value_type, value, .. }
1722                if value_type == "ChaseTimeReeval" && value == "NONE"
1723        ));
1724
1725        // 3-argument form fills the reference default member.
1726        let hir = crate::compile(
1727            &action_source("chaseOverTime(g, 10, 3)"),
1728            "test.opy",
1729            std::path::Path::new(""),
1730        )
1731        .expect("default-reevaluation chaseOverTime compiles");
1732        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1733            panic!("expected a rule");
1734        };
1735        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1736            panic!("expected expression statement");
1737        };
1738        let HirExpr::Call { args, .. } = expr.as_ref() else {
1739            panic!("expected a call");
1740        };
1741        assert_eq!(args.len(), 4);
1742        assert!(matches!(
1743            &args[3],
1744            HirExpr::Enum { value_type, value, .. }
1745                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1746        ));
1747    }
1748
1749    #[test]
1750    fn is_game_in_progress_resolves_as_a_builtin_value() {
1751        // Generic value gap from #106: `isGameInProgress()` in a condition.
1752        let hir = crate::compile(
1753            &action_source("@Condition isGameInProgress() == true"),
1754            "test.opy",
1755            std::path::Path::new(""),
1756        )
1757        .expect("reference-supported isGameInProgress compiles");
1758        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1759            panic!("expected a rule");
1760        };
1761        assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1762    }
1763
1764    #[test]
1765    fn enum_gated_members_resolve_through_the_manifest() {
1766        // Enum-gated members from #106: setInvisibility (Invis), getThrottle
1767        // (member value), worldVector (Transform arg), setStatusEffect
1768        // (Status arg).
1769        let source = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1770            @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n    \
1771            @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n    \
1772            eventPlayer.setInvisibility(Invis.ALL)\n    \
1773            eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1774        let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1775            .expect("enum-gated members compile");
1776        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1777            panic!("expected a rule");
1778        };
1779        assert_eq!(rule.actions.len(), 2);
1780    }
1781
1782    #[test]
1783    fn get_players_in_radius_fills_reference_enum_defaults() {
1784        // 2-argument form fills Team.ALL and LosCheck.OFF (reference
1785        // emission: `Players Within Radius(..., All Teams, Off)`).
1786        let hir = crate::compile(
1787            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1788             @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n    \
1789             disableInspector()\n",
1790            "test.opy",
1791            std::path::Path::new(""),
1792        )
1793        .expect("getPlayersInRadius with defaults compiles");
1794        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1795            panic!("expected a rule");
1796        };
1797        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1798            panic!("expected a comparison");
1799        };
1800        let HirExpr::Call { name, args, .. } = left.as_ref() else {
1801            panic!("expected len call");
1802        };
1803        assert_eq!(name, "len");
1804        let HirExpr::Call { name, args, .. } = &args[0] else {
1805            panic!("expected getPlayersInRadius call");
1806        };
1807        assert_eq!(name, "getPlayersInRadius");
1808        assert_eq!(args.len(), 4);
1809        assert!(matches!(
1810            &args[2],
1811            HirExpr::Enum { value_type, value, .. }
1812                if value_type == "Team" && value == "ALL"
1813        ));
1814        assert!(matches!(
1815            &args[3],
1816            HirExpr::Enum { value_type, value, .. }
1817                if value_type == "LosCheck" && value == "OFF"
1818        ));
1819    }
1820
1821    #[test]
1822    fn value_call_in_action_position_is_rejected() {
1823        let error = compile_error(&action_source("isGameInProgress()"), 4);
1824        assert_eq!(error.code, "value-in-action-position");
1825    }
1826
1827    #[test]
1828    fn value_member_in_action_position_is_rejected() {
1829        // The #106 baseline records the oracle rejecting `B.isAlive()` as a
1830        // statement; the manifest enforces that contract (#109).
1831        let error = compile_error(
1832            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isAlive()\n",
1833            4,
1834        );
1835        assert_eq!(error.code, "value-in-action-position");
1836    }
1837
1838    // --- Named/keyword argument binding and chase call context (#110) ---
1839
1840    /// Compile a program and return the lowered first action's expression
1841    /// (the statement expression, or the value of a leading assignment).
1842    fn first_action_expr(source: &str) -> HirExpr {
1843        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1844            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1845        let RuleEntry::Rule(rule) = &program.rules[0] else {
1846            panic!("expected a rule");
1847        };
1848        match &rule.actions[0] {
1849            HirStmt::Expr { expr, .. } => (**expr).clone(),
1850            HirStmt::Assign { value, .. } => (**value).clone(),
1851            other => panic!("expected an expression or assignment, got {other:?}"),
1852        }
1853    }
1854
1855    /// Remove every `span`/`name_span` key from a serialized expression (the
1856    /// differential suite's normalization).
1857    fn strip_spans(value: &mut serde_json::Value) {
1858        match value {
1859            serde_json::Value::Object(map) => {
1860                map.remove("span");
1861                map.remove("name_span");
1862                for nested in map.values_mut() {
1863                    strip_spans(nested);
1864                }
1865            }
1866            serde_json::Value::Array(items) => {
1867                for item in items {
1868                    strip_spans(item);
1869                }
1870            }
1871            _ => {}
1872        }
1873    }
1874
1875    #[test]
1876    fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1877        // The reference `chase` form: `rate = …` dispatches to chaseAtRate
1878        // with the ChaseRateReeval domain, `duration = …` to chaseOverTime
1879        // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves
1880        // only through this call context (issue #110).
1881        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1882        let HirExpr::Call { name, args, .. } = &expr else {
1883            panic!("expected a call, got {expr:?}");
1884        };
1885        assert_eq!(name, "chaseAtRate");
1886        assert!(matches!(
1887            &args[3],
1888            HirExpr::Enum { value_type, value, .. }
1889                if value_type == "ChaseRateReeval" && value == "NONE"
1890        ));
1891
1892        let expr = first_action_expr(&action_source(
1893            "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1894        ));
1895        let HirExpr::Call { name, args, .. } = &expr else {
1896            panic!("expected a call, got {expr:?}");
1897        };
1898        assert_eq!(name, "chaseOverTime");
1899        assert!(matches!(
1900            &args[3],
1901            HirExpr::Enum { value_type, value, .. }
1902                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1903        ));
1904
1905        // Player-variable first arguments resolve too (the emission layer
1906        // picks the player form).
1907        let expr = first_action_expr(
1908            "playervar P\nrule \"r\":\n    @Event eachPlayer\n    \
1909             chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1910        );
1911        let HirExpr::Call { name, args, .. } = &expr else {
1912            panic!("expected a call, got {expr:?}");
1913        };
1914        assert_eq!(name, "chaseAtRate");
1915        assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1916    }
1917
1918    #[test]
1919    fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1920        // `ChaseReeval` is a contextual domain, not a standalone domain
1921        // identity: a bare member access outside the chase signature is
1922        // rejected.
1923        let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1924        assert_eq!(error.code, "unsupported-member");
1925
1926        // Inside the chase context the member is an opaque identity
1927        // dispatched to the concrete domain selected by the keyword
1928        // selector; member existence in that domain is not validated here
1929        // (lowering-dependent, #8).
1930        let expr = first_action_expr(&action_source(
1931            "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1932        ));
1933        let HirExpr::Call { name, args, .. } = &expr else {
1934            panic!("expected a call, got {expr:?}");
1935        };
1936        assert_eq!(name, "chaseAtRate");
1937        assert!(matches!(
1938            &args[3],
1939            HirExpr::Enum { value_type, value, .. }
1940                if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1941        ));
1942
1943        // A non-enum 4th argument is carried structurally; the reference's
1944        // "expected an enum member" rejection is lowering-dependent.
1945        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1946        let HirExpr::Call { name, args, .. } = &expr else {
1947            panic!("expected a call, got {expr:?}");
1948        };
1949        assert_eq!(name, "chase");
1950        assert!(matches!(&args[3], HirExpr::Number { .. }));
1951    }
1952
1953    #[test]
1954    fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1955        let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1956        assert_eq!(error.code, "keyword-required");
1957        assert!(error.message.contains("rate"));
1958    }
1959
1960    #[test]
1961    fn chase_family_requires_a_variable_first_argument() {
1962        // The reference rejects non-variable first arguments for the chase
1963        // family ("Expected variable for 1st argument of function
1964        // 'chaseOverTime'", issue #110) — the variable kind also selects
1965        // the global/player emission form.
1966        let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1967        assert_eq!(error.code, "invalid-argument");
1968
1969        let error = compile_error(
1970            &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1971            4,
1972        );
1973        assert_eq!(error.code, "invalid-argument");
1974
1975        let error = compile_error(
1976            &action_source("chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)"),
1977            4,
1978        );
1979        assert_eq!(error.code, "invalid-argument");
1980
1981        // A variable first argument binds (positive case).
1982        crate::compile(
1983            &action_source("chaseAtRate(g, 0, 2, ChaseRateReeval.NONE)"),
1984            "test.opy",
1985            std::path::Path::new(""),
1986        )
1987        .expect("a variable first argument compiles");
1988    }
1989
1990    #[test]
1991    fn keyword_binding_matches_positional_binding_in_hir() {
1992        // Keyword binding consumes the manifest signatures: the bound HIR is
1993        // identical to the positional form's (defaults filled the same way),
1994        // modulo source spans (the keyword values sit at different columns).
1995        fn without_spans(expr: &HirExpr) -> serde_json::Value {
1996            let mut value = serde_json::to_value(expr).unwrap();
1997            strip_spans(&mut value);
1998            value
1999        }
2000        let keyword = without_spans(&first_action_expr(&action_source(
2001            "chaseOverTime(g, 10, duration=3)",
2002        )));
2003        let positional = without_spans(&first_action_expr(&action_source(
2004            "chaseOverTime(g, 10, 3)",
2005        )));
2006        assert_eq!(keyword, positional);
2007
2008        let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
2009        let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
2010        assert_eq!(keyword, positional);
2011
2012        // Out-of-order keywords bind by name.
2013        let keyword = without_spans(&first_action_expr(&action_source(
2014            "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
2015        )));
2016        let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
2017        assert_eq!(keyword, positional);
2018
2019        let keyword = without_spans(&first_action_expr(&action_source(
2020            "g = vect(x=1, y=2, z=3)",
2021        )));
2022        let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
2023        assert_eq!(keyword, positional);
2024    }
2025
2026    #[test]
2027    fn keyword_binding_diagnostics_are_structured_and_source_located() {
2028        // Unknown keyword name.
2029        let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
2030        assert_eq!(error.code, "unknown-keyword");
2031        assert!(error.message.contains("bogus"));
2032
2033        // Duplicate (positional slot filled again by keyword).
2034        let error = compile_error(
2035            &action_source(
2036                "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
2037                 reevaluation=ChaseTimeReeval.NONE)",
2038            ),
2039            4,
2040        );
2041        assert_eq!(error.code, "duplicate-argument");
2042
2043        // Positional after keyword.
2044        let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
2045        assert_eq!(error.code, "positional-after-keyword");
2046
2047        // Missing required argument (reference: "Missing argument 'duration'").
2048        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2049        assert_eq!(error.code, "missing-argument");
2050
2051        // Positional-only parameter bound by keyword (`chase`'s leading
2052        // arguments; the reference rejects the keyword form).
2053        let error = compile_error(
2054            &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
2055            4,
2056        );
2057        assert_eq!(error.code, "unknown-keyword");
2058    }
2059
2060    #[test]
2061    fn keyword_arguments_are_rejected_for_reference_special_cases() {
2062        // The reference routes `range`, `random.*`, and `.format` around its
2063        // generic keyword binder; keyword arguments fail deterministically.
2064        let error = compile_error(
2065            "globalvar g\nrule \"r\":\n    @Event global\n    \
2066             for I in range(start=0, stop=3):\n        debug(I)\n",
2067            4,
2068        );
2069        assert_eq!(error.code, "keyword-unsupported");
2070
2071        let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
2072        assert_eq!(error.code, "keyword-unsupported");
2073
2074        let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
2075        assert_eq!(error.code, "keyword-unsupported");
2076    }
2077
2078    #[test]
2079    fn sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
2080        // #437: the reference's special `sorted` parse path only strips a
2081        // `key=` prefix inside the second argument; other keyword spellings
2082        // and a misplaced `key=` fail upstream parsing.
2083        let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
2084        assert_eq!(error.code, "unknown-keyword");
2085
2086        let error = compile_error(
2087            &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
2088            4,
2089        );
2090        assert_eq!(error.code, "unknown-keyword");
2091
2092        let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
2093        assert_eq!(error.code, "unknown-keyword");
2094    }
2095
2096    #[test]
2097    fn call_args_reject_parenthesized_keyword_names() {
2098        // #443: the reference detects keyword arguments at the token level,
2099        // so `(name)=expr` never binds as a keyword.
2100        let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
2101        assert_eq!(error.code, "parse-error");
2102
2103        let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
2104        assert_eq!(error.code, "parse-error");
2105
2106        // Bare `name=expr` keyword binding is unaffected, and a parenthesized
2107        // name without `=` still binds positionally.
2108        crate::compile(
2109            &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
2110            "test.opy",
2111            std::path::Path::new(""),
2112        )
2113        .expect("bare keyword arguments compile");
2114    }
2115
2116    #[test]
2117    fn call_args_reject_trailing_comma() {
2118        // #444: the reference keeps the empty segment after a trailing comma
2119        // and rejects it in call argument lists.
2120        for statement in [
2121            "wait(1,)",
2122            "g = vect(1, 2, 3,)",
2123            "g = sorted([1, 2],)",
2124            "g = sorted([1, 2], key=lambda v: -v,)",
2125        ] {
2126            let error = compile_error(&action_source(statement), 4);
2127            assert_eq!(error.code, "parse-error", "statement: {statement}");
2128        }
2129
2130        // Array and dictionary literals keep accepting a trailing comma on
2131        // both sides, as do parenthesized @Event argument lists.
2132        crate::compile(
2133            &action_source("g = [1, 2,]"),
2134            "test.opy",
2135            std::path::Path::new(""),
2136        )
2137        .expect("array literal trailing comma compiles");
2138        crate::compile(
2139            &action_source("g = {\"a\": 1,}[\"a\"]"),
2140            "test.opy",
2141            std::path::Path::new(""),
2142        )
2143        .expect("dict literal trailing comma compiles");
2144        crate::compile(
2145            "rule \"r\":\n    @Event playerDied(victim,)\n    wait(1)\n",
2146            "test.opy",
2147            std::path::Path::new(""),
2148        )
2149        .expect("@Event argument trailing comma compiles");
2150    }
2151
2152    #[test]
2153    fn binder_lambda_rejects_parenthesized() {
2154        // #445: the reference detects binder lambdas at the token level, so
2155        // `(lambda x: e)` never satisfies a lambda-argument position.
2156        for statement in [
2157            "g = sorted([1, 2], (lambda v: -v))",
2158            "g = sorted([1, 2], key=(lambda v: -v))",
2159            "g = [1, 2].map((lambda v: v))",
2160            "g = [1, 2].filter((lambda v: v))",
2161            "g = [1, 2].all((lambda v: v))",
2162            "g = [1, 2].any((lambda v: v))",
2163            "g = sorted([1, 2], ((lambda v: -v)))",
2164        ] {
2165            let error = compile_error(&action_source(statement), 4);
2166            assert_eq!(error.code, "lambda-context", "statement: {statement}");
2167        }
2168
2169        // Bare lambdas still bind.
2170        crate::compile(
2171            &action_source("g = sorted([1, 2], key=lambda v: -v)"),
2172            "test.opy",
2173            std::path::Path::new(""),
2174        )
2175        .expect("bare lambda key compiles");
2176    }
2177
2178    #[test]
2179    fn wait_uses_the_reference_keyword_names() {
2180        // The manifest's `wait` parameter names match the pinned reference
2181        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
2182        // keyword exactly like the oracle.
2183        let error = compile_error(&action_source("wait(duration=1)"), 4);
2184        assert_eq!(error.code, "unknown-keyword");
2185        assert!(error.message.contains("duration"));
2186    }
2187
2188    #[test]
2189    fn action_call_in_value_position_is_rejected() {
2190        let error = compile_error(&action_source("g = wait(1)"), 4);
2191        assert_eq!(error.code, "action-in-value-position");
2192    }
2193
2194    #[test]
2195    fn missing_required_argument_is_a_source_located_diagnostic() {
2196        // Too-few calls reject with the reference's missing-argument
2197        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
2198        // issue #110); positional overflow keeps `invalid-arity`.
2199        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2200        assert_eq!(error.code, "missing-argument");
2201        assert!(error.message.contains("duration"));
2202
2203        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2204        assert_eq!(error.code, "invalid-arity");
2205    }
2206
2207    #[test]
2208    fn missing_member_argument_is_a_source_located_diagnostic() {
2209        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
2210        // 30)` must reject like the oracle (the `status` argument is
2211        // missing; the reference: "Missing argument 'status' for function
2212        // '.setStatusEffect'", issue #110).
2213        let error = compile_error(
2214            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2215             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2216            4,
2217        );
2218        assert_eq!(error.code, "missing-argument");
2219        assert!(error.message.contains("duration"));
2220    }
2221
2222    #[test]
2223    fn invalid_receiver_categories_are_rejected() {
2224        // `.append`/`.remove` require an assignable receiver; `.format` a
2225        // string literal — the reference-enforced requirements, now typed
2226        // member policy (`lower::policy`, issue #458).
2227        let error = compile_error(&action_source("3.append(1)"), 4);
2228        assert_eq!(error.code, "invalid-receiver");
2229        assert!(error.message.contains("append"));
2230
2231        let error = compile_error(&action_source("attacker.append(1)"), 4);
2232        assert_eq!(error.code, "invalid-receiver");
2233        assert!(error.message.contains("append"));
2234
2235        let error = compile_error(&action_source("3.remove(1)"), 4);
2236        assert_eq!(error.code, "invalid-receiver");
2237        assert!(error.message.contains("remove"));
2238
2239        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2240        assert_eq!(error.code, "invalid-receiver");
2241        assert!(error.message.contains("format"));
2242
2243        // Positive cases: assignable receivers and string literals compile.
2244        crate::compile(
2245            &action_source("g.append(1)\n    g.remove(1)"),
2246            "test.opy",
2247            std::path::Path::new(""),
2248        )
2249        .expect("assignable receivers compile");
2250        crate::compile(
2251            &action_source("g = \"{}\".format(1)"),
2252            "test.opy",
2253            std::path::Path::new(""),
2254        )
2255        .expect("a string-literal receiver compiles");
2256    }
2257
2258    /// Lower `source` against an explicitly supplied manifest. The builtin
2259    /// manifest is immutable, so tests that mutate descriptive metadata use
2260    /// this entry instead of [`crate::compile`].
2261    fn lower_with_manifest(source: &str, manifest: &'static Manifest) -> OpyResult<HirProgram> {
2262        let tokens = lex(LexInput {
2263            file_id: 0,
2264            text: source,
2265        })
2266        .expect("lexes");
2267        let output = parse(&tokens);
2268        assert!(
2269            output.errors.is_empty(),
2270            "unexpected parse errors: {:?}",
2271            output.errors
2272        );
2273        let program = output.program.expect("parse produces a program");
2274        lower_program(
2275            &program,
2276            vec![],
2277            vec![],
2278            &PreprocessingState::default(),
2279            manifest,
2280            Catalog::builtin().expect("the builtin catalog loads"),
2281        )
2282    }
2283
2284    #[test]
2285    fn special_enforcement_is_typed_policy_not_manifest_metadata() {
2286        // #458: `param.variable` and `Function::receiver` are descriptive
2287        // signature metadata. Mutating them alone must not change
2288        // acceptance/rejection — enforcement is typed `lower::policy`.
2289        let mut file: serde_json::Value =
2290            serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
2291        for function in file["functions"].as_array_mut().unwrap() {
2292            match function["id"].as_str().unwrap() {
2293                // Strip the chase family's variable-argument markers.
2294                "chase" | "chaseAtRate" | "chaseOverTime" => {
2295                    for param in function["params"].as_array_mut().unwrap() {
2296                        param.as_object_mut().unwrap().remove("variable");
2297                    }
2298                }
2299                // Reclassify the enforced receivers to an unenforced
2300                // category.
2301                "append" | "remove" | "format" => {
2302                    function["receiver"] = "Any".into();
2303                }
2304                // Mark unrelated signatures with the metadata: if the flag
2305                // still selected behavior, these calls would start
2306                // rejecting.
2307                "wait" => function["params"][0]["variable"] = true.into(),
2308                "getPosition" => function["receiver"] = "Variable".into(),
2309                _ => {}
2310            }
2311        }
2312        let manifest: &'static Manifest = Box::leak(Box::new(
2313            Manifest::load(
2314                &serde_json::to_string(&file).unwrap(),
2315                crate::manifest::PROBES_DATA,
2316            )
2317            .expect("mutated metadata still validates"),
2318        ));
2319
2320        // The chase family still rejects a non-variable first argument…
2321        for call in [
2322            "chase(10, 10, rate=2, ChaseReeval.NONE)",
2323            "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)",
2324            "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)",
2325        ] {
2326            let error = lower_with_manifest(&action_source(call), manifest)
2327                .expect_err("the chase family still requires a variable");
2328            assert_eq!(error.code, "invalid-argument", "{call}");
2329        }
2330        // …and still accepts a variable.
2331        lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest)
2332            .expect("a variable first argument compiles");
2333
2334        // `.append`/`.remove` still reject a non-assignable receiver and
2335        // `.format` a non-string receiver.
2336        for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] {
2337            let error = lower_with_manifest(&action_source(call), manifest)
2338                .expect_err("the enforced receiver requirement still rejects");
2339            assert_eq!(error.code, "invalid-receiver", "{call}");
2340        }
2341        lower_with_manifest(&action_source("g.append(1)"), manifest)
2342            .expect("an assignable receiver compiles");
2343        lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest)
2344            .expect("a string-literal receiver compiles");
2345
2346        // Conversely, the mutated markers create no new enforcement:
2347        // `wait`'s first argument and `getPosition`'s receiver are accepted.
2348        lower_with_manifest(&action_source("wait(1)"), manifest)
2349            .expect("`wait`'s first argument is not variable-required");
2350        lower_with_manifest(
2351            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2352             g = eventPlayer.getPosition()\n",
2353            manifest,
2354        )
2355        .expect("`getPosition`'s receiver is not assignable-enforced");
2356    }
2357
2358    #[test]
2359    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2360        // Domain mismatches need canonical Workshop enum knowledge: a member
2361        // of the wrong domain is carried as an opaque identity and the check
2362        // is lowering-dependent (#8).
2363        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2364        let HirExpr::Call { args, .. } = &expr else {
2365            panic!("expected a call, got {expr:?}");
2366        };
2367        assert!(matches!(
2368            &args[3],
2369            HirExpr::Enum { value_type, value, .. }
2370                if value_type == "Invis" && value == "ALL"
2371        ));
2372
2373        let expr = first_action_expr(&action_source(
2374            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2375        ));
2376        let HirExpr::ReceiverCall { args, .. } = &expr else {
2377            panic!("expected a receiver call, got {expr:?}");
2378        };
2379        assert!(matches!(
2380            &args[0],
2381            HirExpr::Enum { value_type, value, .. }
2382                if value_type == "ChaseTimeReeval" && value == "NONE"
2383        ));
2384    }
2385
2386    #[test]
2387    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2388        // Whether a non-enum value is acceptable for an enum parameter is
2389        // Workshop catalog knowledge; the frontend carries the argument
2390        // structurally and leaves the check to lowering (#8).
2391        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2392        let HirExpr::ReceiverCall { args, .. } = &expr else {
2393            panic!("expected a receiver call, got {expr:?}");
2394        };
2395        assert!(matches!(
2396            &args[0],
2397            HirExpr::GlobalVar { name, .. } if name == "g"
2398        ));
2399
2400        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2401        let HirExpr::ReceiverCall { args, .. } = &expr else {
2402            panic!("expected a receiver call, got {expr:?}");
2403        };
2404        assert!(matches!(&args[0], HirExpr::Number { .. }));
2405    }
2406
2407    #[test]
2408    fn unknown_builtins_fail_at_resolution_not_emission() {
2409        let error = compile_error(&action_source("frobnicate()"), 4);
2410        assert_eq!(error.code, "unknown-action");
2411
2412        let error = compile_error(&action_source("g = frobnicate()"), 4);
2413        assert_eq!(error.code, "unknown-value");
2414
2415        let error = compile_error(
2416            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
2417            4,
2418        );
2419        assert_eq!(error.code, "unknown-member");
2420    }
2421
2422    #[test]
2423    fn wright_only_catalog_names_are_rejected() {
2424        // `createHudText` and `squareRoot` are Workshop emission spellings,
2425        // not OPY source functions; the pinned reference rejects them, so
2426        // the manifest does not preserve the accidental acceptance.
2427        let error = compile_error(&action_source("createHudText(1)"), 4);
2428        assert_eq!(error.code, "unknown-action");
2429
2430        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2431        assert_eq!(error.code, "unknown-value");
2432    }
2433
2434    #[test]
2435    fn canonical_ids_without_upstream_spellings_are_rejected() {
2436        // These canonical Workshop ids are not upstream OPY spellings (#410):
2437        // the oracle rejects them, so they are source diagnostics. Their
2438        // upstream spellings keep the same canonical identity via `catalogId`.
2439        for (statement, code) in [
2440            ("g = evaluateOnce(1)", "unknown-value"),
2441            ("g = lastCreatedEntity()", "unknown-value"),
2442            ("g = lastTextId()", "unknown-value"),
2443            ("g = allTankHeroes()", "unknown-value"),
2444            ("g = allDamageHeroes()", "unknown-value"),
2445            ("g = allSupportHeroes()", "unknown-value"),
2446            ("destroyAllHudText()", "unknown-action"),
2447        ] {
2448            let error = compile_error(&action_source(statement), 4);
2449            assert_eq!(error.code, code, "{statement}");
2450        }
2451
2452        let member = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2453        let error = compile_error(member, 4);
2454        assert_eq!(error.code, "unknown-member");
2455
2456        // `.x`/`.y`/`.z` are vector component accessors, not member calls.
2457        for axis in ["x", "y", "z"] {
2458            let source = format!(
2459                "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.{axis}()\n"
2460            );
2461            let error = compile_error(&source, 4);
2462            assert_eq!(error.code, "unknown-member", "{axis}()");
2463        }
2464
2465        // The upstream spellings still resolve.
2466        crate::compile(
2467            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = evalOnce(1)\n    g = getLastCreatedEntity()\n    g = getLastCreatedText()\n    g = getTankHeroes()\n    g = getDamageHeroes()\n    g = getSupportHeroes()\n    g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n    destroyAllHudTexts()\n",
2468            "test.opy",
2469            std::path::Path::new(""),
2470        )
2471        .expect("the upstream spellings compile");
2472    }
2473
2474    #[test]
2475    fn generic_member_only_actions_are_rejected() {
2476        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
2477        // member form is the reference surface.
2478        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2479        assert_eq!(error.code, "unknown-action");
2480    }
2481
2482    #[test]
2483    fn range_is_for_iterables_only() {
2484        // Standalone `range(...)` is rejected by the reference; the
2485        // for-header form keeps 1-3 arguments.
2486        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2487        assert_eq!(error.code, "invalid-call-context");
2488
2489        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
2490        assert_eq!(error.code, "invalid-iterable");
2491
2492        crate::compile(
2493            &action_source("for g in range(3):\n        debug(g)"),
2494            "test.opy",
2495            std::path::Path::new(""),
2496        )
2497        .expect("the for-header range form compiles");
2498    }
2499
2500    #[test]
2501    fn source_aliases_resolve_to_canonical_names() {
2502        // Non-contextual aliases rewrite to the canonical entry so identity,
2503        // position, and emission use the target name.
2504        let hir = crate::compile(
2505            &action_source("stopChasingVariable(g)"),
2506            "test.opy",
2507            std::path::Path::new(""),
2508        )
2509        .expect("the alias target compiles");
2510        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2511            panic!("expected a rule");
2512        };
2513        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2514            panic!("expected expression statement");
2515        };
2516        let HirExpr::Call { name, .. } = expr.as_ref() else {
2517            panic!("expected a call");
2518        };
2519        assert_eq!(name, "stopChasingVariable");
2520
2521        let hir = crate::compile(
2522            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2523             @Condition eventPlayer.getCurrentHero() != null\n    \
2524             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
2525             disableInspector()\n",
2526            "test.opy",
2527            std::path::Path::new(""),
2528        )
2529        .expect("member aliases compile");
2530        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2531            panic!("expected a rule");
2532        };
2533        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2534            panic!("expected a comparison");
2535        };
2536        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2537            panic!("expected a receiver call");
2538        };
2539        assert_eq!(name, "getHero");
2540    }
2541
2542    #[test]
2543    fn unknown_catalog_enum_members_are_rejected() {
2544        for source in [
2545            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
2546            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
2547        ] {
2548            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2549                .expect_err("unknown catalog member must be rejected");
2550            assert_eq!(error.code, "unknown-enum-member");
2551        }
2552    }
2553
2554    #[test]
2555    fn default_var_for_binder_resolves_at_all_range_arities() {
2556        // The agent-lab regression: `for I in range(0, 10):` with `I` not
2557        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
2558        // the pinned reference accepts as an implicit global loop binder
2559        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
2560        for (binder, iterable) in [
2561            ("I", "range(0, 10)"),
2562            ("I", "range(3)"),
2563            ("I", "range(1, 5, 2)"),
2564        ] {
2565            let hir = lower_ok(&format!(
2566                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
2567            ));
2568            let (_, actions) = rule_conditions_and_actions(&hir);
2569            let HirStmt::For { variable, body, .. } = &actions[0] else {
2570                panic!("expected a for statement");
2571            };
2572            assert!(
2573                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2574                "the binder resolves to the implicit global 'I', got {variable:?}"
2575            );
2576            assert!(!body.is_empty(), "the loop body lowers");
2577            // The binder use in the body resolves too: `total += I` has a
2578            // GlobalVar operand.
2579            let HirStmt::Assign { value, .. } = &body[0] else {
2580                panic!("expected an assignment in the body");
2581            };
2582            let HirExpr::Binary { right, .. } = value.as_ref() else {
2583                panic!("expected a binary expression");
2584            };
2585            assert!(
2586                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2587                "the binder use inside the body resolves to the implicit global"
2588            );
2589        }
2590    }
2591
2592    #[test]
2593    fn player_variable_range_binder_preserves_host_player_receiver() {
2594        let hir = lower_ok(
2595            "playervar I\nrule \"r\":\n    @Event global\n    for hostPlayer.I in range(3):\n        hostPlayer.I = 1\n",
2596        );
2597        let (_, actions) = rule_conditions_and_actions(&hir);
2598        let HirStmt::For { variable, .. } = &actions[0] else {
2599            panic!("expected a for statement");
2600        };
2601        let HirExpr::PlayerVar {
2602            player,
2603            name,
2604            member_span,
2605            span,
2606        } = variable.as_ref()
2607        else {
2608            panic!("expected a player-variable binder, got {variable:?}");
2609        };
2610        assert_eq!(name, "I");
2611        assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2612        assert_eq!(span.unwrap().start.line, 4);
2613        assert_eq!(span.unwrap().start.col, 9);
2614        assert_eq!(span.unwrap().end.line, 4);
2615        assert_eq!(span.unwrap().end.col, 21);
2616        let member_span = member_span.expect("player binder member span");
2617        assert_eq!(member_span.start.line, 4);
2618        assert_eq!(member_span.start.col, 20);
2619        assert_eq!(member_span.end.line, 4);
2620        assert_eq!(member_span.end.col, 21);
2621    }
2622
2623    #[test]
2624    fn default_var_names_resolve_as_implicit_globals() {
2625        // Default variable names resolve anywhere a variable may appear,
2626        // matching the pinned reference (no `globalvar` declaration needed).
2627        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
2628        let (_, actions) = rule_conditions_and_actions(&hir);
2629        let HirStmt::Assign { target, .. } = &actions[0] else {
2630            panic!("expected an assignment");
2631        };
2632        assert!(
2633            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2634            "the implicit global resolves, got {target:?}"
2635        );
2636        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
2637        assert_eq!(default_var_index("I"), Some(8));
2638        assert_eq!(default_var_index("AA"), Some(26));
2639        assert_eq!(default_var_index("Z"), Some(25));
2640        assert_eq!(default_var_index("DX"), Some(127));
2641        assert_eq!(default_var_index("DY"), None);
2642        assert_eq!(default_var_index("i"), None);
2643    }
2644
2645    #[test]
2646    fn nested_same_name_for_binders_reuse_the_implicit_global() {
2647        // Nested loops with the same default-var binder reuse the single
2648        // implicit variable, matching the pinned reference (the inner loop
2649        // overwrites the same Workshop global — no separate binding).
2650        let hir = lower_ok(
2651            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
2652        );
2653        let (_, actions) = rule_conditions_and_actions(&hir);
2654        let HirStmt::For {
2655            variable: outer,
2656            body,
2657            ..
2658        } = &actions[0]
2659        else {
2660            panic!("expected an outer for statement");
2661        };
2662        let HirStmt::For {
2663            variable: inner, ..
2664        } = &body[0]
2665        else {
2666            panic!("expected an inner for statement");
2667        };
2668        assert!(
2669            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2670                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2671            "both loops bind the same implicit global (spans differ per binder site)"
2672        );
2673    }
2674
2675    #[test]
2676    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2677        // A lowercase undeclared binder is not a default variable name; the
2678        // pinned reference rejects the program ("Unknown function name"), and
2679        // Wright reports the same reject with the structured
2680        // `unknown-identifier` diagnostic (#114).
2681        let error = compile_error(
2682            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
2683            3,
2684        );
2685        assert_eq!(error.code, "unknown-identifier");
2686        let span = error.span.expect("the error is source-located");
2687        assert_eq!(span.start.line, 3);
2688    }
2689
2690    #[test]
2691    fn syntax_constructs_lower_to_provenance_preserving_hir() {
2692        let hir = lower_ok(
2693            "globalvar x\nrule \"r\":\n    @Event global\n    do:\n        x = {\"x\": 1}[\"x\"]\n    while x not in [2, 3]\n    switch x:\n        case 0x10:\n            x = 1 in [1, 2]\n        default:\n            x = 2\n    x = [value * 2 for value, index in [1, 2] if value > index]\n    x = sorted([1, 2], key=lambda value: value)\n    x = w\"wide\"\n",
2694        );
2695        let (_, actions) = rule_conditions_and_actions(&hir);
2696        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2697            panic!("expected do-while");
2698        };
2699        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2700        let HirStmt::Switch { arms, .. } = &actions[1] else {
2701            panic!("expected switch");
2702        };
2703        assert_eq!(arms.len(), 2);
2704        let HirSwitchArm::Case {
2705            value: case_value,
2706            body,
2707            ..
2708        } = &arms[0]
2709        else {
2710            panic!("expected case arm");
2711        };
2712        assert!(
2713            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2714        );
2715        let HirStmt::Assign { value, .. } = &body[0] else {
2716            panic!("expected case assignment");
2717        };
2718        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2719        let HirSwitchArm::Default { body, .. } = &arms[1] else {
2720            panic!("expected default arm");
2721        };
2722        assert!(matches!(body[0], HirStmt::Assign { .. }));
2723        let HirStmt::Assign { value, .. } = &actions[2] else {
2724            panic!("expected comprehension assignment");
2725        };
2726        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2727        let HirStmt::Assign { value, .. } = &actions[3] else {
2728            panic!("expected sorted assignment");
2729        };
2730        assert!(
2731            matches!(value.as_ref(), HirExpr::Call { name, args, .. } if name == "sorted" && matches!(&args[1], HirExpr::Lambda { body, .. } if matches!(body.as_ref(), HirExpr::Local { name, .. } if name == "value")))
2732        );
2733        let HirStmt::Assign { value, .. } = &actions[4] else {
2734            panic!("expected string assignment");
2735        };
2736        assert!(
2737            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2738        );
2739    }
2740
2741    #[test]
2742    fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2743        let dict_error = compile_error(
2744            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
2745            4,
2746        );
2747        assert_eq!(dict_error.code, "dict-access");
2748        let lambda_error = compile_error(
2749            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
2750            4,
2751        );
2752        assert_eq!(lambda_error.code, "lambda-context");
2753    }
2754
2755    #[test]
2756    fn do_while_requires_rule_or_definition_prefix_position() {
2757        let error = compile_error(
2758            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
2759            5,
2760        );
2761        assert_eq!(error.code, "do-while-placement");
2762        assert_eq!(
2763            error.message,
2764            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2765        );
2766    }
2767}