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