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 sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
1883        // #437: the reference's special `sorted` parse path only strips a
1884        // `key=` prefix inside the second argument; other keyword spellings
1885        // and a misplaced `key=` fail upstream parsing.
1886        let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
1887        assert_eq!(error.code, "unknown-keyword");
1888
1889        let error = compile_error(
1890            &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
1891            4,
1892        );
1893        assert_eq!(error.code, "unknown-keyword");
1894
1895        let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
1896        assert_eq!(error.code, "unknown-keyword");
1897    }
1898
1899    #[test]
1900    fn call_args_reject_parenthesized_keyword_names() {
1901        // #443: the reference detects keyword arguments at the token level,
1902        // so `(name)=expr` never binds as a keyword.
1903        let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
1904        assert_eq!(error.code, "parse-error");
1905
1906        let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
1907        assert_eq!(error.code, "parse-error");
1908
1909        // Bare `name=expr` keyword binding is unaffected, and a parenthesized
1910        // name without `=` still binds positionally.
1911        crate::compile(
1912            &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
1913            "test.opy",
1914            std::path::Path::new(""),
1915        )
1916        .expect("bare keyword arguments compile");
1917    }
1918
1919    #[test]
1920    fn call_args_reject_trailing_comma() {
1921        // #444: the reference keeps the empty segment after a trailing comma
1922        // and rejects it in call argument lists.
1923        for statement in [
1924            "wait(1,)",
1925            "g = vect(1, 2, 3,)",
1926            "g = sorted([1, 2],)",
1927            "g = sorted([1, 2], key=lambda v: -v,)",
1928        ] {
1929            let error = compile_error(&action_source(statement), 4);
1930            assert_eq!(error.code, "parse-error", "statement: {statement}");
1931        }
1932
1933        // Array and dictionary literals keep accepting a trailing comma on
1934        // both sides, as do parenthesized @Event argument lists.
1935        crate::compile(
1936            &action_source("g = [1, 2,]"),
1937            "test.opy",
1938            std::path::Path::new(""),
1939        )
1940        .expect("array literal trailing comma compiles");
1941        crate::compile(
1942            &action_source("g = {\"a\": 1,}[\"a\"]"),
1943            "test.opy",
1944            std::path::Path::new(""),
1945        )
1946        .expect("dict literal trailing comma compiles");
1947        crate::compile(
1948            "rule \"r\":\n    @Event playerDied(victim,)\n    wait(1)\n",
1949            "test.opy",
1950            std::path::Path::new(""),
1951        )
1952        .expect("@Event argument trailing comma compiles");
1953    }
1954
1955    #[test]
1956    fn binder_lambda_rejects_parenthesized() {
1957        // #445: the reference detects binder lambdas at the token level, so
1958        // `(lambda x: e)` never satisfies a lambda-argument position.
1959        for statement in [
1960            "g = sorted([1, 2], (lambda v: -v))",
1961            "g = sorted([1, 2], key=(lambda v: -v))",
1962            "g = [1, 2].map((lambda v: v))",
1963            "g = [1, 2].filter((lambda v: v))",
1964            "g = [1, 2].all((lambda v: v))",
1965            "g = [1, 2].any((lambda v: v))",
1966            "g = sorted([1, 2], ((lambda v: -v)))",
1967        ] {
1968            let error = compile_error(&action_source(statement), 4);
1969            assert_eq!(error.code, "lambda-context", "statement: {statement}");
1970        }
1971
1972        // Bare lambdas still bind.
1973        crate::compile(
1974            &action_source("g = sorted([1, 2], key=lambda v: -v)"),
1975            "test.opy",
1976            std::path::Path::new(""),
1977        )
1978        .expect("bare lambda key compiles");
1979    }
1980
1981    #[test]
1982    fn wait_uses_the_reference_keyword_names() {
1983        // The manifest's `wait` parameter names match the pinned reference
1984        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
1985        // keyword exactly like the oracle.
1986        let error = compile_error(&action_source("wait(duration=1)"), 4);
1987        assert_eq!(error.code, "unknown-keyword");
1988        assert!(error.message.contains("duration"));
1989    }
1990
1991    #[test]
1992    fn action_call_in_value_position_is_rejected() {
1993        let error = compile_error(&action_source("g = wait(1)"), 4);
1994        assert_eq!(error.code, "action-in-value-position");
1995    }
1996
1997    #[test]
1998    fn missing_required_argument_is_a_source_located_diagnostic() {
1999        // Too-few calls reject with the reference's missing-argument
2000        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
2001        // issue #110); positional overflow keeps `invalid-arity`.
2002        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2003        assert_eq!(error.code, "missing-argument");
2004        assert!(error.message.contains("duration"));
2005
2006        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2007        assert_eq!(error.code, "invalid-arity");
2008    }
2009
2010    #[test]
2011    fn missing_member_argument_is_a_source_located_diagnostic() {
2012        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
2013        // 30)` must reject like the oracle (the `status` argument is
2014        // missing; the reference: "Missing argument 'status' for function
2015        // '.setStatusEffect'", issue #110).
2016        let error = compile_error(
2017            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2018             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2019            4,
2020        );
2021        assert_eq!(error.code, "missing-argument");
2022        assert!(error.message.contains("duration"));
2023    }
2024
2025    #[test]
2026    fn invalid_receiver_categories_are_rejected() {
2027        // `.append` requires an assignable receiver; `.format` a string
2028        // literal (both reference-enforced categories).
2029        let error = compile_error(&action_source("3.append(1)"), 4);
2030        assert_eq!(error.code, "invalid-receiver");
2031        assert!(error.message.contains("append"));
2032
2033        let error = compile_error(&action_source("attacker.append(1)"), 4);
2034        assert_eq!(error.code, "invalid-receiver");
2035        assert!(error.message.contains("append"));
2036
2037        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2038        assert_eq!(error.code, "invalid-receiver");
2039        assert!(error.message.contains("format"));
2040    }
2041
2042    #[test]
2043    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2044        // Domain mismatches need canonical Workshop enum knowledge: a member
2045        // of the wrong domain is carried as an opaque identity and the check
2046        // is lowering-dependent (#8).
2047        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2048        let HirExpr::Call { args, .. } = &expr else {
2049            panic!("expected a call, got {expr:?}");
2050        };
2051        assert!(matches!(
2052            &args[3],
2053            HirExpr::Enum { value_type, value, .. }
2054                if value_type == "Invis" && value == "ALL"
2055        ));
2056
2057        let expr = first_action_expr(&action_source(
2058            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2059        ));
2060        let HirExpr::ReceiverCall { args, .. } = &expr else {
2061            panic!("expected a receiver call, got {expr:?}");
2062        };
2063        assert!(matches!(
2064            &args[0],
2065            HirExpr::Enum { value_type, value, .. }
2066                if value_type == "ChaseTimeReeval" && value == "NONE"
2067        ));
2068    }
2069
2070    #[test]
2071    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2072        // Whether a non-enum value is acceptable for an enum parameter is
2073        // Workshop catalog knowledge; the frontend carries the argument
2074        // structurally and leaves the check to lowering (#8).
2075        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2076        let HirExpr::ReceiverCall { args, .. } = &expr else {
2077            panic!("expected a receiver call, got {expr:?}");
2078        };
2079        assert!(matches!(
2080            &args[0],
2081            HirExpr::GlobalVar { name, .. } if name == "g"
2082        ));
2083
2084        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2085        let HirExpr::ReceiverCall { args, .. } = &expr else {
2086            panic!("expected a receiver call, got {expr:?}");
2087        };
2088        assert!(matches!(&args[0], HirExpr::Number { .. }));
2089    }
2090
2091    #[test]
2092    fn unknown_builtins_fail_at_resolution_not_emission() {
2093        let error = compile_error(&action_source("frobnicate()"), 4);
2094        assert_eq!(error.code, "unknown-action");
2095
2096        let error = compile_error(&action_source("g = frobnicate()"), 4);
2097        assert_eq!(error.code, "unknown-value");
2098
2099        let error = compile_error(
2100            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
2101            4,
2102        );
2103        assert_eq!(error.code, "unknown-member");
2104    }
2105
2106    #[test]
2107    fn wright_only_catalog_names_are_rejected() {
2108        // `createHudText` and `squareRoot` are Workshop emission spellings,
2109        // not OPY source functions; the pinned reference rejects them, so
2110        // the manifest does not preserve the accidental acceptance.
2111        let error = compile_error(&action_source("createHudText(1)"), 4);
2112        assert_eq!(error.code, "unknown-action");
2113
2114        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2115        assert_eq!(error.code, "unknown-value");
2116    }
2117
2118    #[test]
2119    fn canonical_ids_without_upstream_spellings_are_rejected() {
2120        // These canonical Workshop ids are not upstream OPY spellings (#410):
2121        // the oracle rejects them, so they are source diagnostics. Their
2122        // upstream spellings keep the same canonical identity via `catalogId`.
2123        for (statement, code) in [
2124            ("g = evaluateOnce(1)", "unknown-value"),
2125            ("g = lastCreatedEntity()", "unknown-value"),
2126            ("g = lastTextId()", "unknown-value"),
2127            ("g = allTankHeroes()", "unknown-value"),
2128            ("g = allDamageHeroes()", "unknown-value"),
2129            ("g = allSupportHeroes()", "unknown-value"),
2130            ("destroyAllHudText()", "unknown-action"),
2131        ] {
2132            let error = compile_error(&action_source(statement), 4);
2133            assert_eq!(error.code, code, "{statement}");
2134        }
2135
2136        let member = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2137        let error = compile_error(member, 4);
2138        assert_eq!(error.code, "unknown-member");
2139
2140        // `.x`/`.y`/`.z` are vector component accessors, not member calls.
2141        for axis in ["x", "y", "z"] {
2142            let source = format!(
2143                "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.{axis}()\n"
2144            );
2145            let error = compile_error(&source, 4);
2146            assert_eq!(error.code, "unknown-member", "{axis}()");
2147        }
2148
2149        // The upstream spellings still resolve.
2150        crate::compile(
2151            "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",
2152            "test.opy",
2153            std::path::Path::new(""),
2154        )
2155        .expect("the upstream spellings compile");
2156    }
2157
2158    #[test]
2159    fn generic_member_only_actions_are_rejected() {
2160        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
2161        // member form is the reference surface.
2162        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2163        assert_eq!(error.code, "unknown-action");
2164    }
2165
2166    #[test]
2167    fn range_is_for_iterables_only() {
2168        // Standalone `range(...)` is rejected by the reference; the
2169        // for-header form keeps 1-3 arguments.
2170        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2171        assert_eq!(error.code, "invalid-call-context");
2172
2173        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
2174        assert_eq!(error.code, "invalid-iterable");
2175
2176        crate::compile(
2177            &action_source("for g in range(3):\n        debug(g)"),
2178            "test.opy",
2179            std::path::Path::new(""),
2180        )
2181        .expect("the for-header range form compiles");
2182    }
2183
2184    #[test]
2185    fn source_aliases_resolve_to_canonical_names() {
2186        // Non-contextual aliases rewrite to the canonical entry so identity,
2187        // position, and emission use the target name.
2188        let hir = crate::compile(
2189            &action_source("stopChasingVariable(g)"),
2190            "test.opy",
2191            std::path::Path::new(""),
2192        )
2193        .expect("the alias target compiles");
2194        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2195            panic!("expected a rule");
2196        };
2197        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2198            panic!("expected expression statement");
2199        };
2200        let HirExpr::Call { name, .. } = expr.as_ref() else {
2201            panic!("expected a call");
2202        };
2203        assert_eq!(name, "stopChasingVariable");
2204
2205        let hir = crate::compile(
2206            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2207             @Condition eventPlayer.getCurrentHero() != null\n    \
2208             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
2209             disableInspector()\n",
2210            "test.opy",
2211            std::path::Path::new(""),
2212        )
2213        .expect("member aliases compile");
2214        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2215            panic!("expected a rule");
2216        };
2217        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2218            panic!("expected a comparison");
2219        };
2220        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2221            panic!("expected a receiver call");
2222        };
2223        assert_eq!(name, "getHero");
2224    }
2225
2226    #[test]
2227    fn unknown_catalog_enum_members_are_rejected() {
2228        for source in [
2229            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
2230            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
2231        ] {
2232            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2233                .expect_err("unknown catalog member must be rejected");
2234            assert_eq!(error.code, "unknown-enum-member");
2235        }
2236    }
2237
2238    #[test]
2239    fn default_var_for_binder_resolves_at_all_range_arities() {
2240        // The agent-lab regression: `for I in range(0, 10):` with `I` not
2241        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
2242        // the pinned reference accepts as an implicit global loop binder
2243        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
2244        for (binder, iterable) in [
2245            ("I", "range(0, 10)"),
2246            ("I", "range(3)"),
2247            ("I", "range(1, 5, 2)"),
2248        ] {
2249            let hir = lower_ok(&format!(
2250                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
2251            ));
2252            let (_, actions) = rule_conditions_and_actions(&hir);
2253            let HirStmt::For { variable, body, .. } = &actions[0] else {
2254                panic!("expected a for statement");
2255            };
2256            assert!(
2257                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2258                "the binder resolves to the implicit global 'I', got {variable:?}"
2259            );
2260            assert!(!body.is_empty(), "the loop body lowers");
2261            // The binder use in the body resolves too: `total += I` has a
2262            // GlobalVar operand.
2263            let HirStmt::Assign { value, .. } = &body[0] else {
2264                panic!("expected an assignment in the body");
2265            };
2266            let HirExpr::Binary { right, .. } = value.as_ref() else {
2267                panic!("expected a binary expression");
2268            };
2269            assert!(
2270                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2271                "the binder use inside the body resolves to the implicit global"
2272            );
2273        }
2274    }
2275
2276    #[test]
2277    fn player_variable_range_binder_preserves_host_player_receiver() {
2278        let hir = lower_ok(
2279            "playervar I\nrule \"r\":\n    @Event global\n    for hostPlayer.I in range(3):\n        hostPlayer.I = 1\n",
2280        );
2281        let (_, actions) = rule_conditions_and_actions(&hir);
2282        let HirStmt::For { variable, .. } = &actions[0] else {
2283            panic!("expected a for statement");
2284        };
2285        let HirExpr::PlayerVar {
2286            player,
2287            name,
2288            member_span,
2289            span,
2290        } = variable.as_ref()
2291        else {
2292            panic!("expected a player-variable binder, got {variable:?}");
2293        };
2294        assert_eq!(name, "I");
2295        assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2296        assert_eq!(span.unwrap().start.line, 4);
2297        assert_eq!(span.unwrap().start.col, 9);
2298        assert_eq!(span.unwrap().end.line, 4);
2299        assert_eq!(span.unwrap().end.col, 21);
2300        let member_span = member_span.expect("player binder member span");
2301        assert_eq!(member_span.start.line, 4);
2302        assert_eq!(member_span.start.col, 20);
2303        assert_eq!(member_span.end.line, 4);
2304        assert_eq!(member_span.end.col, 21);
2305    }
2306
2307    #[test]
2308    fn default_var_names_resolve_as_implicit_globals() {
2309        // Default variable names resolve anywhere a variable may appear,
2310        // matching the pinned reference (no `globalvar` declaration needed).
2311        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
2312        let (_, actions) = rule_conditions_and_actions(&hir);
2313        let HirStmt::Assign { target, .. } = &actions[0] else {
2314            panic!("expected an assignment");
2315        };
2316        assert!(
2317            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2318            "the implicit global resolves, got {target:?}"
2319        );
2320        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
2321        assert_eq!(default_var_index("I"), Some(8));
2322        assert_eq!(default_var_index("AA"), Some(26));
2323        assert_eq!(default_var_index("Z"), Some(25));
2324        assert_eq!(default_var_index("DX"), Some(127));
2325        assert_eq!(default_var_index("DY"), None);
2326        assert_eq!(default_var_index("i"), None);
2327    }
2328
2329    #[test]
2330    fn nested_same_name_for_binders_reuse_the_implicit_global() {
2331        // Nested loops with the same default-var binder reuse the single
2332        // implicit variable, matching the pinned reference (the inner loop
2333        // overwrites the same Workshop global — no separate binding).
2334        let hir = lower_ok(
2335            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
2336        );
2337        let (_, actions) = rule_conditions_and_actions(&hir);
2338        let HirStmt::For {
2339            variable: outer,
2340            body,
2341            ..
2342        } = &actions[0]
2343        else {
2344            panic!("expected an outer for statement");
2345        };
2346        let HirStmt::For {
2347            variable: inner, ..
2348        } = &body[0]
2349        else {
2350            panic!("expected an inner for statement");
2351        };
2352        assert!(
2353            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2354                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2355            "both loops bind the same implicit global (spans differ per binder site)"
2356        );
2357    }
2358
2359    #[test]
2360    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2361        // A lowercase undeclared binder is not a default variable name; the
2362        // pinned reference rejects the program ("Unknown function name"), and
2363        // Wright reports the same reject with the structured
2364        // `unknown-identifier` diagnostic (#114).
2365        let error = compile_error(
2366            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
2367            3,
2368        );
2369        assert_eq!(error.code, "unknown-identifier");
2370        let span = error.span.expect("the error is source-located");
2371        assert_eq!(span.start.line, 3);
2372    }
2373
2374    #[test]
2375    fn syntax_constructs_lower_to_provenance_preserving_hir() {
2376        let hir = lower_ok(
2377            "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",
2378        );
2379        let (_, actions) = rule_conditions_and_actions(&hir);
2380        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2381            panic!("expected do-while");
2382        };
2383        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2384        let HirStmt::Switch { arms, .. } = &actions[1] else {
2385            panic!("expected switch");
2386        };
2387        assert_eq!(arms.len(), 2);
2388        let HirSwitchArm::Case {
2389            value: case_value,
2390            body,
2391            ..
2392        } = &arms[0]
2393        else {
2394            panic!("expected case arm");
2395        };
2396        assert!(
2397            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2398        );
2399        let HirStmt::Assign { value, .. } = &body[0] else {
2400            panic!("expected case assignment");
2401        };
2402        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2403        let HirSwitchArm::Default { body, .. } = &arms[1] else {
2404            panic!("expected default arm");
2405        };
2406        assert!(matches!(body[0], HirStmt::Assign { .. }));
2407        let HirStmt::Assign { value, .. } = &actions[2] else {
2408            panic!("expected comprehension assignment");
2409        };
2410        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2411        let HirStmt::Assign { value, .. } = &actions[3] else {
2412            panic!("expected sorted assignment");
2413        };
2414        assert!(
2415            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")))
2416        );
2417        let HirStmt::Assign { value, .. } = &actions[4] else {
2418            panic!("expected string assignment");
2419        };
2420        assert!(
2421            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2422        );
2423    }
2424
2425    #[test]
2426    fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2427        let dict_error = compile_error(
2428            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
2429            4,
2430        );
2431        assert_eq!(dict_error.code, "dict-access");
2432        let lambda_error = compile_error(
2433            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
2434            4,
2435        );
2436        assert_eq!(lambda_error.code, "lambda-context");
2437    }
2438
2439    #[test]
2440    fn do_while_requires_rule_or_definition_prefix_position() {
2441        let error = compile_error(
2442            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
2443            5,
2444        );
2445        assert_eq!(error.code, "do-while-placement");
2446        assert_eq!(
2447            error.message,
2448            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2449        );
2450    }
2451}