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::{
41    Function, FunctionContext, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory,
42};
43use workshop_rs::catalog::{Catalog, Locale};
44
45/// The protocol envelope this frontend produces.
46const PROTOCOL_NAME: &str = "wright/opy-hir";
47
48/// The call-position context of an expression being lowered; builtin
49/// resolution checks action/value identity against this context.
50#[derive(Clone, Copy, PartialEq, Eq)]
51enum CallPosition {
52    /// A statement position (a bare expression statement).
53    Statement,
54    /// A value position (conditions, assignments, call arguments, …).
55    Value,
56    /// A `for ... in` iterable (only `range` is a valid builtin here).
57    ForIterable,
58    /// An expression occupying a signature-approved lambda argument slot.
59    LambdaArgument,
60    /// An expression in a macro body, whose final call position is determined
61    /// when the macro is expanded.
62    MacroBody,
63}
64
65/// The lowerer's symbol context, built from the CST declarations.
66struct Lowerer {
67    global_declarations: HashMap<String, usize>,
68    player_declarations: HashMap<String, usize>,
69    subroutine_declarations: HashMap<String, usize>,
70    subroutine_definitions: Vec<(String, usize)>,
71    constant_declarations: HashMap<String, usize>,
72    macro_declarations: HashMap<String, usize>,
73    enums: HashMap<String, Vec<String>>,
74    enum_declarations: HashMap<String, usize>,
75    locals: Vec<String>,
76    current_order: usize,
77    allow_dict_literal: bool,
78    /// The authoritative builtin semantic table (issue #109).
79    manifest: &'static Manifest,
80    /// The canonical Workshop catalog linked by the manifest.
81    catalog: Catalog,
82    errors: Vec<OpyError>,
83}
84
85/// Lower a parsed program into the Opy HIR contract.
86pub fn lower(
87    program: &cst::Program,
88    files: Vec<SourceFile>,
89    defines: Vec<Define>,
90) -> OpyResult<HirProgram> {
91    lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
92}
93
94pub fn lower_with_preprocessing(
95    program: &cst::Program,
96    files: Vec<SourceFile>,
97    defines: Vec<Define>,
98    preprocessing: &PreprocessingState,
99) -> OpyResult<HirProgram> {
100    let manifest = match Manifest::builtin() {
101        Ok(manifest) => manifest,
102        Err(error) => {
103            return Err(OpyError::new(
104                "manifest-error",
105                format!("cannot load the OPY semantic compatibility manifest: {error}"),
106            ));
107        }
108    };
109    let catalog = match Catalog::builtin() {
110        Ok(catalog) => catalog,
111        Err(error) => {
112            return Err(OpyError::new(
113                "catalog-error",
114                format!("cannot load the Workshop catalog: {error}"),
115            ));
116        }
117    };
118    let mut lowerer = Lowerer {
119        global_declarations: HashMap::new(),
120        player_declarations: HashMap::new(),
121        subroutine_declarations: HashMap::new(),
122        subroutine_definitions: Vec::new(),
123        constant_declarations: HashMap::new(),
124        macro_declarations: HashMap::new(),
125        enums: HashMap::new(),
126        enum_declarations: HashMap::new(),
127        locals: Vec::new(),
128        current_order: 0,
129        allow_dict_literal: false,
130        manifest,
131        catalog,
132        errors: Vec::new(),
133    };
134    lowerer.collect_symbols(program);
135
136    let mut declarations = Vec::new();
137    let mut rules = Vec::new();
138    let mut implicit_subroutines = HashSet::new();
139    for (order, item) in program.top_level.iter().enumerate() {
140        lowerer.current_order = order;
141        match item {
142            TopLevel::Declaration(decl) => match decl {
143                Decl::GlobalVariable {
144                    name,
145                    index,
146                    span,
147                    name_span,
148                    initializer,
149                } => declarations.push(Declaration::GlobalVariable {
150                    name: name.clone(),
151                    index: *index,
152                    span: Some(span.into()),
153                    name_span: Some(name_span.into()),
154                    initializer: lowerer.initializer(initializer.as_ref()),
155                }),
156                Decl::PlayerVariable {
157                    name,
158                    index,
159                    span,
160                    name_span,
161                    initializer,
162                } => declarations.push(Declaration::PlayerVariable {
163                    name: name.clone(),
164                    index: *index,
165                    span: Some(span.into()),
166                    name_span: Some(name_span.into()),
167                    initializer: lowerer.initializer(initializer.as_ref()),
168                }),
169                Decl::Subroutine {
170                    name,
171                    span,
172                    name_span,
173                } => declarations.push(Declaration::Subroutine {
174                    name: name.clone(),
175                    index: None,
176                    span: Some(span.into()),
177                    name_span: Some(name_span.into()),
178                }),
179                Decl::Enum { .. } => {
180                    // Custom enums fold to numeric constants at use sites and
181                    // produce no HIR declaration (reference behavior).
182                }
183                Decl::Constant { name, value, span } => {
184                    let previous = lowerer.allow_dict_literal;
185                    lowerer.allow_dict_literal = true;
186                    let value = lowerer.lower_expr(value, &[], CallPosition::Value);
187                    lowerer.allow_dict_literal = previous;
188                    declarations.push(Declaration::Constant {
189                        name: name.clone(),
190                        span: Some(span.into()),
191                        value: Box::new(value),
192                    });
193                }
194                Decl::Macro {
195                    name,
196                    args,
197                    body,
198                    span,
199                } => {
200                    let lowered_body = lowerer.lower_macro_body(body, args);
201                    declarations.push(Declaration::Macro {
202                        name: name.clone(),
203                        args: args.clone(),
204                        span: Some(span.into()),
205                        body: lowered_body,
206                    });
207                }
208            },
209            TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
210                lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
211            )),
212            TopLevel::Rule(CstRuleEntry::SubroutineDef {
213                name,
214                presentation_name,
215                span,
216                name_span,
217                body,
218                annotations,
219                rule_prefix,
220            }) => {
221                if !lowerer.subroutine_declarations.contains_key(name)
222                    && implicit_subroutines.insert(name.clone())
223                {
224                    declarations.push(Declaration::Subroutine {
225                        name: name.clone(),
226                        index: None,
227                        span: Some(span.into()),
228                        name_span: Some(name_span.into()),
229                    });
230                }
231                let base_name = presentation_name
232                    .as_deref()
233                    .map(str::to_string)
234                    .unwrap_or_else(|| name.clone());
235                let generated_name = render_rule_name(
236                    &base_name,
237                    rule_prefix.as_deref(),
238                    false,
239                    *span,
240                    files.as_slice(),
241                    preprocessing,
242                )?;
243                rules.push(RuleEntry::SubroutineDef {
244                    kind: "subroutineDef".to_string(),
245                    name: generated_name,
246                    source_name: name.clone(),
247                    span: Some(span.into()),
248                    name_span: Some(name_span.into()),
249                    body: lowerer.lower_block(body, &[], false, true, false),
250                    annotations: lower_annotations(annotations),
251                });
252            }
253        }
254    }
255
256    if !lowerer.errors.is_empty() {
257        return Err(lowerer.errors.swap_remove(0));
258    }
259
260    Ok(HirProgram {
261        protocol: Protocol {
262            name: PROTOCOL_NAME.to_string(),
263            version: PROTOCOL_VERSION.to_string(),
264        },
265        generator: Generator {
266            name: crate::LANGUAGE_NAME.to_string(),
267            version: crate::LANGUAGE_VERSION.to_string(),
268            frontend: crate::LANGUAGE_NAME.to_string(),
269        },
270        files,
271        defines,
272        declarations,
273        rules,
274        settings: program.settings.as_ref().map(lower_settings),
275        preprocessing: preprocessing.clone(),
276    })
277}
278
279fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
280    match prefix {
281        Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
282            format!("[{prefix}] {name}")
283        }
284        _ => name.to_string(),
285    }
286}
287
288#[derive(Clone, Debug)]
289enum TemplateValue {
290    String(String),
291    Bool(bool),
292}
293
294fn render_rule_name(
295    name: &str,
296    prefix: Option<&str>,
297    delimiter: bool,
298    span: Span,
299    files: &[SourceFile],
300    preprocessing: &PreprocessingState,
301) -> OpyResult<String> {
302    let Some(template) = preprocessing
303        .rule_prefix_template
304        .as_ref()
305        .map(|value| value.value.as_str())
306    else {
307        return Ok(prefixed_rule_name(name, prefix, delimiter));
308    };
309    let (file, path) = rule_file_parts(span.file, files);
310    let prefix = prefix.unwrap_or_default();
311    let values = [
312        ("$rule", TemplateValue::String(name.to_string())),
313        ("$prefix", TemplateValue::String(prefix.to_string())),
314        ("$file", TemplateValue::String(file.clone())),
315        ("$path", TemplateValue::String(path.clone())),
316        ("$isDelimiter", TemplateValue::Bool(delimiter)),
317        ("$prefixTitle", TemplateValue::String(title_case(prefix))),
318        ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
319        ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
320        ("$fileTitle", TemplateValue::String(title_case(&file))),
321        ("$fileUpper", TemplateValue::String(file.to_uppercase())),
322        ("$fileLower", TemplateValue::String(file.to_lowercase())),
323        ("$pathTitle", TemplateValue::String(title_case(&path))),
324        ("$pathUpper", TemplateValue::String(path.to_uppercase())),
325        ("$pathLower", TemplateValue::String(path.to_lowercase())),
326    ];
327    evaluate_template(template, &values).map_err(|message| {
328        OpyError::at(
329            "rule-prefix-template-invalid",
330            format!("could not resolve rule prefix template: {message}"),
331            span,
332        )
333    })
334}
335
336fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
337    let path = files
338        .iter()
339        .find(|file| file.id == file_id)
340        .map(|file| file.path.replace('\\', "/"))
341        .unwrap_or_default();
342    let without_extension = path
343        .strip_suffix(".opy")
344        .or_else(|| path.strip_suffix(".OPY"))
345        .unwrap_or(&path)
346        .to_string();
347    let file = without_extension
348        .rsplit('/')
349        .next()
350        .unwrap_or_default()
351        .to_string();
352    (file, without_extension)
353}
354
355fn title_case(value: &str) -> String {
356    let mut result = String::with_capacity(value.len());
357    let mut capitalize = true;
358    for ch in value.chars() {
359        if ch == '_' {
360            result.push(' ');
361            capitalize = true;
362        } else if capitalize && ch.is_ascii_alphabetic() {
363            result.push(ch.to_ascii_uppercase());
364            capitalize = false;
365        } else {
366            result.push(ch);
367            if !ch.is_whitespace() && ch != '/' {
368                capitalize = false;
369            }
370        }
371        if ch == '/' || ch.is_whitespace() {
372            capitalize = true;
373        }
374    }
375    result
376}
377
378fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
379    if let Some((then_value, condition, else_value)) = split_conditional(template) {
380        let branch = if evaluate_condition(condition, values)? {
381            then_value
382        } else {
383            else_value
384        };
385        return evaluate_string(branch, values);
386    }
387    evaluate_string(template, values)
388}
389
390fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
391    let mut quote = None;
392    let mut depth = 0usize;
393    let mut if_start = None;
394    let mut else_start = None;
395    for (index, ch) in value.char_indices() {
396        match (ch, quote) {
397            ('"' | '\'', None) => quote = Some(ch),
398            (ch, Some(current)) if ch == current => quote = None,
399            ('{', None) => depth += 1,
400            ('}', None) => depth = depth.saturating_sub(1),
401            _ => {}
402        }
403        if quote.is_none() && depth == 0 {
404            if value[index..].starts_with(" if ") && if_start.is_none() {
405                if_start = Some(index);
406            } else if value[index..].starts_with(" else ") && else_start.is_none() {
407                else_start = Some(index);
408            }
409        }
410    }
411    let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
412        return None;
413    };
414    Some((
415        value[..if_start].trim(),
416        value[if_start + 4..else_start].trim(),
417        value[else_start + 6..].trim(),
418    ))
419}
420
421fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
422    let value = value.trim();
423    if let Some(rest) = value.strip_prefix("not ") {
424        return Ok(!evaluate_condition(rest, values)?);
425    }
426    if let Some((left, right)) = value.split_once(" or ") {
427        return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
428    }
429    if let Some((left, right)) = value.split_once(" and ") {
430        return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
431    }
432    match lookup_template_value(value, values)? {
433        TemplateValue::Bool(value) => Ok(value),
434        TemplateValue::String(value) => Ok(!value.is_empty()),
435    }
436}
437
438fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
439    let value = value.trim();
440    if let Some(body) = value
441        .strip_prefix("f\"")
442        .and_then(|body| body.strip_suffix('"'))
443    {
444        return interpolate_fstring(body, values);
445    }
446    if let Some(body) = value
447        .strip_prefix("f'")
448        .and_then(|body| body.strip_suffix('\''))
449    {
450        return interpolate_fstring(body, values);
451    }
452    if value.len() >= 2
453        && ((value.starts_with('"') && value.ends_with('"'))
454            || (value.starts_with('\'') && value.ends_with('\'')))
455    {
456        return Ok(value[1..value.len() - 1].to_string());
457    }
458    match lookup_template_value(value, values)? {
459        TemplateValue::String(value) => Ok(value),
460        TemplateValue::Bool(value) => Ok(value.to_string()),
461    }
462}
463
464fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
465    let mut result = String::new();
466    let mut remaining = body;
467    while let Some(start) = remaining.find('{') {
468        result.push_str(&remaining[..start]);
469        let end = remaining[start + 1..]
470            .find('}')
471            .ok_or_else(|| "unterminated interpolation".to_string())?
472            + start
473            + 1;
474        result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
475        remaining = &remaining[end + 1..];
476    }
477    result.push_str(remaining);
478    Ok(result)
479}
480
481fn lookup_template_value(
482    value: &str,
483    values: &[(&str, TemplateValue)],
484) -> Result<TemplateValue, String> {
485    let value = value.trim();
486    let (base, mut methods) = value
487        .split_once('.')
488        .map_or((value, ""), |(base, methods)| (base, methods));
489    let mut result = values
490        .iter()
491        .find(|(name, _)| *name == base)
492        .map(|(_, value)| value.clone())
493        .ok_or_else(|| format!("unsupported expression '{value}'"))?;
494    while !methods.is_empty() {
495        let (method, rest) = methods
496            .split_once('.')
497            .map_or((methods, ""), |(method, rest)| (method, rest));
498        if method == "upper()" {
499            result = TemplateValue::String(as_string(&result).to_uppercase());
500        } else if method == "lower()" {
501            result = TemplateValue::String(as_string(&result).to_lowercase());
502        } else if let Some(args) = method
503            .strip_prefix("replace(")
504            .and_then(|v| v.strip_suffix(')'))
505        {
506            let (from, to) = args
507                .split_once(',')
508                .ok_or_else(|| "replace expects two arguments".to_string())?;
509            let from = unquote_template_arg(from.trim())?;
510            let to = unquote_template_arg(to.trim())?;
511            result = TemplateValue::String(as_string(&result).replace(&from, &to));
512        } else {
513            return Err(format!("unsupported method '{method}'"));
514        }
515        methods = rest;
516    }
517    Ok(result)
518}
519
520fn as_string(value: &TemplateValue) -> String {
521    match value {
522        TemplateValue::String(value) => value.clone(),
523        TemplateValue::Bool(value) => value.to_string(),
524    }
525}
526
527fn unquote_template_arg(value: &str) -> Result<String, String> {
528    if value.len() >= 2
529        && ((value.starts_with('"') && value.ends_with('"'))
530            || (value.starts_with('\'') && value.ends_with('\'')))
531    {
532        Ok(value[1..value.len() - 1].to_string())
533    } else {
534        Err(format!("expected a quoted string argument, got '{value}'"))
535    }
536}
537
538fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
539    annotations
540        .iter()
541        .map(|annotation| HirAnnotation {
542            name: annotation.name.clone(),
543            args: annotation
544                .args
545                .iter()
546                .map(|arg| HirAnnotationArg {
547                    text: arg.text.clone(),
548                    span: Some(arg.span.into()),
549                })
550                .collect(),
551            span: Some(annotation.span.into()),
552        })
553        .collect()
554}
555
556/// Map a parsed CST settings block onto the protocol settings tree (#86).
557fn lower_settings(settings: &cst::Settings) -> HirSettings {
558    HirSettings {
559        span: Some(settings.span.into()),
560        children: settings.children.iter().map(lower_settings_node).collect(),
561    }
562}
563
564fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
565    match node {
566        cst::SettingsNode::Group {
567            name,
568            children,
569            span,
570        } => HirSettingsNode::Group {
571            name: name.clone(),
572            children: children.iter().map(lower_settings_node).collect(),
573            span: Some((*span).into()),
574        },
575        cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
576            name: name.clone(),
577            value: *value,
578            span: Some((*span).into()),
579        },
580        cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
581            name: name.clone(),
582            value: *value,
583            span: Some((*span).into()),
584        },
585        cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
586            name: name.clone(),
587            value: value.clone(),
588            span: Some((*span).into()),
589        },
590        cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
591            name: name.clone(),
592            value: value.clone(),
593            span: Some((*span).into()),
594        },
595        cst::SettingsNode::List {
596            name,
597            elements,
598            span,
599        } => HirSettingsNode::List {
600            name: name.clone(),
601            elements: elements
602                .iter()
603                .map(|element| crate::hir::types::SettingsListElement {
604                    value: element.value.clone(),
605                    span: Some(element.span.into()),
606                })
607                .collect(),
608            span: Some((*span).into()),
609        },
610    }
611}
612
613impl Lowerer {
614    fn collect_symbols(&mut self, program: &cst::Program) {
615        for (order, item) in program.top_level.iter().enumerate() {
616            let TopLevel::Declaration(decl) = item else {
617                continue;
618            };
619            match decl {
620                Decl::GlobalVariable { name, span, .. } => {
621                    let duplicate = self.global_declarations.contains_key(name);
622                    self.global_declarations
623                        .entry(name.clone())
624                        .or_insert(order);
625                    if duplicate {
626                        self.error_at(
627                            "duplicate-declaration",
628                            format!("duplicate global variable '{name}'"),
629                            *span,
630                        );
631                    }
632                }
633                Decl::PlayerVariable { name, span, .. } => {
634                    let duplicate = self.player_declarations.contains_key(name);
635                    self.player_declarations
636                        .entry(name.clone())
637                        .or_insert(order);
638                    if duplicate {
639                        self.error_at(
640                            "duplicate-declaration",
641                            format!("duplicate player variable '{name}'"),
642                            *span,
643                        );
644                    }
645                }
646                Decl::Subroutine { name, span, .. } => {
647                    let duplicate = self.subroutine_declarations.contains_key(name);
648                    self.subroutine_declarations
649                        .entry(name.clone())
650                        .or_insert(order);
651                    if duplicate {
652                        self.error_at(
653                            "duplicate-declaration",
654                            format!("duplicate subroutine '{name}'"),
655                            *span,
656                        );
657                    }
658                }
659                Decl::Constant { name, span, .. } => {
660                    let duplicate = self.constant_declarations.contains_key(name);
661                    self.constant_declarations
662                        .entry(name.clone())
663                        .or_insert(order);
664                    if duplicate {
665                        self.error_at(
666                            "duplicate-declaration",
667                            format!("duplicate constant '{name}'"),
668                            *span,
669                        );
670                    }
671                }
672                Decl::Enum { name, members, .. } => {
673                    self.enum_declarations.entry(name.clone()).or_insert(order);
674                    self.enums.entry(name.clone()).or_insert_with(|| {
675                        members.iter().map(|(member, _)| member.clone()).collect()
676                    });
677                }
678                Decl::Macro { name, .. } => {
679                    self.macro_declarations.entry(name.clone()).or_insert(order);
680                }
681            }
682        }
683        for (order, item) in program.top_level.iter().enumerate() {
684            let TopLevel::Rule(CstRuleEntry::SubroutineDef { name, span, .. }) = item else {
685                continue;
686            };
687            if self
688                .subroutine_definitions
689                .iter()
690                .any(|(defined, _)| defined == name)
691            {
692                self.error_at(
693                    "duplicate-definition",
694                    format!("duplicate subroutine definition '{name}'"),
695                    *span,
696                );
697            }
698            self.subroutine_definitions.push((name.clone(), order));
699        }
700    }
701
702    fn subroutine_visible(&self, name: &str) -> bool {
703        self.subroutine_declarations
704            .get(name)
705            .is_some_and(|order| *order <= self.current_order)
706            || self
707                .subroutine_definitions
708                .iter()
709                .any(|(definition, order)| definition == name && *order <= self.current_order)
710    }
711
712    fn global_visible(&self, name: &str) -> bool {
713        self.global_declarations
714            .get(name)
715            .is_some_and(|order| *order <= self.current_order)
716    }
717
718    fn player_visible(&self, name: &str) -> bool {
719        self.player_declarations
720            .get(name)
721            .is_some_and(|order| *order <= self.current_order)
722    }
723
724    fn macro_visible(&self, name: &str) -> bool {
725        self.macro_declarations
726            .get(name)
727            .is_some_and(|order| *order <= self.current_order)
728    }
729
730    fn constant_visible(&self, name: &str) -> bool {
731        self.constant_declarations
732            .get(name)
733            .is_some_and(|order| *order <= self.current_order)
734    }
735
736    fn enum_visible(&self, name: &str) -> bool {
737        self.enum_declarations
738            .get(name)
739            .is_some_and(|order| *order <= self.current_order)
740    }
741
742    /// A declaration initializer: integer-`0` literal initializers are
743    /// dropped (matching the reference adapter, which drops `h = 0` but
744    /// carries `j = 5` and `k = 0.0`); other initializers are kept.
745    fn initializer(&mut self, initializer: Option<&Expr>) -> Option<Box<HirExpr>> {
746        let initializer = initializer?;
747        let lowered = self.lower_expr(initializer, &[], CallPosition::Value);
748        match &lowered {
749            HirExpr::Number { text, .. } if text == "0" => None,
750            other => Some(Box::new(other.clone())),
751        }
752    }
753
754    fn lower_rule(
755        &mut self,
756        rule: &cst::Rule,
757        files: &[SourceFile],
758        preprocessing: &PreprocessingState,
759    ) -> OpyResult<Rule> {
760        if let Some(annotation) = rule
761            .annotations
762            .iter()
763            .find(|annotation| annotation.name == "Name")
764        {
765            self.error_at(
766                "duplicate-rule-name",
767                "Rule name was already declared".to_string(),
768                annotation.span,
769            );
770        }
771        let conditions = rule
772            .conditions
773            .iter()
774            .map(|condition| self.lower_expr(condition, &[], CallPosition::Value))
775            .collect();
776        let actions = self.lower_block(&rule.actions, &[], false, true, false);
777        Ok(Rule {
778            name: render_rule_name(
779                &rule.name,
780                rule.rule_prefix.as_deref(),
781                rule.delimiter,
782                rule.span,
783                files,
784                preprocessing,
785            )?,
786            span: Some(rule.span.into()),
787            name_span: Some(rule.name_span.into()),
788            disabled: rule.disabled,
789            delimiter: rule.delimiter,
790            new_page: rule.new_page.clone(),
791            annotations: lower_annotations(&rule.annotations),
792            event: Event {
793                name: rule.event.name.clone(),
794                args: rule
795                    .event
796                    .args
797                    .iter()
798                    .map(|arg| self.lower_expr(arg, &[], CallPosition::Value))
799                    .collect(),
800                span: Some(rule.event.span.into()),
801            },
802            conditions,
803            actions,
804        })
805    }
806
807    /// Lower a statement block; `macro_params` names resolve to `MacroParam`.
808    fn lower_block(
809        &mut self,
810        stmts: &[Stmt],
811        macro_params: &[String],
812        breakable: bool,
813        allow_do_while: bool,
814        loopable: bool,
815    ) -> Vec<HirStmt> {
816        let mut lowered = Vec::new();
817        for (index, stmt) in stmts.iter().enumerate() {
818            if matches!(stmt, Stmt::DoWhile { .. })
819                && (!allow_do_while
820                    || stmts[..index]
821                        .iter()
822                        .any(|previous| !matches!(previous, Stmt::Pass { .. })))
823            {
824                self.error_at(
825                    "do-while-placement",
826                    "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it".to_string(),
827                    stmt.span(),
828                );
829            }
830            if let Stmt::Expr {
831                expr: Expr::Call { name, args, .. },
832                span,
833            } = stmt
834            {
835                if name == "splitDictArray" {
836                    lowered.extend(self.lower_split_dict_array(args, *span, macro_params));
837                    continue;
838                }
839            }
840            lowered.push(self.lower_stmt(stmt, macro_params, breakable, loopable));
841        }
842        lowered
843    }
844
845    fn lower_split_dict_array(
846        &mut self,
847        args: &[cst::CallArg],
848        span: Span,
849        macro_params: &[String],
850    ) -> Vec<HirStmt> {
851        let Some(fields) = args.first().map(|arg| &arg.value) else {
852            self.error_at(
853                "split-dict-array-arity",
854                "splitDictArray requires a field mapping and data array".to_string(),
855                span,
856            );
857            return Vec::new();
858        };
859        let Some(rows) = args.get(1).map(|arg| &arg.value) else {
860            self.error_at(
861                "split-dict-array-arity",
862                "splitDictArray requires a field mapping and data array".to_string(),
863                span,
864            );
865            return Vec::new();
866        };
867        let Expr::Dict {
868            entries: field_entries,
869            ..
870        } = fields
871        else {
872            self.error_at(
873                "split-dict-array-arguments",
874                "splitDictArray first argument must be a dictionary".to_string(),
875                fields.span(),
876            );
877            return Vec::new();
878        };
879        let Expr::Array { elements, .. } = rows else {
880            self.error_at(
881                "split-dict-array-arguments",
882                "splitDictArray second argument must be an array".to_string(),
883                rows.span(),
884            );
885            return Vec::new();
886        };
887
888        let mut result = Vec::with_capacity(field_entries.len());
889        for field in field_entries {
890            let Some(field_name) = expr_identifier(&field.key) else {
891                self.error_at(
892                    "split-dict-array-key",
893                    "splitDictArray field keys must be identifiers".to_string(),
894                    field.key.span(),
895                );
896                continue;
897            };
898            let target = self.lower_expr(&field.value, macro_params, CallPosition::Value);
899            let previous = self.allow_dict_literal;
900            self.allow_dict_literal = true;
901            let values = elements
902                .iter()
903                .map(|element| match element {
904                    Expr::Dict { entries, .. } => entries
905                        .iter()
906                        .find(|entry| expr_identifier(&entry.key) == Some(field_name))
907                        .map(|entry| {
908                            self.lower_expr(&entry.value, macro_params, CallPosition::Value)
909                        })
910                        .unwrap_or(HirExpr::Null { span: None }),
911                    _ => HirExpr::Null { span: None },
912                })
913                .collect();
914            self.allow_dict_literal = previous;
915            result.push(HirStmt::Assign {
916                target: Box::new(target),
917                value: Box::new(HirExpr::Array {
918                    elements: values,
919                    span: Some(span.into()),
920                }),
921                span: Some(span.into()),
922            });
923        }
924        result
925    }
926
927    fn lower_stmt(
928        &mut self,
929        stmt: &Stmt,
930        macro_params: &[String],
931        breakable: bool,
932        loopable: bool,
933    ) -> HirStmt {
934        match stmt {
935            Stmt::Expr { expr, span } => {
936                // A bare call of a declared subroutine becomes
937                // `CallSubroutine` (reference behavior).
938                if let Expr::Call { name, args, .. } = expr {
939                    if self.subroutine_visible(name) && args.is_empty() {
940                        return HirStmt::CallSubroutine {
941                            name: name.clone(),
942                            span: Some(span.into()),
943                        };
944                    }
945                }
946                // Statement-position builtin resolution (action/value
947                // identity, unknown names) happens inside `lower_expr`.
948                HirStmt::Expr {
949                    expr: Box::new(self.lower_expr(expr, macro_params, CallPosition::Statement)),
950                    span: Some(span.into()),
951                }
952            }
953            Stmt::Assign {
954                target,
955                value,
956                span,
957            } => {
958                if indexed_expr_depth(target) >= 4 {
959                    self.error_at(
960                        "four-dimensional-assignment",
961                        "Cannot assign to 4d array".to_string(),
962                        target.span(),
963                    );
964                }
965                HirStmt::Assign {
966                    target: Box::new(self.lower_expr(target, macro_params, CallPosition::Value)),
967                    value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)),
968                    span: Some(span.into()),
969                }
970            }
971            Stmt::If {
972                branches,
973                r#else,
974                span,
975            } => HirStmt::If {
976                branches: branches
977                    .iter()
978                    .map(|branch| IfBranch {
979                        condition: Box::new(self.lower_expr(
980                            &branch.condition,
981                            macro_params,
982                            CallPosition::Value,
983                        )),
984                        body: self.lower_block(
985                            &branch.body,
986                            macro_params,
987                            breakable,
988                            false,
989                            loopable,
990                        ),
991                    })
992                    .collect(),
993                r#else: r#else
994                    .as_ref()
995                    .map(|body| self.lower_block(body, macro_params, breakable, false, loopable)),
996                span: Some(span.into()),
997            },
998            Stmt::For {
999                variable,
1000                iterable,
1001                body,
1002                span,
1003            } => {
1004                // The reference accepts only `range(...)` as a `for ... in`
1005                // iterable; other iterables are an explicit frontend error
1006                // (recovered by lowering in value position).
1007                let iterable_position = if matches!(iterable, Expr::Call { name, .. } if name == "range")
1008                {
1009                    CallPosition::ForIterable
1010                } else {
1011                    self.error_at(
1012                        "invalid-iterable",
1013                        "for-loop iterable must be a range(...) call".to_string(),
1014                        iterable.span(),
1015                    );
1016                    CallPosition::Value
1017                };
1018                HirStmt::For {
1019                    variable: Box::new(self.lower_for_binder(variable, macro_params)),
1020                    iterable: Box::new(self.lower_expr(iterable, macro_params, iterable_position)),
1021                    body: self.lower_block(body, macro_params, true, false, true),
1022                    span: Some(span.into()),
1023                }
1024            }
1025            Stmt::While {
1026                condition,
1027                body,
1028                span,
1029            } => HirStmt::While {
1030                condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)),
1031                body: self.lower_block(body, macro_params, true, false, true),
1032                span: Some(span.into()),
1033            },
1034            Stmt::DoWhile {
1035                condition,
1036                body,
1037                span,
1038            } => HirStmt::DoWhile {
1039                condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)),
1040                body: self.lower_block(body, macro_params, true, true, true),
1041                span: Some(span.into()),
1042            },
1043            Stmt::Switch { value, arms, span } => HirStmt::Switch {
1044                value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)),
1045                arms: arms
1046                    .iter()
1047                    .map(|arm| match arm {
1048                        cst::SwitchArm::Case { value, body, span } => HirSwitchArm::Case {
1049                            value: Box::new(self.lower_expr(
1050                                value,
1051                                macro_params,
1052                                CallPosition::Value,
1053                            )),
1054                            body: self.lower_block(body, macro_params, true, false, loopable),
1055                            span: Some((*span).into()),
1056                        },
1057                        cst::SwitchArm::Default { body, span } => HirSwitchArm::Default {
1058                            body: self.lower_block(body, macro_params, true, false, loopable),
1059                            span: Some((*span).into()),
1060                        },
1061                    })
1062                    .collect(),
1063                span: Some(span.into()),
1064            },
1065            Stmt::Delete { target, span } => HirStmt::Delete {
1066                target: Box::new(self.lower_expr(target, macro_params, CallPosition::Value)),
1067                span: Some(span.into()),
1068            },
1069            Stmt::Break { span } => {
1070                if !breakable {
1071                    self.error_at(
1072                        "break-context",
1073                        "break is only valid inside a switch or loop".to_string(),
1074                        *span,
1075                    );
1076                }
1077                HirStmt::Break {
1078                    span: Some(span.into()),
1079                }
1080            }
1081            Stmt::Return { span } => HirStmt::Return {
1082                span: Some(span.into()),
1083            },
1084            Stmt::Continue { span } => {
1085                if !loopable {
1086                    self.error_at(
1087                        "continue-context",
1088                        "continue is only valid inside a loop".to_string(),
1089                        *span,
1090                    );
1091                }
1092                HirStmt::Continue {
1093                    span: Some(span.into()),
1094                }
1095            }
1096            Stmt::Goto {
1097                label,
1098                offset,
1099                rule_start,
1100                span,
1101            } => HirStmt::Goto {
1102                label: label.clone(),
1103                offset: offset.as_ref().map(|offset| {
1104                    Box::new(self.lower_expr(offset, macro_params, CallPosition::Value))
1105                }),
1106                rule_start: *rule_start,
1107                span: Some(span.into()),
1108            },
1109            Stmt::Label { name, span } => HirStmt::Label {
1110                name: name.clone(),
1111                span: Some(span.into()),
1112            },
1113            Stmt::Pass { span } => HirStmt::Pass {
1114                span: Some(span.into()),
1115            },
1116        }
1117    }
1118
1119    fn lower_for_binder(&mut self, variable: &Expr, macro_params: &[String]) -> HirExpr {
1120        if let Expr::Member {
1121            receiver,
1122            member,
1123            member_span,
1124            span,
1125        } = variable
1126        {
1127            if !default_var_index(member).is_some() && !self.player_visible(member) {
1128                self.error_at(
1129                    "unknown-identifier",
1130                    format!("unknown player variable '{member}'"),
1131                    *member_span,
1132                );
1133                return HirExpr::Null { span: None };
1134            }
1135            return HirExpr::PlayerVar {
1136                player: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1137                name: member.clone(),
1138                member_span: Some((*member_span).into()),
1139                span: Some((*span).into()),
1140            };
1141        }
1142        let lowered = self.lower_expr(variable, macro_params, CallPosition::Value);
1143        if !matches!(
1144            lowered,
1145            HirExpr::GlobalVar { .. } | HirExpr::PlayerVar { .. }
1146        ) {
1147            self.error_at(
1148                "invalid-range-binder",
1149                format!(
1150                    "Expected variable for 1st argument of function 'for', but got {}",
1151                    lowered.kind_name()
1152                ),
1153                variable.span(),
1154            );
1155        }
1156        lowered
1157    }
1158
1159    fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec<HirStmt> {
1160        body.iter()
1161            .map(|stmt| match stmt {
1162                Stmt::Expr { expr, span } => HirStmt::Expr {
1163                    expr: Box::new(self.lower_expr(expr, params, CallPosition::MacroBody)),
1164                    span: Some(span.into()),
1165                },
1166                _ => self.lower_stmt(stmt, params, false, false),
1167            })
1168            .collect()
1169    }
1170
1171    fn lower_expr(
1172        &mut self,
1173        expr: &Expr,
1174        macro_params: &[String],
1175        position: CallPosition,
1176    ) -> HirExpr {
1177        match expr {
1178            Expr::Number { value, text, span } => HirExpr::Number {
1179                value: *value,
1180                text: text.clone(),
1181                span: Some(span.into()),
1182            },
1183            Expr::String { value, span } => HirExpr::String {
1184                value: value.clone(),
1185                span: Some(span.into()),
1186            },
1187            Expr::Bool { value, span } => HirExpr::Bool {
1188                value: *value,
1189                span: Some(span.into()),
1190            },
1191            Expr::Null { span } => HirExpr::Null {
1192                span: Some(span.into()),
1193            },
1194            Expr::Array { elements, span } => HirExpr::Array {
1195                elements: elements
1196                    .iter()
1197                    .map(|element| self.lower_expr(element, macro_params, CallPosition::Value))
1198                    .collect(),
1199                span: Some(span.into()),
1200            },
1201            Expr::Dict { entries, span } => {
1202                if !self.allow_dict_literal {
1203                    self.error_at(
1204                        "dict-access",
1205                        "dictionary literals must be accessed by a key".to_string(),
1206                        *span,
1207                    );
1208                    return HirExpr::Null { span: None };
1209                }
1210                HirExpr::Dict {
1211                    entries: entries
1212                        .iter()
1213                        .map(|entry| HirDictEntry {
1214                            key: Box::new(self.lower_expr(
1215                                &entry.key,
1216                                macro_params,
1217                                CallPosition::Value,
1218                            )),
1219                            value: Box::new(self.lower_expr(
1220                                &entry.value,
1221                                macro_params,
1222                                CallPosition::Value,
1223                            )),
1224                            span: Some(entry.span.into()),
1225                        })
1226                        .collect(),
1227                    span: Some(span.into()),
1228                }
1229            }
1230            Expr::Comprehension {
1231                element,
1232                variable,
1233                variable_span,
1234                index,
1235                iterable,
1236                condition,
1237                span,
1238            } => {
1239                let iterable = self.lower_expr(iterable, macro_params, CallPosition::Value);
1240                let previous = std::mem::take(&mut self.locals);
1241                self.locals.push(variable.clone());
1242                if let Some((index, _)) = index {
1243                    self.locals.push(index.clone());
1244                }
1245                let element = self.lower_expr(element, macro_params, CallPosition::Value);
1246                let condition = condition.as_ref().map(|condition| {
1247                    Box::new(self.lower_expr(condition, macro_params, CallPosition::Value))
1248                });
1249                self.locals = previous;
1250                HirExpr::Comprehension {
1251                    element: Box::new(element),
1252                    variable: variable.clone(),
1253                    variable_span: Some(variable_span.into()),
1254                    index: index.as_ref().map(|(name, _)| name.clone()),
1255                    index_span: index.as_ref().map(|(_, span)| (*span).into()),
1256                    iterable: Box::new(iterable),
1257                    condition,
1258                    span: Some(span.into()),
1259                }
1260            }
1261            Expr::Lambda { params, body, span } => {
1262                if position != CallPosition::LambdaArgument {
1263                    self.error_at(
1264                        "lambda-context",
1265                        "lambda expressions are only valid as array operation arguments"
1266                            .to_string(),
1267                        *span,
1268                    );
1269                    return HirExpr::Null { span: None };
1270                }
1271                let previous = std::mem::take(&mut self.locals);
1272                self.locals = params.iter().map(|(name, _)| name.clone()).collect();
1273                let body = self.lower_expr(body, macro_params, CallPosition::Value);
1274                self.locals = previous;
1275                HirExpr::Lambda {
1276                    params: params.iter().map(|(name, _)| name.clone()).collect(),
1277                    param_spans: params
1278                        .iter()
1279                        .map(|(_, span)| Some((*span).into()))
1280                        .collect(),
1281                    body: Box::new(body),
1282                    span: Some(span.into()),
1283                }
1284            }
1285            Expr::StringModifier {
1286                modifier,
1287                value,
1288                format_text,
1289                interpolations,
1290                span,
1291            } => {
1292                if *modifier == 'f' {
1293                    if let Some(format_text) = format_text {
1294                        if !interpolations.is_empty() {
1295                            return HirExpr::Format {
1296                                text: format_text.clone(),
1297                                args: interpolations
1298                                    .iter()
1299                                    .map(|expr| {
1300                                        self.lower_expr(expr, macro_params, CallPosition::Value)
1301                                    })
1302                                    .collect(),
1303                                span: Some(span.into()),
1304                            };
1305                        }
1306                        return HirExpr::String {
1307                            value: format_text.clone(),
1308                            span: Some(span.into()),
1309                        };
1310                    }
1311                }
1312                HirExpr::StringModifier {
1313                    modifier: modifier.to_string(),
1314                    value: value.clone(),
1315                    span: Some(span.into()),
1316                }
1317            }
1318            Expr::Name { name, span } => self.lower_name(name, *span, macro_params),
1319            Expr::Type { name, args, span } => HirExpr::Type {
1320                name: name.clone(),
1321                args: args
1322                    .iter()
1323                    .map(|arg| self.lower_expr(arg, macro_params, CallPosition::Value))
1324                    .collect(),
1325                span: Some(span.into()),
1326            },
1327            Expr::Member {
1328                receiver,
1329                member,
1330                member_span,
1331                span,
1332            } => self.lower_member(receiver, member, *member_span, *span, macro_params),
1333            Expr::Index { array, index, span } => {
1334                let previous = self.allow_dict_literal;
1335                self.allow_dict_literal = true;
1336                let array = self.lower_expr(array, macro_params, CallPosition::Value);
1337                self.allow_dict_literal = previous;
1338                HirExpr::Index {
1339                    array: Box::new(array),
1340                    index: Box::new(self.lower_expr(index, macro_params, CallPosition::Value)),
1341                    span: Some(span.into()),
1342                }
1343            }
1344            Expr::Call { name, args, span } => {
1345                self.lower_call(name, args, *span, macro_params, position)
1346            }
1347            Expr::ReceiverCall {
1348                receiver,
1349                name,
1350                args,
1351                span,
1352            } => self.lower_receiver_call(receiver, name, args, *span, macro_params, position),
1353            Expr::Binary {
1354                op,
1355                left,
1356                right,
1357                span,
1358            } => HirExpr::Binary {
1359                op: op.clone(),
1360                left: Box::new(self.lower_expr(left, macro_params, CallPosition::Value)),
1361                right: Box::new(self.lower_expr(right, macro_params, CallPosition::Value)),
1362                span: Some(span.into()),
1363            },
1364            Expr::Conditional {
1365                then_value,
1366                condition,
1367                else_value,
1368                span,
1369            } => HirExpr::Conditional {
1370                then_value: Box::new(self.lower_expr(
1371                    then_value,
1372                    macro_params,
1373                    CallPosition::Value,
1374                )),
1375                condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)),
1376                else_value: Box::new(self.lower_expr(
1377                    else_value,
1378                    macro_params,
1379                    CallPosition::Value,
1380                )),
1381                span: Some((*span).into()),
1382            },
1383            Expr::Unary { op, operand, span } => HirExpr::Unary {
1384                op: op.clone(),
1385                operand: Box::new(self.lower_expr(operand, macro_params, CallPosition::Value)),
1386                span: Some(span.into()),
1387            },
1388        }
1389    }
1390
1391    fn lower_name(&mut self, name: &str, span: Span, macro_params: &[String]) -> HirExpr {
1392        if macro_params.iter().any(|param| param == name) {
1393            return HirExpr::MacroParam {
1394                name: name.to_string(),
1395                span: Some(span.into()),
1396            };
1397        }
1398        if self.locals.iter().any(|local| local == name) {
1399            return HirExpr::Local {
1400                name: name.to_string(),
1401                span: Some(span.into()),
1402            };
1403        }
1404        if let Some(player) = context_player_expr(name, Some(span)) {
1405            return player;
1406        }
1407        match name {
1408            "RULE_CONDITION" | "ruleCondition" => HirExpr::Call {
1409                name: "ruleCondition".to_string(),
1410                args: Vec::new(),
1411                span: Some(span.into()),
1412            },
1413            "eventAbility" | "eventDamage" | "eventHealing" | "eventWasCriticalHit" => {
1414                HirExpr::Call {
1415                    name: name.to_string(),
1416                    args: Vec::new(),
1417                    span: Some(span.into()),
1418                }
1419            }
1420            _ if self.global_visible(name) => HirExpr::GlobalVar {
1421                name: name.to_string(),
1422                span: Some(span.into()),
1423            },
1424            _ if self.player_visible(name) => HirExpr::PlayerVar {
1425                player: Box::new(HirExpr::EventPlayer { span: None }),
1426                name: name.to_string(),
1427                member_span: None,
1428                span: Some(span.into()),
1429            },
1430            _ if self.constant_visible(name) => HirExpr::Constant {
1431                name: name.to_string(),
1432                span: Some(span.into()),
1433            },
1434            _ if self.enum_visible(name) => {
1435                self.error_at(
1436                    "enum-type-without-member",
1437                    format!("enum type '{name}' must be used with a member (e.g. {name}.MEMBER)"),
1438                    span,
1439                );
1440                HirExpr::Null { span: None }
1441            }
1442            // OverPy default variable names (A–Z, AA–…, DX): implicit global
1443            // variables at fixed Workshop slots. The pinned reference accepts
1444            // these without a `globalvar` declaration anywhere a variable may
1445            // appear, including as a `for ... in range(...)` loop binder
1446            // (#114). Custom enums take precedence over default-var names,
1447            // matching the reference's identifier resolution order.
1448            _ if default_var_index(name).is_some() => HirExpr::GlobalVar {
1449                name: name.to_string(),
1450                span: Some(span.into()),
1451            },
1452            _ => {
1453                self.error_at(
1454                    "unknown-identifier",
1455                    format!("unknown identifier '{name}'"),
1456                    span,
1457                );
1458                HirExpr::Null { span: None }
1459            }
1460        }
1461    }
1462
1463    fn lower_member(
1464        &mut self,
1465        receiver: &Expr,
1466        member: &str,
1467        member_span: Span,
1468        span: Span,
1469        macro_params: &[String],
1470    ) -> HirExpr {
1471        if let Some(name) = self.qualified_macro_for_member(member) {
1472            return HirExpr::MacroCall {
1473                name,
1474                args: vec![self.lower_expr(receiver, macro_params, CallPosition::Value)],
1475                span: Some(span.into()),
1476            };
1477        }
1478        if let Expr::Name { name, .. } = receiver {
1479            // Custom enum member: folds to its numeric constant.
1480            if self.enum_visible(name) {
1481                let members = self.enums.get(name).expect("enum span and members agree");
1482                return match members.iter().position(|candidate| candidate == member) {
1483                    Some(index) => HirExpr::Number {
1484                        value: index as f64,
1485                        text: index.to_string(),
1486                        span: Some(span.into()),
1487                    },
1488                    None => {
1489                        self.error_at(
1490                            "unknown-enum-member",
1491                            format!("enum '{name}' has no member '{member}'"),
1492                            span,
1493                        );
1494                        HirExpr::Null { span: None }
1495                    }
1496                };
1497            }
1498            if name == "Math" {
1499                if let Some((value, text)) = match member {
1500                    "PI" => Some((std::f64::consts::PI, "3.141592653589793")),
1501                    "E" => Some((std::f64::consts::E, "2.718281828459045")),
1502                    "INFINITY" => Some((999_999_999_999.0, "999999999999")),
1503                    "EPSILON" => Some((1192093e-13, "0.0000001192093")),
1504                    _ => None,
1505                } {
1506                    return HirExpr::Number {
1507                        value,
1508                        text: text.to_string(),
1509                        span: Some(span.into()),
1510                    };
1511                }
1512            }
1513            // Builtin Workshop enum: the domain name is a declared OPY
1514            // signature identity (manifest `param.domain`); the member list
1515            // is Workshop-owned catalog content, so the member access
1516            // resolves as an opaque identity after validating the member
1517            // against the canonical Workshop catalog.
1518            let catalog_domain = match name.as_str() {
1519                "Clip" => "Clipping",
1520                "AsyncBehavior" => "StartRuleBehavior",
1521                _ => name.as_str(),
1522            };
1523            if self.manifest.domain_identity(name)
1524                || self.catalog.enum_domain(name).is_some()
1525                || self.catalog.enum_domain(catalog_domain).is_some()
1526                || (name == "Clip" && self.manifest.domain_identity(catalog_domain))
1527            {
1528                let locale = Locale::new("en-US");
1529                let catalog_member = match (name.as_str(), member) {
1530                    ("Map", "BLIZZ_WORLD") => "BLIZZARD_WORLD",
1531                    ("Map", "BLIZZ_WORLD_WINTER") => "BLIZZARD_WORLD_WINTER",
1532                    ("Map", "ROUTE66") => "ROUTE_66",
1533                    ("Map", "VOLSKAYA") => "VOLSKAYA_INDUSTRIES",
1534                    ("Clip", "NONE") => "DO_NOT_CLIP",
1535                    ("Clip", "SURFACES") => "CLIP_AGAINST_SURFACES",
1536                    ("SpecVisibility", "ALWAYS") => "VISIBLE_ALWAYS",
1537                    ("SpecVisibility", "NEVER") => "VISIBLE_NEVER",
1538                    ("EffectReeval", "VISIBILITY_POSITION_AND_RADIUS") => {
1539                        "VISIBLE_TO_POSITION_AND_RADIUS"
1540                    }
1541                    ("HudReeval", "VISIBILITY_AND_COLOR") => "VISIBLE_TO_AND_COLOR",
1542                    ("HudReeval", "VISIBILITY_STRING_AND_COLOR") => "VISIBLE_TO_STRING_AND_COLOR",
1543                    ("Hero", "MCCREE") => "CASSIDY",
1544                    ("Hero", "HAMMOND") => "WRECKING_BALL",
1545                    ("Hero", "SOLDIER") => "SOLDIER_76",
1546                    ("Hero", "DOMINA") => "JINYU",
1547                    ("Hero", "DMON") => "D_MON",
1548                    _ => member,
1549                };
1550                let canonical_member = self
1551                    .catalog
1552                    .enum_domain(catalog_domain)
1553                    .and_then(|domain| {
1554                        domain
1555                            .members
1556                            .iter()
1557                            .find(|candidate| candidate.member == catalog_member)
1558                            .map(|candidate| candidate.member.clone())
1559                            .or_else(|| {
1560                                (name == "Map").then(|| {
1561                                    let normalized = catalog_member.replace('_', "");
1562                                    domain
1563                                        .members
1564                                        .iter()
1565                                        .find(|candidate| {
1566                                            candidate.member.replace('_', "") == normalized
1567                                        })
1568                                        .map(|candidate| candidate.member.clone())
1569                                })?
1570                            })
1571                    })
1572                    .or_else(|| {
1573                        if name == "Team" && member.parse::<u32>().is_ok() {
1574                            self.catalog
1575                                .resolve_enum_member(name, &locale, &format!("{name} {member}"))
1576                                .map(|(_, member)| member)
1577                        } else {
1578                            None
1579                        }
1580                    })
1581                    .or_else(|| {
1582                        (name == "HudReeval" && member == "VISIBILITY_STRING_AND_COLOR")
1583                            .then_some(catalog_member.to_string())
1584                    });
1585                let Some(canonical_member) = canonical_member else {
1586                    self.error_at(
1587                        "unknown-enum-member",
1588                        format!("enum '{name}' has no member '{member}'"),
1589                        span,
1590                    );
1591                    return HirExpr::Null { span: None };
1592                };
1593                return HirExpr::Enum {
1594                    value_type: catalog_domain.to_string(),
1595                    value: canonical_member,
1596                    span: Some(span.into()),
1597                };
1598            }
1599            // Context-player member: a player-variable reference.
1600            if matches!(
1601                name.as_str(),
1602                "eventPlayer" | "hostPlayer" | "localPlayer" | "attacker" | "victim"
1603            ) {
1604                if !default_var_index(member).is_some() && !self.player_visible(member) {
1605                    self.error_at(
1606                        "unknown-member",
1607                        format!("unknown member '{member}'"),
1608                        member_span,
1609                    );
1610                    return HirExpr::Null { span: None };
1611                }
1612                let player = context_player_expr(
1613                    name,
1614                    (!matches!(name.as_str(), "eventPlayer" | "hostPlayer"))
1615                        .then_some(receiver.span()),
1616                )
1617                .expect("context-player receiver name is exhaustive");
1618                return HirExpr::PlayerVar {
1619                    player: Box::new(player),
1620                    name: member.to_string(),
1621                    member_span: Some(member_span.into()),
1622                    span: Some(span.into()),
1623                };
1624            }
1625            // A module member used without a call (`random.uniform` alone).
1626            if name == "random" {
1627                self.error_at(
1628                    "unsupported-member",
1629                    format!("module member '{name}.{member}' must be called"),
1630                    span,
1631                );
1632                return HirExpr::Null { span: None };
1633            }
1634            if self.player_visible(member) {
1635                return HirExpr::PlayerVar {
1636                    player: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1637                    name: member.to_string(),
1638                    member_span: Some(member_span.into()),
1639                    span: Some(span.into()),
1640                };
1641            }
1642            // A bare variable receiver member is valid OPY source syntax even
1643            // when canonical member existence is deferred to Workshop. Keep
1644            // both the resolved variable receiver and the source member
1645            // identity in HIR instead of treating it as an unknown member.
1646            if self.global_visible(name)
1647                || self.player_visible(name)
1648                || default_var_index(name).is_some()
1649            {
1650                let receiver = if default_var_index(name).is_some() {
1651                    HirExpr::GlobalVar {
1652                        name: name.to_string(),
1653                        span: Some(receiver.span().into()),
1654                    }
1655                } else {
1656                    self.lower_name(name, receiver.span(), &[])
1657                };
1658                return HirExpr::Member {
1659                    receiver: Box::new(receiver),
1660                    member: member.to_string(),
1661                    member_span: Some(member_span.into()),
1662                    span: Some(span.into()),
1663                };
1664            }
1665        }
1666        if self.player_visible(member) {
1667            return HirExpr::PlayerVar {
1668                player: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1669                name: member.to_string(),
1670                member_span: Some(member_span.into()),
1671                span: Some(span.into()),
1672            };
1673        }
1674        if matches!(member, "x" | "y" | "z") {
1675            return HirExpr::Member {
1676                receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1677                member: member.to_string(),
1678                member_span: Some(member_span.into()),
1679                span: Some(span.into()),
1680            };
1681        }
1682        self.error_at(
1683            "unsupported-member",
1684            "unsupported member access on this expression".to_string(),
1685            span,
1686        );
1687        HirExpr::Null { span: None }
1688    }
1689
1690    fn lower_call(
1691        &mut self,
1692        name: &str,
1693        args: &[cst::CallArg],
1694        span: Span,
1695        macro_params: &[String],
1696        position: CallPosition,
1697    ) -> HirExpr {
1698        if name == "createWorkshopSetting" {
1699            return self.lower_workshop_setting(args, span, macro_params);
1700        }
1701        if name == "compressed" && args.len() == 1 {
1702            return self.lower_expr(&args[0].value, macro_params, CallPosition::Value);
1703        }
1704        if matches!(
1705            name,
1706            "createWorkshopSettingBool"
1707                | "createWorkshopSettingEnum"
1708                | "createWorkshopSettingInt"
1709                | "createWorkshopSettingFloat"
1710        ) {
1711            return HirExpr::Call {
1712                name: name.to_string(),
1713                args: self.lower_arg_values(args, macro_params),
1714                span: Some(span.into()),
1715            };
1716        }
1717        // Builtin identity and position checks run before the special forms
1718        // so that a misplaced `wait`/`vect` still diagnoses its position.
1719        if !self.macro_visible(name) && !self.subroutine_visible(name) && name != "sorted" {
1720            match self.manifest.resolve_function(name) {
1721                Some(entry) => self.check_call_position(name, entry, position, span),
1722                None => {
1723                    let (code, message) = match position {
1724                        CallPosition::Statement => {
1725                            ("unknown-action", format!("unknown action '{name}'"))
1726                        }
1727                        CallPosition::Value => ("unknown-value", format!("unknown value '{name}'")),
1728                        CallPosition::ForIterable => (
1729                            "invalid-iterable",
1730                            format!("for-loop iterable '{name}' must be a range(...) call"),
1731                        ),
1732                        CallPosition::LambdaArgument => {
1733                            ("unknown-value", format!("unknown value '{name}'"))
1734                        }
1735                        CallPosition::MacroBody => {
1736                            ("unknown-value", format!("unknown value '{name}'"))
1737                        }
1738                    };
1739                    self.error_at(code, message, span);
1740                }
1741            }
1742        }
1743        match name {
1744            "sorted" => HirExpr::Call {
1745                name: name.to_string(),
1746                args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| {
1747                    index == 1 || arg.keyword.as_ref().is_some_and(|(name, _)| name == "key")
1748                }),
1749                span: Some(span.into()),
1750            },
1751            "vect" => {
1752                // `vect` goes through the generic argument binder so its
1753                // keyword forms (`vect(x=1, y=2, z=3)`) bind like any other
1754                // manifest signature; the result must fill exactly the three
1755                // declared parameters (x, y, z).
1756                let (bound, _) = match self.manifest.resolve_function(name) {
1757                    Some(entry) => self.bind_args(entry, args, macro_params),
1758                    None => (self.lower_arg_values(args, macro_params), None),
1759                };
1760                if bound.len() < 3 {
1761                    self.error_at(
1762                        "vect-arity",
1763                        format!(
1764                            "vect() expects 3 arguments (x, y, z) but got {}",
1765                            args.len()
1766                        ),
1767                        span,
1768                    );
1769                    return HirExpr::Null { span: None };
1770                }
1771                HirExpr::Vector {
1772                    x: Box::new(bound[0].clone()),
1773                    y: Box::new(bound[1].clone()),
1774                    z: Box::new(bound[2].clone()),
1775                    span: Some(span.into()),
1776                }
1777            }
1778            _ => {
1779                if self.macro_visible(name) {
1780                    // A declared `macro` invocation is recorded as a macroCall
1781                    // (positional-only; keyword arguments are an explicit
1782                    // diagnostic).
1783                    for arg in args {
1784                        if let Some((keyword, span)) = &arg.keyword {
1785                            self.error_at(
1786                                "keyword-unsupported",
1787                                format!(
1788                                    "macro '{name}' does not accept keyword \
1789                                     arguments ('{keyword}')"
1790                                ),
1791                                *span,
1792                            );
1793                        }
1794                    }
1795                    return HirExpr::MacroCall {
1796                        name: name.to_string(),
1797                        args: self.lower_arg_values(args, macro_params),
1798                        span: Some(span.into()),
1799                    };
1800                }
1801                if name == "async" {
1802                    let lowered = args
1803                        .iter()
1804                        .enumerate()
1805                        .map(|(index, arg)| {
1806                            if index == 0 && arg.keyword.is_none() {
1807                                if let cst::Expr::Name {
1808                                    name: subroutine,
1809                                    span,
1810                                } = &arg.value
1811                                {
1812                                    if self.subroutine_visible(subroutine) {
1813                                        return HirExpr::Call {
1814                                            name: subroutine.clone(),
1815                                            args: Vec::new(),
1816                                            span: Some((*span).into()),
1817                                        };
1818                                    }
1819                                }
1820                            }
1821                            self.lower_expr(&arg.value, macro_params, CallPosition::Value)
1822                        })
1823                        .collect();
1824                    return HirExpr::Call {
1825                        name: name.to_string(),
1826                        args: lowered,
1827                        span: Some(span.into()),
1828                    };
1829                }
1830                match self.manifest.resolve_function(name) {
1831                    Some(entry) => {
1832                        // Declared subroutines with arguments stay generic
1833                        // calls; builtins get keyword binding, arity, and
1834                        // domain/default handling.
1835                        if self.subroutine_visible(name) {
1836                            return HirExpr::Call {
1837                                name: name.to_string(),
1838                                args: self.lower_arg_values(args, macro_params),
1839                                span: Some(span.into()),
1840                            };
1841                        }
1842                        let (bound, selector) = self.bind_args(entry, args, macro_params);
1843                        let (call_name, bound) =
1844                            self.resolve_contextual_domain(entry, bound, selector.as_deref());
1845                        HirExpr::Call {
1846                            name: call_name,
1847                            args: bound,
1848                            span: Some(span.into()),
1849                        }
1850                    }
1851                    None => HirExpr::Call {
1852                        name: name.to_string(),
1853                        args: self.lower_arg_values(args, macro_params),
1854                        span: Some(span.into()),
1855                    },
1856                }
1857            }
1858        }
1859    }
1860
1861    fn lower_workshop_setting(
1862        &mut self,
1863        args: &[cst::CallArg],
1864        span: Span,
1865        macro_params: &[String],
1866    ) -> HirExpr {
1867        if !(4..=5).contains(&args.len()) {
1868            self.error_at(
1869                "invalid-arity",
1870                format!(
1871                    "function 'createWorkshopSetting' takes 4 or 5 arguments, received {}",
1872                    args.len()
1873                ),
1874                span,
1875            );
1876            return HirExpr::Null { span: None };
1877        }
1878        for arg in args {
1879            if let Some((keyword, keyword_span)) = &arg.keyword {
1880                self.error_at(
1881                    "keyword-unsupported",
1882                    format!(
1883                        "function 'createWorkshopSetting' does not accept keyword arguments ('{keyword}')"
1884                    ),
1885                    *keyword_span,
1886                );
1887            }
1888        }
1889
1890        let setting_type = match &args[0].value {
1891            Expr::Type {
1892                name,
1893                args: type_args,
1894                span: type_span,
1895            } => HirExpr::Type {
1896                name: name.clone(),
1897                args: type_args
1898                    .iter()
1899                    .map(|arg| self.lower_expr(arg, macro_params, CallPosition::Value))
1900                    .collect(),
1901                span: Some((*type_span).into()),
1902            },
1903            Expr::Name {
1904                name,
1905                span: type_span,
1906            } if matches!(name.as_str(), "bool" | "int" | "float") => HirExpr::Type {
1907                name: name.clone(),
1908                args: Vec::new(),
1909                span: Some((*type_span).into()),
1910            },
1911            other => {
1912                self.error_at(
1913                    "invalid-argument",
1914                    "argument 1 of 'createWorkshopSetting' must be a setting type".to_string(),
1915                    other.span(),
1916                );
1917                HirExpr::Null { span: None }
1918            }
1919        };
1920        let mut lowered = Vec::with_capacity(5);
1921        lowered.push(setting_type);
1922        lowered.extend(
1923            args[1..]
1924                .iter()
1925                .map(|arg| self.lower_expr(&arg.value, macro_params, CallPosition::Value)),
1926        );
1927        if args.len() == 4 {
1928            lowered.push(HirExpr::Number {
1929                value: 0.0,
1930                text: "0".to_string(),
1931                span: None,
1932            });
1933        }
1934        HirExpr::Call {
1935            name: "createWorkshopSetting".to_string(),
1936            args: lowered,
1937            span: Some(span.into()),
1938        }
1939    }
1940
1941    /// Lower call arguments to HIR values in source order (used for macro
1942    /// calls and unresolved names; keyword values lose their name).
1943    fn lower_arg_values(&mut self, args: &[cst::CallArg], macro_params: &[String]) -> Vec<HirExpr> {
1944        self.lower_arg_values_with_lambda(args, macro_params, |_, _| false)
1945    }
1946
1947    fn lower_arg_values_with_lambda(
1948        &mut self,
1949        args: &[cst::CallArg],
1950        macro_params: &[String],
1951        allows_lambda: impl Fn(usize, &cst::CallArg) -> bool,
1952    ) -> Vec<HirExpr> {
1953        args.iter()
1954            .enumerate()
1955            .map(|(index, arg)| {
1956                let position = if allows_lambda(index, arg) {
1957                    CallPosition::LambdaArgument
1958                } else {
1959                    CallPosition::Value
1960                };
1961                self.lower_expr(&arg.value, macro_params, position)
1962            })
1963            .collect()
1964    }
1965
1966    /// Bind positional and keyword arguments against a manifest signature
1967    /// (issue #110), producing lowered values in parameter order with
1968    /// declared defaults filled. Diagnostics are structured and
1969    /// source-located: `unknown-keyword`, `duplicate-argument`,
1970    /// `keyword-required`, `positional-after-keyword`, `missing-argument`,
1971    /// `keyword-unsupported`, `invalid-arity` (overflow), and
1972    /// `invalid-argument` (variable-required parameters).
1973    ///
1974    /// The returned `selector` is the keyword spelling used to bind the
1975    /// entry's contextual-domain selector parameter (the `chase` form's
1976    /// `rate`/`duration`), when the entry declares one.
1977    fn bind_args(
1978        &mut self,
1979        entry: &Function,
1980        args: &[cst::CallArg],
1981        macro_params: &[String],
1982    ) -> (Vec<HirExpr>, Option<String>) {
1983        let mut slots: Vec<Option<HirExpr>> = vec![None; entry.params.len()];
1984        let mut selector = None;
1985        let mut has_keyword = false;
1986        let mut binding_error = false;
1987        let contextual = entry.contextual_domain.as_ref();
1988
1989        // Keyword spellings resolve through the declared parameter names
1990        // (alternate spellings included) — generic binding, no per-spelling
1991        // branches.
1992        let mut by_spelling: HashMap<&str, usize> = HashMap::new();
1993        for (index, param) in entry.params.iter().enumerate() {
1994            by_spelling.insert(param.name.as_str(), index);
1995            for alternate in &param.alternate_names {
1996                by_spelling.insert(alternate.as_str(), index);
1997            }
1998        }
1999
2000        for (arg_index, arg) in args.iter().enumerate() {
2001            match &arg.keyword {
2002                Some((keyword, name_span)) => {
2003                    if !entry.keyword_args {
2004                        binding_error = true;
2005                        self.error_at(
2006                            "keyword-unsupported",
2007                            format!(
2008                                "function '{}' does not accept keyword arguments ('{keyword}')",
2009                                entry.id
2010                            ),
2011                            *name_span,
2012                        );
2013                        continue;
2014                    }
2015                    has_keyword = true;
2016                    match by_spelling.get(keyword.as_str()) {
2017                        None => {
2018                            binding_error = true;
2019                            self.error_at(
2020                                "unknown-keyword",
2021                                format!(
2022                                    "unknown keyword argument '{keyword}' for function '{}'",
2023                                    entry.id
2024                                ),
2025                                *name_span,
2026                            );
2027                        }
2028                        Some(&index) => {
2029                            let param = &entry.params[index];
2030                            if param.positional_only {
2031                                binding_error = true;
2032                                self.error_at(
2033                                    "unknown-keyword",
2034                                    format!(
2035                                        "parameter '{}' of '{}' cannot be bound by keyword",
2036                                        param.name, entry.id
2037                                    ),
2038                                    *name_span,
2039                                );
2040                            } else if slots[index].is_some() {
2041                                binding_error = true;
2042                                self.error_at(
2043                                    "duplicate-argument",
2044                                    format!(
2045                                        "argument '{}' of function '{}' is defined twice",
2046                                        keyword, entry.id
2047                                    ),
2048                                    *name_span,
2049                                );
2050                            } else {
2051                                slots[index] = Some(self.lower_call_arg_value(
2052                                    entry,
2053                                    index,
2054                                    arg,
2055                                    macro_params,
2056                                ));
2057                                if contextual.is_some_and(|c| c.by == param.name) {
2058                                    selector = Some(keyword.clone());
2059                                }
2060                            }
2061                        }
2062                    }
2063                }
2064                None => {
2065                    // The reference's generic binder rejects positional
2066                    // arguments after keyword arguments; its special forms
2067                    // (the contextual-domain entries, e.g. `chase`) bind the
2068                    // trailing positionals by slot and skip the ordering
2069                    // rule.
2070                    if has_keyword && entry.contextual_domain.is_none() {
2071                        binding_error = true;
2072                        self.error_at(
2073                            "positional-after-keyword",
2074                            format!(
2075                                "cannot use positional arguments after keyword \
2076                                 arguments in call to '{}'",
2077                                entry.id
2078                            ),
2079                            arg.value.span(),
2080                        );
2081                    }
2082                    // Positional arguments fill the slot at their argument
2083                    // index (keywords occupy their named slots), matching
2084                    // the reference binder.
2085                    let index = arg_index;
2086                    if index < entry.params.len() {
2087                        let param = &entry.params[index];
2088                        if param.keyword_only {
2089                            binding_error = true;
2090                            self.error_at(
2091                                "keyword-required",
2092                                format!(
2093                                    "argument {} of '{}' must be passed as a keyword \
2094                                     (name = value; accepted names: {})",
2095                                    index + 1,
2096                                    entry.id,
2097                                    keyword_spellings(param).join(", ")
2098                                ),
2099                                arg.value.span(),
2100                            );
2101                        }
2102                        if slots[index].is_none() {
2103                            slots[index] =
2104                                Some(self.lower_call_arg_value(entry, index, arg, macro_params));
2105                        }
2106                    } else {
2107                        self.lower_expr(&arg.value, macro_params, CallPosition::Value);
2108                    }
2109                }
2110            }
2111        }
2112
2113        // Positional overflow: the declared arity bounds report the
2114        // reference's "takes N arguments, received M" rejection. A binding
2115        // error already reported (duplicate keyword, unknown keyword, …)
2116        // suppresses the secondary arity noise, matching the reference's
2117        // first-error behavior.
2118        if !binding_error && args.len() > entry.params.len() {
2119            self.check_arity(entry, args.len(), arg_span(args));
2120        }
2121
2122        // Unbound parameters: declared defaults fill; required parameters
2123        // without a default are the reference's missing-argument rejection;
2124        // `optional` parameters stay omittable without an emitted expansion.
2125        let mut bound: Vec<HirExpr> = Vec::with_capacity(entry.params.len());
2126        for (index, param) in entry.params.iter().enumerate() {
2127            match &slots[index] {
2128                Some(value) => bound.push(value.clone()),
2129                None => match &param.default {
2130                    Some(ParamDefault::Call { call }) => {
2131                        bound.push(HirExpr::Call {
2132                            name: call.clone(),
2133                            args: Vec::new(),
2134                            span: None,
2135                        });
2136                    }
2137                    Some(ParamDefault::EnumMember(member)) => {
2138                        let domain = param.domain.clone().unwrap_or_default();
2139                        bound.push(HirExpr::Enum {
2140                            value_type: domain,
2141                            value: member.clone(),
2142                            span: None,
2143                        });
2144                    }
2145                    Some(ParamDefault::Bool(value)) => {
2146                        bound.push(HirExpr::Bool {
2147                            value: *value,
2148                            span: None,
2149                        });
2150                    }
2151                    Some(ParamDefault::Number(number)) => {
2152                        bound.push(HirExpr::Number {
2153                            value: *number,
2154                            text: format!("{number}"),
2155                            span: None,
2156                        });
2157                    }
2158                    None if param.optional => {
2159                        // Omitted entirely (the reference's short forms keep
2160                        // the argument list short, e.g. `range(3)`).
2161                    }
2162                    None => {
2163                        self.error_at(
2164                            "missing-argument",
2165                            format!(
2166                                "missing argument '{}' for function '{}'",
2167                                param.name, entry.id
2168                            ),
2169                            arg_span(args),
2170                        );
2171                        bound.push(HirExpr::Null { span: None });
2172                    }
2173                },
2174            }
2175        }
2176
2177        // Variable-required parameters (the chase family's first argument)
2178        // must resolve to a variable reference.
2179        for (index, param) in entry.params.iter().enumerate() {
2180            if !param.variable {
2181                continue;
2182            }
2183            if let Some(Some(value)) = slots.get(index) {
2184                if !matches!(value, HirExpr::GlobalVar { .. } | HirExpr::PlayerVar { .. }) {
2185                    self.error_at(
2186                        "invalid-argument",
2187                        format!(
2188                            "argument {} of '{}' must be a variable (globalvar or \
2189                             playervar)",
2190                            index + 1,
2191                            entry.id
2192                        ),
2193                        arg_span(args),
2194                    );
2195                }
2196            }
2197        }
2198
2199        (bound, selector)
2200    }
2201
2202    /// Lower one call argument's value; the contextual-domain parameter (the
2203    /// `chase` form's `ChaseReeval` member) is recorded as a pending enum
2204    /// without validating the domain — it resolves only against the concrete
2205    /// domain selected by the call's keyword selector (issue #110). Outside
2206    /// that signature context `ChaseReeval` never resolves because it is not
2207    /// a declared enum domain.
2208    fn lower_call_arg_value(
2209        &mut self,
2210        entry: &Function,
2211        param_index: usize,
2212        arg: &cst::CallArg,
2213        macro_params: &[String],
2214    ) -> HirExpr {
2215        if let Some(contextual) = &entry.contextual_domain {
2216            let is_contextual = entry.params[param_index]
2217                .domain
2218                .as_deref()
2219                .is_some_and(|domain| domain == contextual.domain);
2220            if is_contextual {
2221                if let Expr::Member {
2222                    receiver,
2223                    member,
2224                    span,
2225                    ..
2226                } = &arg.value
2227                {
2228                    if let Expr::Name { name, .. } = receiver.as_ref() {
2229                        if name == &contextual.domain {
2230                            return HirExpr::Enum {
2231                                value_type: contextual.domain.clone(),
2232                                value: member.clone(),
2233                                span: Some((*span).into()),
2234                            };
2235                        }
2236                    }
2237                }
2238            }
2239        }
2240        self.lower_expr(&arg.value, macro_params, CallPosition::Value)
2241    }
2242
2243    /// Resolve a contextual enum-domain parameter (the `chase` form's
2244    /// `ChaseReeval` member, issue #110): the keyword spelling bound to the
2245    /// selector parameter selects the concrete domain and the function the
2246    /// call lowers to. This is pure catalog-identity dispatch — member
2247    /// *existence* in the selected domain is Workshop-owned knowledge and is
2248    /// not validated here (lowering-dependent, issue #8). Outside this
2249    /// signature context `ChaseReeval` never resolves (it is not a standalone
2250    /// domain identity). A call that does not bind a contextual member keeps
2251    /// its declared name and arguments without a domain diagnostic.
2252    fn resolve_contextual_domain(
2253        &mut self,
2254        entry: &Function,
2255        mut bound: Vec<HirExpr>,
2256        selector: Option<&str>,
2257    ) -> (String, Vec<HirExpr>) {
2258        let Some(contextual) = &entry.contextual_domain else {
2259            return (entry.id.clone(), bound);
2260        };
2261        let Some(contextual_param) = entry
2262            .params
2263            .iter()
2264            .position(|param| param.domain.as_deref() == Some(contextual.domain.as_str()))
2265        else {
2266            return (entry.id.clone(), bound);
2267        };
2268        // Dispatch only when the argument is written as a member of the
2269        // contextual domain; anything else keeps the generic call (the
2270        // reference's "expected an enum" rejection is lowering-dependent).
2271        let HirExpr::Enum {
2272            value_type,
2273            value,
2274            span: value_span,
2275        } = &bound[contextual_param]
2276        else {
2277            return (entry.id.clone(), bound);
2278        };
2279        if value_type != &contextual.domain {
2280            return (entry.id.clone(), bound);
2281        }
2282        let Some(keyword) = selector else {
2283            return (entry.id.clone(), bound);
2284        };
2285        let Some(option) = contextual.options.get(keyword) else {
2286            return (entry.id.clone(), bound);
2287        };
2288        bound[contextual_param] = HirExpr::Enum {
2289            value_type: option.domain.clone(),
2290            value: value.clone(),
2291            span: *value_span,
2292        };
2293        (option.target.clone(), bound)
2294    }
2295
2296    fn lower_receiver_call(
2297        &mut self,
2298        receiver: &Expr,
2299        name: &str,
2300        args: &[cst::CallArg],
2301        span: Span,
2302        macro_params: &[String],
2303        position: CallPosition,
2304    ) -> HirExpr {
2305        if let Some(macro_name) = self.qualified_macro_for_member(name) {
2306            return HirExpr::MacroCall {
2307                name: macro_name,
2308                args: std::iter::once(self.lower_expr(receiver, macro_params, CallPosition::Value))
2309                    .chain(self.lower_arg_values(args, macro_params))
2310                    .collect(),
2311                span: Some(span.into()),
2312            };
2313        }
2314        if matches!(name, "map" | "filter" | "all" | "any") {
2315            let lowered = HirExpr::ReceiverCall {
2316                receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
2317                name: name.to_string(),
2318                args: self.lower_arg_values_with_lambda(args, macro_params, |index, _| index == 0),
2319                span: Some(span.into()),
2320            };
2321            return lowered;
2322        }
2323        // `random.uniform(...)` etc. are dotted generic calls.
2324        if let Expr::Name { name: root, .. } = receiver {
2325            if root == "random" {
2326                return self.lower_call(
2327                    &format!("random.{name}"),
2328                    args,
2329                    span,
2330                    macro_params,
2331                    position,
2332                );
2333            }
2334        }
2335        // `.format` on a string literal is the format special form; it is
2336        // also a declared member value (receiver category `String`), so
2337        // position misuse diagnoses here.
2338        if let Expr::String { value, .. } = receiver {
2339            if name == "format" {
2340                if args.iter().any(|arg| arg.keyword.is_some()) {
2341                    for arg in args {
2342                        if let Some((keyword, span)) = &arg.keyword {
2343                            self.error_at(
2344                                "keyword-unsupported",
2345                                format!(
2346                                    "function 'format' does not accept keyword \
2347                                     arguments ('{keyword}')"
2348                                ),
2349                                *span,
2350                            );
2351                        }
2352                    }
2353                }
2354                let lowered: Vec<HirExpr> = self.lower_arg_values(args, macro_params);
2355                if let Some(entry) = self.manifest.resolve_member("format") {
2356                    self.check_call_position("format", entry, position, span);
2357                }
2358                return HirExpr::Format {
2359                    text: value.clone(),
2360                    args: lowered,
2361                    span: Some(span.into()),
2362                };
2363            }
2364        }
2365        // Member calls resolve through the manifest (receiver category,
2366        // explicit-argument signatures, keyword binding).
2367        let (member_name, lowered) = match self.manifest.resolve_member(name) {
2368            Some(entry) => {
2369                self.check_call_position(name, entry, position, span);
2370                if let Some(category) = entry.receiver {
2371                    self.check_receiver(receiver, category, entry, span);
2372                }
2373                let (bound, _) = self.bind_args(entry, args, macro_params);
2374                (entry.id.clone(), bound)
2375            }
2376            None => {
2377                self.error_at("unknown-member", format!("unknown member '{name}'"), span);
2378                (name.to_string(), self.lower_arg_values(args, macro_params))
2379            }
2380        };
2381        // `eventPlayer.member(...)` → receiver call on the event player.
2382        if let Expr::Name { name: root, .. } = receiver {
2383            if matches!(
2384                root.as_str(),
2385                "eventPlayer" | "hostPlayer" | "localPlayer" | "attacker" | "victim"
2386            ) {
2387                return HirExpr::ReceiverCall {
2388                    receiver: Box::new(
2389                        context_player_expr(
2390                            root,
2391                            (!matches!(root.as_str(), "eventPlayer" | "hostPlayer"))
2392                                .then_some(receiver.span()),
2393                        )
2394                        .expect("context-player receiver name is exhaustive"),
2395                    ),
2396                    name: member_name,
2397                    args: lowered,
2398                    span: Some(span.into()),
2399                };
2400            }
2401        }
2402        // Any other receiver: resolve it and keep the receiver call.
2403        HirExpr::ReceiverCall {
2404            receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
2405            name: member_name,
2406            args: lowered,
2407            span: Some(span.into()),
2408        }
2409    }
2410
2411    fn qualified_macro_for_member(&self, member: &str) -> Option<String> {
2412        let suffix = format!(".{member}");
2413        self.macro_declarations
2414            .iter()
2415            .filter(|(name, order)| name.ends_with(&suffix) && **order <= self.current_order)
2416            .map(|(name, _)| name.clone())
2417            .next()
2418    }
2419
2420    /// Check a builtin entry against its call position: action/value
2421    /// identity and for-iterable context.
2422    fn check_call_position(
2423        &mut self,
2424        name: &str,
2425        entry: &Function,
2426        position: CallPosition,
2427        span: Span,
2428    ) {
2429        match position {
2430            CallPosition::Statement => {
2431                if entry.context == Some(FunctionContext::ForIterable) {
2432                    self.error_at(
2433                        "invalid-call-context",
2434                        format!("'{name}' is only valid as a for-loop iterable"),
2435                        span,
2436                    );
2437                } else if entry.kind.is_value() {
2438                    self.error_at(
2439                        "value-in-action-position",
2440                        format!("value function '{name}' cannot be used as an action"),
2441                        span,
2442                    );
2443                }
2444            }
2445            CallPosition::Value => {
2446                if entry.kind.is_action() {
2447                    self.error_at(
2448                        "action-in-value-position",
2449                        format!("action function '{name}' cannot be used as a value"),
2450                        span,
2451                    );
2452                } else if entry.context == Some(FunctionContext::ForIterable) {
2453                    self.error_at(
2454                        "invalid-call-context",
2455                        format!("'{name}' is only valid as a for-loop iterable"),
2456                        span,
2457                    );
2458                }
2459            }
2460            CallPosition::ForIterable => {
2461                if entry.context != Some(FunctionContext::ForIterable) {
2462                    self.error_at(
2463                        "invalid-iterable",
2464                        format!("for-loop iterable '{name}' must be a range(...) call"),
2465                        span,
2466                    );
2467                }
2468            }
2469            CallPosition::LambdaArgument => {
2470                if entry.kind.is_action() {
2471                    self.error_at(
2472                        "action-in-value-position",
2473                        format!("action function '{name}' cannot be used as a value"),
2474                        span,
2475                    );
2476                } else if entry.context == Some(FunctionContext::ForIterable) {
2477                    self.error_at(
2478                        "invalid-call-context",
2479                        format!("'{name}' is only valid as a for-loop iterable"),
2480                        span,
2481                    );
2482                }
2483            }
2484            CallPosition::MacroBody => {}
2485        }
2486    }
2487
2488    /// Check a member call's receiver against its declared category. Only
2489    /// the reference-enforced categories reject: `.append` requires an
2490    /// assignable receiver and `.format` a string literal; player-oriented
2491    /// members accept any receiver (the pinned reference does not type-check
2492    /// them).
2493    fn check_receiver(
2494        &mut self,
2495        receiver: &Expr,
2496        category: ReceiverCategory,
2497        entry: &Function,
2498        span: Span,
2499    ) {
2500        let mismatch = match category {
2501            ReceiverCategory::String => {
2502                !matches!(receiver, Expr::String { .. } | Expr::StringModifier { .. })
2503            }
2504            ReceiverCategory::Variable => !assignable_receiver(receiver),
2505            ReceiverCategory::Player | ReceiverCategory::Vector | ReceiverCategory::Any => false,
2506        };
2507        if mismatch {
2508            self.error_at(
2509                "invalid-receiver",
2510                format!(
2511                    "member '{}' requires {} as its receiver",
2512                    entry.id,
2513                    category.describe()
2514                ),
2515                span,
2516            );
2517        }
2518    }
2519
2520    /// Check a builtin call's argument count against its declared arity.
2521    fn check_arity(&mut self, entry: &Function, got: usize, span: Span) {
2522        let (min, max) = entry.arity_bounds();
2523        let valid = got >= min && max.is_none_or(|max| got <= max);
2524        if !valid {
2525            let expects = match max {
2526                Some(max) if min == max => format!("exactly {min}"),
2527                Some(max) => format!("{min} to {max}"),
2528                None => format!("at least {min}"),
2529            };
2530            let role = match entry.kind {
2531                FunctionKind::Action => "action",
2532                FunctionKind::Value => "value",
2533                FunctionKind::MemberAction => "member action",
2534                FunctionKind::MemberValue => "member value",
2535            };
2536            self.error_at(
2537                "invalid-arity",
2538                format!(
2539                    "{role} '{}' expects {expects} arguments but got {got}",
2540                    entry.id
2541                ),
2542                span,
2543            );
2544        }
2545    }
2546
2547    fn error_at(&mut self, code: &str, message: String, span: Span) {
2548        self.errors.push(OpyError::at(code, message, span));
2549    }
2550}
2551
2552/// The keyword spellings a parameter accepts (its name plus alternates).
2553fn keyword_spellings(param: &Param) -> Vec<String> {
2554    let mut spellings = vec![param.name.clone()];
2555    spellings.extend(param.alternate_names.iter().cloned());
2556    spellings
2557}
2558
2559/// The source span covering a call's argument list (the start of the first
2560/// argument through the last argument).
2561fn arg_span(args: &[CallArg]) -> Span {
2562    args.first().map(CallArg::span).unwrap_or_else(|| {
2563        Span::new(
2564            0,
2565            crate::diag::Position::new(1, 1),
2566            crate::diag::Position::new(1, 1),
2567        )
2568    })
2569}
2570
2571/// Lower a source-level player context value without adding a new HIR node
2572/// kind. `attacker` and `victim` are catalog-backed zero-argument values;
2573/// using the existing `Call` node keeps their source identity and span while
2574/// leaving canonical value ownership with Workshop.
2575fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
2576    match name {
2577        "eventPlayer" => Some(HirExpr::EventPlayer {
2578            span: span.map(Into::into),
2579        }),
2580        "localPlayer" => Some(HirExpr::Call {
2581            name: name.to_string(),
2582            args: Vec::new(),
2583            span: span.map(Into::into),
2584        }),
2585        "hostPlayer" => Some(HirExpr::HostPlayer {
2586            span: span.map(Into::into),
2587        }),
2588        "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
2589            name: name.to_string(),
2590            args: Vec::new(),
2591            span: span.map(Into::into),
2592        }),
2593        _ => None,
2594    }
2595}
2596
2597/// Whether a CST receiver is assignable (the `.append` receiver rule): a
2598/// variable name (including macro parameters), an array literal, or an index
2599/// expression — matching the pinned reference, which rejects constant and
2600/// function receivers ("Cannot modify or assign to …").
2601fn assignable_receiver(receiver: &Expr) -> bool {
2602    match receiver {
2603        Expr::Name { name, .. } => !matches!(
2604            name.as_str(),
2605            "eventPlayer" | "hostPlayer" | "attacker" | "victim"
2606        ),
2607        Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
2608        _ => false,
2609    }
2610}
2611
2612fn expr_identifier(expr: &Expr) -> Option<&str> {
2613    match expr {
2614        Expr::Name { name, .. } => Some(name.as_str()),
2615        _ => None,
2616    }
2617}
2618
2619fn indexed_expr_depth(expr: &Expr) -> usize {
2620    match expr {
2621        Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
2622        _ => 0,
2623    }
2624}
2625
2626impl From<Span> for HirSpan {
2627    fn from(span: Span) -> HirSpan {
2628        HirSpan {
2629            file: span.file,
2630            start: Position {
2631                line: span.start.line,
2632                col: span.start.col,
2633            },
2634            end: Position {
2635                line: span.end.line,
2636                col: span.end.col,
2637            },
2638        }
2639    }
2640}
2641
2642impl From<&Span> for HirSpan {
2643    fn from(span: &Span) -> HirSpan {
2644        (*span).into()
2645    }
2646}
2647
2648#[cfg(test)]
2649mod tests {
2650    use super::*;
2651    use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
2652    use crate::lexer::{LexInput, lex};
2653    use crate::parser::parse;
2654
2655    fn lower_ok(text: &str) -> HirProgram {
2656        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
2657        let output = parse(&tokens);
2658        assert!(
2659            output.errors.is_empty(),
2660            "unexpected parse errors: {:?}",
2661            output.errors
2662        );
2663        let program = output.program.expect("parse produces a program");
2664        lower(&program, vec![], vec![]).expect("lowers without errors")
2665    }
2666
2667    fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
2668        let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
2669            panic!("expected a rule");
2670        };
2671        (&rule.conditions, &rule.actions)
2672    }
2673
2674    #[test]
2675    fn producer_emits_the_v2_ordered_switch_contract() {
2676        let hir = lower_ok(
2677            "globalvar value\nrule \"r\":\n    @Event global\n    switch value:\n        default:\n            value = 1\n        case 2:\n            value = 2\n",
2678        );
2679        assert_eq!(hir.protocol.name, "wright/opy-hir");
2680        assert_eq!(hir.protocol.version, "2.0.0");
2681        let value = serde_json::to_value(&hir).expect("HIR must serialize");
2682        let switch = &value["rules"][0]["actions"][0];
2683        assert!(switch.get("arms").is_some());
2684        assert!(switch.get("cases").is_none());
2685        assert!(switch.get("default").is_none());
2686    }
2687
2688    #[test]
2689    fn receiver_calls_lower_to_receiver_call_hir() {
2690        // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the
2691        // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a
2692        // global-variable receiver (#104).
2693        let hir = lower_ok(
2694            "globalvar target\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n    target.setMoveSpeed(50)\n",
2695        );
2696        let (_, actions) = rule_conditions_and_actions(&hir);
2697        assert_eq!(actions.len(), 2);
2698
2699        let HirStmt::Expr { expr, .. } = &actions[0] else {
2700            panic!("expected expression statement");
2701        };
2702        let HirExpr::ReceiverCall {
2703            receiver,
2704            name,
2705            args,
2706            ..
2707        } = expr.as_ref()
2708        else {
2709            panic!("expected receiver call, got {expr:?}");
2710        };
2711        assert_eq!(name, "setMoveSpeed");
2712        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
2713        assert_eq!(args.len(), 1);
2714        assert!(matches!(&args[0], HirExpr::Number { .. }));
2715
2716        let HirStmt::Expr { expr, .. } = &actions[1] else {
2717            panic!("expected expression statement");
2718        };
2719        let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
2720            panic!("expected receiver call, got {expr:?}");
2721        };
2722        assert_eq!(name, "setMoveSpeed");
2723        assert!(
2724            matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
2725            "globalvar receiver must resolve to a GlobalVar"
2726        );
2727    }
2728
2729    #[test]
2730    fn bare_variable_member_expression_preserves_receiver_and_member() {
2731        let hir = lower_ok(
2732            "globalvar A\nplayervar B\nrule \"receiver\":\n    @Event eachPlayer\n    A = B.C\n",
2733        );
2734        let HirStmt::Assign { value, .. } = &hir
2735            .rules
2736            .iter()
2737            .find_map(|entry| {
2738                let RuleEntry::Rule(rule) = entry else {
2739                    return None;
2740                };
2741                rule.actions.first()
2742            })
2743            .expect("assignment")
2744        else {
2745            panic!("expected assignment");
2746        };
2747        let HirExpr::Member {
2748            receiver, member, ..
2749        } = value.as_ref()
2750        else {
2751            panic!("expected opaque member expression, got {value:?}");
2752        };
2753        assert_eq!(member, "C");
2754        assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
2755    }
2756
2757    #[test]
2758    fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
2759        let text = "rule \"before\":\n    pass\ndef source_name():\n    @Name \"Friendly\"\n    pass\nrule \"after\":\n    pass\n";
2760        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
2761        let output = parse(&tokens);
2762        assert!(
2763            output.errors.is_empty(),
2764            "unexpected parse errors: {:?}",
2765            output.errors
2766        );
2767        let program = output.program.expect("program");
2768        let preprocessing = PreprocessingState {
2769            rule_prefix_template: Some(crate::hir::types::DirectiveValue {
2770                value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
2771                span: None,
2772            }),
2773            ..PreprocessingState::default()
2774        };
2775        let hir = lower_with_preprocessing(
2776            &program,
2777            vec![SourceFile {
2778                id: 0,
2779                path: "main.opy".to_string(),
2780            }],
2781            vec![],
2782            &preprocessing,
2783        )
2784        .expect("lowers");
2785        let names: Vec<_> = hir
2786            .rules
2787            .iter()
2788            .map(|entry| match entry {
2789                HirRuleEntry::Rule(rule) => rule.name.clone(),
2790                HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
2791            })
2792            .collect();
2793        assert_eq!(
2794            names,
2795            vec!["[Main] before", "[Main] Friendly", "[Main] after"]
2796        );
2797        let HirRuleEntry::SubroutineDef {
2798            name, source_name, ..
2799        } = &hir.rules[1]
2800        else {
2801            panic!("expected subroutine definition");
2802        };
2803        assert_eq!(name, "[Main] Friendly");
2804        assert_eq!(source_name, "source_name");
2805    }
2806
2807    #[test]
2808    fn receiver_call_values_lower_in_conditions() {
2809        // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value;
2810        // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver
2811        // call inside another receiver call's arguments (#104).
2812        let hir = lower_ok(
2813            "rule \"r\":\n    @Event eachPlayer\n    @Condition eventPlayer.isAlive()\n    eventPlayer.teleport(eventPlayer.getPosition())\n",
2814        );
2815        let (conditions, actions) = rule_conditions_and_actions(&hir);
2816        assert_eq!(conditions.len(), 1);
2817        let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
2818            panic!("expected receiver call condition, got {:?}", conditions[0]);
2819        };
2820        assert_eq!(name, "isAlive");
2821        assert_eq!(args.len(), 0);
2822
2823        let HirStmt::Expr { expr, .. } = &actions[0] else {
2824            panic!("expected expression statement");
2825        };
2826        let HirExpr::ReceiverCall {
2827            name,
2828            args,
2829            receiver,
2830            ..
2831        } = expr.as_ref()
2832        else {
2833            panic!("expected receiver call, got {expr:?}");
2834        };
2835        assert_eq!(name, "teleport");
2836        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
2837        assert_eq!(args.len(), 1);
2838        assert!(matches!(
2839            &args[0],
2840            HirExpr::ReceiverCall { name, .. } if name == "getPosition"
2841        ));
2842    }
2843
2844    #[test]
2845    fn format_string_receiver_stays_a_format_node() {
2846        // `.format()` on a string receiver is unaffected by the receiver-call
2847        // path (existing supported form).
2848        let hir = lower_ok(
2849            "rule \"r\":\n    @Event global\n    print(\"{} points\".format(len([1, 2])))\n",
2850        );
2851        let (_, actions) = rule_conditions_and_actions(&hir);
2852        let HirStmt::Expr { expr, .. } = &actions[0] else {
2853            panic!("expected expression statement");
2854        };
2855        assert!(
2856            has_format(expr),
2857            "string `.format()` must lower to a Format node"
2858        );
2859    }
2860
2861    fn has_format(expr: &HirExpr) -> bool {
2862        match expr {
2863            HirExpr::Format { .. } => true,
2864            HirExpr::Call { args, .. } => args.iter().any(has_format),
2865            HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
2866            _ => false,
2867        }
2868    }
2869
2870    /// Lower one rule action and return the assignment's value expression.
2871    fn lowered_value(source: &str) -> HirExpr {
2872        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
2873            .unwrap_or_else(|error| panic!("compile failed: {error}"));
2874        let RuleEntry::Rule(rule) = &program.rules[0] else {
2875            panic!("expected a rule");
2876        };
2877        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
2878            panic!("expected an assign statement");
2879        };
2880        (**value).clone()
2881    }
2882
2883    #[test]
2884    fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
2885        let value = lowered_value(
2886            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NONE\n",
2887        );
2888        assert_enum(&value, "ChaseTimeReeval", "NONE");
2889    }
2890
2891    #[test]
2892    fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
2893        let value = lowered_value(
2894            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
2895        );
2896        assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
2897    }
2898
2899    #[test]
2900    fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
2901        for member in ["NONE", "DESTINATION_AND_RATE"] {
2902            let source = format!(
2903                "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseRateReeval.{member}\n"
2904            );
2905            assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
2906        }
2907    }
2908
2909    /// Assert the expression is the catalog enum `(domain, member)` node,
2910    /// ignoring its source span (the span is frontend-internal provenance).
2911    fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
2912        match value {
2913            HirExpr::Enum {
2914                value_type, value, ..
2915            } => {
2916                assert_eq!(value_type, domain);
2917                assert_eq!(value, member);
2918            }
2919            other => panic!("expected enum {domain}.{member}, got {other:?}"),
2920        }
2921    }
2922
2923    #[test]
2924    fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
2925        let error = crate::compile(
2926            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NOPE\n",
2927            "test.opy",
2928            std::path::Path::new(""),
2929        )
2930        .expect_err("unknown catalog member must be rejected");
2931        assert_eq!(error.code, "unknown-enum-member");
2932    }
2933
2934    #[test]
2935    fn unknown_enum_receiver_is_an_unsupported_member_error() {
2936        let error = crate::compile(
2937            "globalvar g\nrule \"r\":\n    @Event global\n    g = NotARealEnum.MEMBER\n",
2938            "test.opy",
2939            std::path::Path::new(""),
2940        )
2941        .expect_err("an unknown enum type must fail");
2942        assert_eq!(error.code, "unsupported-member");
2943        let span = error.span.expect("the error is source-located");
2944        assert_eq!(span.start.line, 4);
2945    }
2946
2947    // --- Builtin semantic manifest coverage (#109) ---
2948
2949    /// Assert a compile failure has the given code at the given line.
2950    fn compile_error(source: &str, line: u32) -> OpyError {
2951        let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2952            .expect_err("expected a compile failure");
2953        let span = error.span.expect("the error is source-located");
2954        assert_eq!(span.start.line, line, "code '{}'", error.code);
2955        error
2956    }
2957
2958    fn action_source(statement: &str) -> String {
2959        format!("globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n")
2960    }
2961
2962    #[test]
2963    fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
2964        // 4-argument form with an explicit reevaluation member (#106).
2965        let hir = crate::compile(
2966            &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
2967            "test.opy",
2968            std::path::Path::new(""),
2969        )
2970        .expect("reference-supported chaseOverTime compiles");
2971        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2972            panic!("expected a rule");
2973        };
2974        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2975            panic!("expected expression statement");
2976        };
2977        let HirExpr::Call { name, args, .. } = expr.as_ref() else {
2978            panic!("expected a call, got {expr:?}");
2979        };
2980        assert_eq!(name, "chaseOverTime");
2981        assert_eq!(args.len(), 4);
2982        assert!(matches!(
2983            &args[3],
2984            HirExpr::Enum { value_type, value, .. }
2985                if value_type == "ChaseTimeReeval" && value == "NONE"
2986        ));
2987
2988        // 3-argument form fills the reference default member.
2989        let hir = crate::compile(
2990            &action_source("chaseOverTime(g, 10, 3)"),
2991            "test.opy",
2992            std::path::Path::new(""),
2993        )
2994        .expect("default-reevaluation chaseOverTime compiles");
2995        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2996            panic!("expected a rule");
2997        };
2998        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2999            panic!("expected expression statement");
3000        };
3001        let HirExpr::Call { args, .. } = expr.as_ref() else {
3002            panic!("expected a call");
3003        };
3004        assert_eq!(args.len(), 4);
3005        assert!(matches!(
3006            &args[3],
3007            HirExpr::Enum { value_type, value, .. }
3008                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
3009        ));
3010    }
3011
3012    #[test]
3013    fn is_game_in_progress_resolves_as_a_builtin_value() {
3014        // Generic value gap from #106: `isGameInProgress()` in a condition.
3015        let hir = crate::compile(
3016            &action_source("@Condition isGameInProgress() == true"),
3017            "test.opy",
3018            std::path::Path::new(""),
3019        )
3020        .expect("reference-supported isGameInProgress compiles");
3021        let RuleEntry::Rule(rule) = &hir.rules[0] else {
3022            panic!("expected a rule");
3023        };
3024        assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
3025    }
3026
3027    #[test]
3028    fn enum_gated_members_resolve_through_the_manifest() {
3029        // Enum-gated members from #106: setInvisibility (Invis), getThrottle
3030        // (member value), worldVector (Transform arg), setStatusEffect
3031        // (Status arg).
3032        let source = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
3033            @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n    \
3034            @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n    \
3035            eventPlayer.setInvisibility(Invis.ALL)\n    \
3036            eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
3037        let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
3038            .expect("enum-gated members compile");
3039        let RuleEntry::Rule(rule) = &hir.rules[0] else {
3040            panic!("expected a rule");
3041        };
3042        assert_eq!(rule.actions.len(), 2);
3043    }
3044
3045    #[test]
3046    fn get_players_in_radius_fills_reference_enum_defaults() {
3047        // 2-argument form fills Team.ALL and LosCheck.OFF (reference
3048        // emission: `Players Within Radius(..., All Teams, Off)`).
3049        let hir = crate::compile(
3050            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
3051             @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n    \
3052             disableInspector()\n",
3053            "test.opy",
3054            std::path::Path::new(""),
3055        )
3056        .expect("getPlayersInRadius with defaults compiles");
3057        let RuleEntry::Rule(rule) = &hir.rules[0] else {
3058            panic!("expected a rule");
3059        };
3060        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
3061            panic!("expected a comparison");
3062        };
3063        let HirExpr::Call { name, args, .. } = left.as_ref() else {
3064            panic!("expected len call");
3065        };
3066        assert_eq!(name, "len");
3067        let HirExpr::Call { name, args, .. } = &args[0] else {
3068            panic!("expected getPlayersInRadius call");
3069        };
3070        assert_eq!(name, "getPlayersInRadius");
3071        assert_eq!(args.len(), 4);
3072        assert!(matches!(
3073            &args[2],
3074            HirExpr::Enum { value_type, value, .. }
3075                if value_type == "Team" && value == "ALL"
3076        ));
3077        assert!(matches!(
3078            &args[3],
3079            HirExpr::Enum { value_type, value, .. }
3080                if value_type == "LosCheck" && value == "OFF"
3081        ));
3082    }
3083
3084    #[test]
3085    fn value_call_in_action_position_is_rejected() {
3086        let error = compile_error(&action_source("isGameInProgress()"), 4);
3087        assert_eq!(error.code, "value-in-action-position");
3088    }
3089
3090    #[test]
3091    fn value_member_in_action_position_is_rejected() {
3092        // The #106 baseline records the oracle rejecting `B.isAlive()` as a
3093        // statement; the manifest enforces that contract (#109).
3094        let error = compile_error(
3095            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isAlive()\n",
3096            4,
3097        );
3098        assert_eq!(error.code, "value-in-action-position");
3099    }
3100
3101    // --- Named/keyword argument binding and chase call context (#110) ---
3102
3103    /// Compile a program and return the lowered first action's expression
3104    /// (the statement expression, or the value of a leading assignment).
3105    fn first_action_expr(source: &str) -> HirExpr {
3106        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
3107            .unwrap_or_else(|error| panic!("compile failed: {error}"));
3108        let RuleEntry::Rule(rule) = &program.rules[0] else {
3109            panic!("expected a rule");
3110        };
3111        match &rule.actions[0] {
3112            HirStmt::Expr { expr, .. } => (**expr).clone(),
3113            HirStmt::Assign { value, .. } => (**value).clone(),
3114            other => panic!("expected an expression or assignment, got {other:?}"),
3115        }
3116    }
3117
3118    /// Remove every `span`/`name_span` key from a serialized expression (the
3119    /// differential suite's normalization).
3120    fn strip_spans(value: &mut serde_json::Value) {
3121        match value {
3122            serde_json::Value::Object(map) => {
3123                map.remove("span");
3124                map.remove("name_span");
3125                for nested in map.values_mut() {
3126                    strip_spans(nested);
3127                }
3128            }
3129            serde_json::Value::Array(items) => {
3130                for item in items {
3131                    strip_spans(item);
3132                }
3133            }
3134            _ => {}
3135        }
3136    }
3137
3138    #[test]
3139    fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
3140        // The reference `chase` form: `rate = …` dispatches to chaseAtRate
3141        // with the ChaseRateReeval domain, `duration = …` to chaseOverTime
3142        // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves
3143        // only through this call context (issue #110).
3144        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
3145        let HirExpr::Call { name, args, .. } = &expr else {
3146            panic!("expected a call, got {expr:?}");
3147        };
3148        assert_eq!(name, "chaseAtRate");
3149        assert!(matches!(
3150            &args[3],
3151            HirExpr::Enum { value_type, value, .. }
3152                if value_type == "ChaseRateReeval" && value == "NONE"
3153        ));
3154
3155        let expr = first_action_expr(&action_source(
3156            "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
3157        ));
3158        let HirExpr::Call { name, args, .. } = &expr else {
3159            panic!("expected a call, got {expr:?}");
3160        };
3161        assert_eq!(name, "chaseOverTime");
3162        assert!(matches!(
3163            &args[3],
3164            HirExpr::Enum { value_type, value, .. }
3165                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
3166        ));
3167
3168        // Player-variable first arguments resolve too (the emission layer
3169        // picks the player form).
3170        let expr = first_action_expr(
3171            "playervar P\nrule \"r\":\n    @Event eachPlayer\n    \
3172             chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
3173        );
3174        let HirExpr::Call { name, args, .. } = &expr else {
3175            panic!("expected a call, got {expr:?}");
3176        };
3177        assert_eq!(name, "chaseAtRate");
3178        assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
3179    }
3180
3181    #[test]
3182    fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
3183        // `ChaseReeval` is a contextual domain, not a standalone domain
3184        // identity: a bare member access outside the chase signature is
3185        // rejected.
3186        let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
3187        assert_eq!(error.code, "unsupported-member");
3188
3189        // Inside the chase context the member is an opaque identity
3190        // dispatched to the concrete domain selected by the keyword
3191        // selector; member existence in that domain is not validated here
3192        // (lowering-dependent, #8).
3193        let expr = first_action_expr(&action_source(
3194            "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
3195        ));
3196        let HirExpr::Call { name, args, .. } = &expr else {
3197            panic!("expected a call, got {expr:?}");
3198        };
3199        assert_eq!(name, "chaseAtRate");
3200        assert!(matches!(
3201            &args[3],
3202            HirExpr::Enum { value_type, value, .. }
3203                if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
3204        ));
3205
3206        // A non-enum 4th argument is carried structurally; the reference's
3207        // "expected an enum member" rejection is lowering-dependent.
3208        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
3209        let HirExpr::Call { name, args, .. } = &expr else {
3210            panic!("expected a call, got {expr:?}");
3211        };
3212        assert_eq!(name, "chase");
3213        assert!(matches!(&args[3], HirExpr::Number { .. }));
3214    }
3215
3216    #[test]
3217    fn chase_requires_the_keyword_rate_or_duration_third_argument() {
3218        let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
3219        assert_eq!(error.code, "keyword-required");
3220        assert!(error.message.contains("rate"));
3221    }
3222
3223    #[test]
3224    fn chase_family_requires_a_variable_first_argument() {
3225        // The reference rejects non-variable first arguments for the chase
3226        // family ("Expected variable for 1st argument of function
3227        // 'chaseOverTime'", issue #110) — the variable kind also selects
3228        // the global/player emission form.
3229        let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
3230        assert_eq!(error.code, "invalid-argument");
3231
3232        let error = compile_error(
3233            &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
3234            4,
3235        );
3236        assert_eq!(error.code, "invalid-argument");
3237    }
3238
3239    #[test]
3240    fn keyword_binding_matches_positional_binding_in_hir() {
3241        // Keyword binding consumes the manifest signatures: the bound HIR is
3242        // identical to the positional form's (defaults filled the same way),
3243        // modulo source spans (the keyword values sit at different columns).
3244        fn without_spans(expr: &HirExpr) -> serde_json::Value {
3245            let mut value = serde_json::to_value(expr).unwrap();
3246            strip_spans(&mut value);
3247            value
3248        }
3249        let keyword = without_spans(&first_action_expr(&action_source(
3250            "chaseOverTime(g, 10, duration=3)",
3251        )));
3252        let positional = without_spans(&first_action_expr(&action_source(
3253            "chaseOverTime(g, 10, 3)",
3254        )));
3255        assert_eq!(keyword, positional);
3256
3257        let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
3258        let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
3259        assert_eq!(keyword, positional);
3260
3261        // Out-of-order keywords bind by name.
3262        let keyword = without_spans(&first_action_expr(&action_source(
3263            "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
3264        )));
3265        let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
3266        assert_eq!(keyword, positional);
3267
3268        let keyword = without_spans(&first_action_expr(&action_source(
3269            "g = vect(x=1, y=2, z=3)",
3270        )));
3271        let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
3272        assert_eq!(keyword, positional);
3273    }
3274
3275    #[test]
3276    fn keyword_binding_diagnostics_are_structured_and_source_located() {
3277        // Unknown keyword name.
3278        let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
3279        assert_eq!(error.code, "unknown-keyword");
3280        assert!(error.message.contains("bogus"));
3281
3282        // Duplicate (positional slot filled again by keyword).
3283        let error = compile_error(
3284            &action_source(
3285                "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
3286                 reevaluation=ChaseTimeReeval.NONE)",
3287            ),
3288            4,
3289        );
3290        assert_eq!(error.code, "duplicate-argument");
3291
3292        // Positional after keyword.
3293        let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
3294        assert_eq!(error.code, "positional-after-keyword");
3295
3296        // Missing required argument (reference: "Missing argument 'duration'").
3297        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
3298        assert_eq!(error.code, "missing-argument");
3299
3300        // Positional-only parameter bound by keyword (`chase`'s leading
3301        // arguments; the reference rejects the keyword form).
3302        let error = compile_error(
3303            &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
3304            4,
3305        );
3306        assert_eq!(error.code, "unknown-keyword");
3307    }
3308
3309    #[test]
3310    fn keyword_arguments_are_rejected_for_reference_special_cases() {
3311        // The reference routes `range`, `random.*`, and `.format` around its
3312        // generic keyword binder; keyword arguments fail deterministically.
3313        let error = compile_error(
3314            "globalvar g\nrule \"r\":\n    @Event global\n    \
3315             for I in range(start=0, stop=3):\n        debug(I)\n",
3316            4,
3317        );
3318        assert_eq!(error.code, "keyword-unsupported");
3319
3320        let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
3321        assert_eq!(error.code, "keyword-unsupported");
3322
3323        let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
3324        assert_eq!(error.code, "keyword-unsupported");
3325    }
3326
3327    #[test]
3328    fn wait_uses_the_reference_keyword_names() {
3329        // The manifest's `wait` parameter names match the pinned reference
3330        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
3331        // keyword exactly like the oracle.
3332        let error = compile_error(&action_source("wait(duration=1)"), 4);
3333        assert_eq!(error.code, "unknown-keyword");
3334        assert!(error.message.contains("duration"));
3335    }
3336
3337    #[test]
3338    fn action_call_in_value_position_is_rejected() {
3339        let error = compile_error(&action_source("g = wait(1)"), 4);
3340        assert_eq!(error.code, "action-in-value-position");
3341    }
3342
3343    #[test]
3344    fn missing_required_argument_is_a_source_located_diagnostic() {
3345        // Too-few calls reject with the reference's missing-argument
3346        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
3347        // issue #110); positional overflow keeps `invalid-arity`.
3348        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
3349        assert_eq!(error.code, "missing-argument");
3350        assert!(error.message.contains("duration"));
3351
3352        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
3353        assert_eq!(error.code, "invalid-arity");
3354    }
3355
3356    #[test]
3357    fn missing_member_argument_is_a_source_located_diagnostic() {
3358        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
3359        // 30)` must reject like the oracle (the `status` argument is
3360        // missing; the reference: "Missing argument 'status' for function
3361        // '.setStatusEffect'", issue #110).
3362        let error = compile_error(
3363            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
3364             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
3365            4,
3366        );
3367        assert_eq!(error.code, "missing-argument");
3368        assert!(error.message.contains("duration"));
3369    }
3370
3371    #[test]
3372    fn invalid_receiver_categories_are_rejected() {
3373        // `.append` requires an assignable receiver; `.format` a string
3374        // literal (both reference-enforced categories).
3375        let error = compile_error(&action_source("3.append(1)"), 4);
3376        assert_eq!(error.code, "invalid-receiver");
3377        assert!(error.message.contains("append"));
3378
3379        let error = compile_error(&action_source("attacker.append(1)"), 4);
3380        assert_eq!(error.code, "invalid-receiver");
3381        assert!(error.message.contains("append"));
3382
3383        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
3384        assert_eq!(error.code, "invalid-receiver");
3385        assert!(error.message.contains("format"));
3386    }
3387
3388    #[test]
3389    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
3390        // Domain mismatches need canonical Workshop enum knowledge: a member
3391        // of the wrong domain is carried as an opaque identity and the check
3392        // is lowering-dependent (#8).
3393        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
3394        let HirExpr::Call { args, .. } = &expr else {
3395            panic!("expected a call, got {expr:?}");
3396        };
3397        assert!(matches!(
3398            &args[3],
3399            HirExpr::Enum { value_type, value, .. }
3400                if value_type == "Invis" && value == "ALL"
3401        ));
3402
3403        let expr = first_action_expr(&action_source(
3404            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
3405        ));
3406        let HirExpr::ReceiverCall { args, .. } = &expr else {
3407            panic!("expected a receiver call, got {expr:?}");
3408        };
3409        assert!(matches!(
3410            &args[0],
3411            HirExpr::Enum { value_type, value, .. }
3412                if value_type == "ChaseTimeReeval" && value == "NONE"
3413        ));
3414    }
3415
3416    #[test]
3417    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
3418        // Whether a non-enum value is acceptable for an enum parameter is
3419        // Workshop catalog knowledge; the frontend carries the argument
3420        // structurally and leaves the check to lowering (#8).
3421        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
3422        let HirExpr::ReceiverCall { args, .. } = &expr else {
3423            panic!("expected a receiver call, got {expr:?}");
3424        };
3425        assert!(matches!(
3426            &args[0],
3427            HirExpr::GlobalVar { name, .. } if name == "g"
3428        ));
3429
3430        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
3431        let HirExpr::ReceiverCall { args, .. } = &expr else {
3432            panic!("expected a receiver call, got {expr:?}");
3433        };
3434        assert!(matches!(&args[0], HirExpr::Number { .. }));
3435    }
3436
3437    #[test]
3438    fn unknown_builtins_fail_at_resolution_not_emission() {
3439        let error = compile_error(&action_source("frobnicate()"), 4);
3440        assert_eq!(error.code, "unknown-action");
3441
3442        let error = compile_error(&action_source("g = frobnicate()"), 4);
3443        assert_eq!(error.code, "unknown-value");
3444
3445        let error = compile_error(
3446            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
3447            4,
3448        );
3449        assert_eq!(error.code, "unknown-member");
3450    }
3451
3452    #[test]
3453    fn wright_only_catalog_names_are_rejected() {
3454        // `createHudText` and `squareRoot` are Workshop emission spellings,
3455        // not OPY source functions; the pinned reference rejects them, so
3456        // the manifest does not preserve the accidental acceptance.
3457        let error = compile_error(&action_source("createHudText(1)"), 4);
3458        assert_eq!(error.code, "unknown-action");
3459
3460        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
3461        assert_eq!(error.code, "unknown-value");
3462    }
3463
3464    #[test]
3465    fn generic_member_only_actions_are_rejected() {
3466        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
3467        // member form is the reference surface.
3468        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
3469        assert_eq!(error.code, "unknown-action");
3470    }
3471
3472    #[test]
3473    fn range_is_for_iterables_only() {
3474        // Standalone `range(...)` is rejected by the reference; the
3475        // for-header form keeps 1-3 arguments.
3476        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
3477        assert_eq!(error.code, "invalid-call-context");
3478
3479        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
3480        assert_eq!(error.code, "invalid-iterable");
3481
3482        crate::compile(
3483            &action_source("for g in range(3):\n        debug(g)"),
3484            "test.opy",
3485            std::path::Path::new(""),
3486        )
3487        .expect("the for-header range form compiles");
3488    }
3489
3490    #[test]
3491    fn source_aliases_resolve_to_canonical_names() {
3492        // Non-contextual aliases rewrite to the canonical entry so identity,
3493        // position, and emission use the target name.
3494        let hir = crate::compile(
3495            &action_source("stopChasingVariable(g)"),
3496            "test.opy",
3497            std::path::Path::new(""),
3498        )
3499        .expect("the alias target compiles");
3500        let RuleEntry::Rule(rule) = &hir.rules[0] else {
3501            panic!("expected a rule");
3502        };
3503        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
3504            panic!("expected expression statement");
3505        };
3506        let HirExpr::Call { name, .. } = expr.as_ref() else {
3507            panic!("expected a call");
3508        };
3509        assert_eq!(name, "stopChasingVariable");
3510
3511        let hir = crate::compile(
3512            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
3513             @Condition eventPlayer.getCurrentHero() != null\n    \
3514             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
3515             disableInspector()\n",
3516            "test.opy",
3517            std::path::Path::new(""),
3518        )
3519        .expect("member aliases compile");
3520        let RuleEntry::Rule(rule) = &hir.rules[0] else {
3521            panic!("expected a rule");
3522        };
3523        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
3524            panic!("expected a comparison");
3525        };
3526        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
3527            panic!("expected a receiver call");
3528        };
3529        assert_eq!(name, "getHero");
3530    }
3531
3532    #[test]
3533    fn unknown_catalog_enum_members_are_rejected() {
3534        for source in [
3535            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
3536            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
3537        ] {
3538            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
3539                .expect_err("unknown catalog member must be rejected");
3540            assert_eq!(error.code, "unknown-enum-member");
3541        }
3542    }
3543
3544    #[test]
3545    fn default_var_for_binder_resolves_at_all_range_arities() {
3546        // The agent-lab regression: `for I in range(0, 10):` with `I` not
3547        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
3548        // the pinned reference accepts as an implicit global loop binder
3549        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
3550        for (binder, iterable) in [
3551            ("I", "range(0, 10)"),
3552            ("I", "range(3)"),
3553            ("I", "range(1, 5, 2)"),
3554        ] {
3555            let hir = lower_ok(&format!(
3556                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
3557            ));
3558            let (_, actions) = rule_conditions_and_actions(&hir);
3559            let HirStmt::For { variable, body, .. } = &actions[0] else {
3560                panic!("expected a for statement");
3561            };
3562            assert!(
3563                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
3564                "the binder resolves to the implicit global 'I', got {variable:?}"
3565            );
3566            assert!(!body.is_empty(), "the loop body lowers");
3567            // The binder use in the body resolves too: `total += I` has a
3568            // GlobalVar operand.
3569            let HirStmt::Assign { value, .. } = &body[0] else {
3570                panic!("expected an assignment in the body");
3571            };
3572            let HirExpr::Binary { right, .. } = value.as_ref() else {
3573                panic!("expected a binary expression");
3574            };
3575            assert!(
3576                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
3577                "the binder use inside the body resolves to the implicit global"
3578            );
3579        }
3580    }
3581
3582    #[test]
3583    fn player_variable_range_binder_preserves_host_player_receiver() {
3584        let hir = lower_ok(
3585            "playervar I\nrule \"r\":\n    @Event global\n    for hostPlayer.I in range(3):\n        hostPlayer.I = 1\n",
3586        );
3587        let (_, actions) = rule_conditions_and_actions(&hir);
3588        let HirStmt::For { variable, .. } = &actions[0] else {
3589            panic!("expected a for statement");
3590        };
3591        let HirExpr::PlayerVar {
3592            player,
3593            name,
3594            member_span,
3595            span,
3596        } = variable.as_ref()
3597        else {
3598            panic!("expected a player-variable binder, got {variable:?}");
3599        };
3600        assert_eq!(name, "I");
3601        assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
3602        assert_eq!(span.unwrap().start.line, 4);
3603        assert_eq!(span.unwrap().start.col, 9);
3604        assert_eq!(span.unwrap().end.line, 4);
3605        assert_eq!(span.unwrap().end.col, 21);
3606        let member_span = member_span.expect("player binder member span");
3607        assert_eq!(member_span.start.line, 4);
3608        assert_eq!(member_span.start.col, 20);
3609        assert_eq!(member_span.end.line, 4);
3610        assert_eq!(member_span.end.col, 21);
3611    }
3612
3613    #[test]
3614    fn default_var_names_resolve_as_implicit_globals() {
3615        // Default variable names resolve anywhere a variable may appear,
3616        // matching the pinned reference (no `globalvar` declaration needed).
3617        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
3618        let (_, actions) = rule_conditions_and_actions(&hir);
3619        let HirStmt::Assign { target, .. } = &actions[0] else {
3620            panic!("expected an assignment");
3621        };
3622        assert!(
3623            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
3624            "the implicit global resolves, got {target:?}"
3625        );
3626        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
3627        assert_eq!(default_var_index("I"), Some(8));
3628        assert_eq!(default_var_index("AA"), Some(26));
3629        assert_eq!(default_var_index("Z"), Some(25));
3630        assert_eq!(default_var_index("DX"), Some(127));
3631        assert_eq!(default_var_index("DY"), None);
3632        assert_eq!(default_var_index("i"), None);
3633    }
3634
3635    #[test]
3636    fn nested_same_name_for_binders_reuse_the_implicit_global() {
3637        // Nested loops with the same default-var binder reuse the single
3638        // implicit variable, matching the pinned reference (the inner loop
3639        // overwrites the same Workshop global — no separate binding).
3640        let hir = lower_ok(
3641            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
3642        );
3643        let (_, actions) = rule_conditions_and_actions(&hir);
3644        let HirStmt::For {
3645            variable: outer,
3646            body,
3647            ..
3648        } = &actions[0]
3649        else {
3650            panic!("expected an outer for statement");
3651        };
3652        let HirStmt::For {
3653            variable: inner, ..
3654        } = &body[0]
3655        else {
3656            panic!("expected an inner for statement");
3657        };
3658        assert!(
3659            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
3660                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
3661            "both loops bind the same implicit global (spans differ per binder site)"
3662        );
3663    }
3664
3665    #[test]
3666    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
3667        // A lowercase undeclared binder is not a default variable name; the
3668        // pinned reference rejects the program ("Unknown function name"), and
3669        // Wright reports the same reject with the structured
3670        // `unknown-identifier` diagnostic (#114).
3671        let error = compile_error(
3672            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
3673            3,
3674        );
3675        assert_eq!(error.code, "unknown-identifier");
3676        let span = error.span.expect("the error is source-located");
3677        assert_eq!(span.start.line, 3);
3678    }
3679
3680    #[test]
3681    fn issue_28_constructs_lower_to_provenance_preserving_hir() {
3682        let hir = lower_ok(
3683            "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",
3684        );
3685        let (_, actions) = rule_conditions_and_actions(&hir);
3686        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
3687            panic!("expected do-while");
3688        };
3689        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
3690        let HirStmt::Switch { arms, .. } = &actions[1] else {
3691            panic!("expected switch");
3692        };
3693        assert_eq!(arms.len(), 2);
3694        let HirSwitchArm::Case {
3695            value: case_value,
3696            body,
3697            ..
3698        } = &arms[0]
3699        else {
3700            panic!("expected case arm");
3701        };
3702        assert!(
3703            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
3704        );
3705        let HirStmt::Assign { value, .. } = &body[0] else {
3706            panic!("expected case assignment");
3707        };
3708        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
3709        let HirSwitchArm::Default { body, .. } = &arms[1] else {
3710            panic!("expected default arm");
3711        };
3712        assert!(matches!(body[0], HirStmt::Assign { .. }));
3713        let HirStmt::Assign { value, .. } = &actions[2] else {
3714            panic!("expected comprehension assignment");
3715        };
3716        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
3717        let HirStmt::Assign { value, .. } = &actions[3] else {
3718            panic!("expected sorted assignment");
3719        };
3720        assert!(
3721            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")))
3722        );
3723        let HirStmt::Assign { value, .. } = &actions[4] else {
3724            panic!("expected string assignment");
3725        };
3726        assert!(
3727            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
3728        );
3729    }
3730
3731    #[test]
3732    fn issue_28_rejects_reference_invalid_bare_dict_and_lambda() {
3733        let dict_error = compile_error(
3734            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
3735            4,
3736        );
3737        assert_eq!(dict_error.code, "dict-access");
3738        let lambda_error = compile_error(
3739            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
3740            4,
3741        );
3742        assert_eq!(lambda_error.code, "lambda-context");
3743    }
3744
3745    #[test]
3746    fn do_while_requires_rule_or_definition_prefix_position() {
3747        let error = compile_error(
3748            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
3749            5,
3750        );
3751        assert_eq!(error.code, "do-while-placement");
3752        assert_eq!(
3753            error.message,
3754            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
3755        );
3756    }
3757}