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