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