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