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;
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_expr(
766                        variable,
767                        macro_params,
768                        CallPosition::Value,
769                    )),
770                    iterable: Box::new(self.lower_expr(iterable, macro_params, iterable_position)),
771                    body: self.lower_block(body, macro_params, true, false),
772                    span: Some(span.into()),
773                }
774            }
775            Stmt::While {
776                condition,
777                body,
778                span,
779            } => HirStmt::While {
780                condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)),
781                body: self.lower_block(body, macro_params, true, false),
782                span: Some(span.into()),
783            },
784            Stmt::DoWhile {
785                condition,
786                body,
787                span,
788            } => HirStmt::DoWhile {
789                condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)),
790                body: self.lower_block(body, macro_params, true, true),
791                span: Some(span.into()),
792            },
793            Stmt::Switch { value, arms, span } => HirStmt::Switch {
794                value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)),
795                arms: arms
796                    .iter()
797                    .map(|arm| match arm {
798                        cst::SwitchArm::Case { value, body, span } => HirSwitchArm::Case {
799                            value: Box::new(self.lower_expr(
800                                value,
801                                macro_params,
802                                CallPosition::Value,
803                            )),
804                            body: self.lower_block(body, macro_params, true, false),
805                            span: Some((*span).into()),
806                        },
807                        cst::SwitchArm::Default { body, span } => HirSwitchArm::Default {
808                            body: self.lower_block(body, macro_params, true, false),
809                            span: Some((*span).into()),
810                        },
811                    })
812                    .collect(),
813                span: Some(span.into()),
814            },
815            Stmt::Break { span } => {
816                if !breakable {
817                    self.error_at(
818                        "break-context",
819                        "break is only valid inside a switch or loop".to_string(),
820                        *span,
821                    );
822                }
823                HirStmt::Break {
824                    span: Some(span.into()),
825                }
826            }
827            Stmt::Pass { span } => HirStmt::Pass {
828                span: Some(span.into()),
829            },
830        }
831    }
832
833    fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec<HirStmt> {
834        self.lower_block(body, params, false, false)
835    }
836
837    fn lower_expr(
838        &mut self,
839        expr: &Expr,
840        macro_params: &[String],
841        position: CallPosition,
842    ) -> HirExpr {
843        match expr {
844            Expr::Number { value, text, span } => HirExpr::Number {
845                value: *value,
846                text: text.clone(),
847                span: Some(span.into()),
848            },
849            Expr::String { value, span } => HirExpr::String {
850                value: value.clone(),
851                span: Some(span.into()),
852            },
853            Expr::Bool { value, span } => HirExpr::Bool {
854                value: *value,
855                span: Some(span.into()),
856            },
857            Expr::Null { span } => HirExpr::Null {
858                span: Some(span.into()),
859            },
860            Expr::Array { elements, span } => HirExpr::Array {
861                elements: elements
862                    .iter()
863                    .map(|element| self.lower_expr(element, macro_params, CallPosition::Value))
864                    .collect(),
865                span: Some(span.into()),
866            },
867            Expr::Dict { entries, span } => {
868                if !self.allow_dict_literal {
869                    self.error_at(
870                        "dict-access",
871                        "dictionary literals must be accessed by a key".to_string(),
872                        *span,
873                    );
874                    return HirExpr::Null { span: None };
875                }
876                HirExpr::Dict {
877                    entries: entries
878                        .iter()
879                        .map(|entry| HirDictEntry {
880                            key: Box::new(self.lower_expr(
881                                &entry.key,
882                                macro_params,
883                                CallPosition::Value,
884                            )),
885                            value: Box::new(self.lower_expr(
886                                &entry.value,
887                                macro_params,
888                                CallPosition::Value,
889                            )),
890                            span: Some(entry.span.into()),
891                        })
892                        .collect(),
893                    span: Some(span.into()),
894                }
895            }
896            Expr::Comprehension {
897                element,
898                variable,
899                variable_span,
900                index,
901                iterable,
902                condition,
903                span,
904            } => {
905                let iterable = self.lower_expr(iterable, macro_params, CallPosition::Value);
906                let previous = std::mem::take(&mut self.locals);
907                self.locals.push(variable.clone());
908                if let Some((index, _)) = index {
909                    self.locals.push(index.clone());
910                }
911                let element = self.lower_expr(element, macro_params, CallPosition::Value);
912                let condition = condition.as_ref().map(|condition| {
913                    Box::new(self.lower_expr(condition, macro_params, CallPosition::Value))
914                });
915                self.locals = previous;
916                HirExpr::Comprehension {
917                    element: Box::new(element),
918                    variable: variable.clone(),
919                    variable_span: Some(variable_span.into()),
920                    index: index.as_ref().map(|(name, _)| name.clone()),
921                    index_span: index.as_ref().map(|(_, span)| (*span).into()),
922                    iterable: Box::new(iterable),
923                    condition,
924                    span: Some(span.into()),
925                }
926            }
927            Expr::Lambda { params, body, span } => {
928                if position != CallPosition::LambdaArgument {
929                    self.error_at(
930                        "lambda-context",
931                        "lambda expressions are only valid as array operation arguments"
932                            .to_string(),
933                        *span,
934                    );
935                    return HirExpr::Null { span: None };
936                }
937                let previous = std::mem::take(&mut self.locals);
938                self.locals = params.iter().map(|(name, _)| name.clone()).collect();
939                let body = self.lower_expr(body, macro_params, CallPosition::Value);
940                self.locals = previous;
941                HirExpr::Lambda {
942                    params: params.iter().map(|(name, _)| name.clone()).collect(),
943                    param_spans: params
944                        .iter()
945                        .map(|(_, span)| Some((*span).into()))
946                        .collect(),
947                    body: Box::new(body),
948                    span: Some(span.into()),
949                }
950            }
951            Expr::StringModifier {
952                modifier,
953                value,
954                format_text,
955                interpolations,
956                span,
957            } => {
958                if *modifier == 'f' {
959                    if let Some(format_text) = format_text {
960                        if !interpolations.is_empty() {
961                            return HirExpr::Format {
962                                text: format_text.clone(),
963                                args: interpolations
964                                    .iter()
965                                    .map(|expr| {
966                                        self.lower_expr(expr, macro_params, CallPosition::Value)
967                                    })
968                                    .collect(),
969                                span: Some(span.into()),
970                            };
971                        }
972                        return HirExpr::String {
973                            value: format_text.clone(),
974                            span: Some(span.into()),
975                        };
976                    }
977                }
978                HirExpr::StringModifier {
979                    modifier: modifier.to_string(),
980                    value: value.clone(),
981                    span: Some(span.into()),
982                }
983            }
984            Expr::Name { name, span } => self.lower_name(name, *span, macro_params),
985            Expr::Member {
986                receiver,
987                member,
988                member_span,
989                span,
990            } => self.lower_member(receiver, member, *member_span, *span, macro_params),
991            Expr::Index { array, index, span } => {
992                let previous = self.allow_dict_literal;
993                self.allow_dict_literal = true;
994                let array = self.lower_expr(array, macro_params, CallPosition::Value);
995                self.allow_dict_literal = previous;
996                HirExpr::Index {
997                    array: Box::new(array),
998                    index: Box::new(self.lower_expr(index, macro_params, CallPosition::Value)),
999                    span: Some(span.into()),
1000                }
1001            }
1002            Expr::Call { name, args, span } => {
1003                self.lower_call(name, args, *span, macro_params, position)
1004            }
1005            Expr::ReceiverCall {
1006                receiver,
1007                name,
1008                args,
1009                span,
1010            } => self.lower_receiver_call(receiver, name, args, *span, macro_params, position),
1011            Expr::Binary {
1012                op,
1013                left,
1014                right,
1015                span,
1016            } => HirExpr::Binary {
1017                op: op.clone(),
1018                left: Box::new(self.lower_expr(left, macro_params, CallPosition::Value)),
1019                right: Box::new(self.lower_expr(right, macro_params, CallPosition::Value)),
1020                span: Some(span.into()),
1021            },
1022            Expr::Unary { op, operand, span } => HirExpr::Unary {
1023                op: op.clone(),
1024                operand: Box::new(self.lower_expr(operand, macro_params, CallPosition::Value)),
1025                span: Some(span.into()),
1026            },
1027        }
1028    }
1029
1030    fn lower_name(&mut self, name: &str, span: Span, macro_params: &[String]) -> HirExpr {
1031        if macro_params.iter().any(|param| param == name) {
1032            return HirExpr::MacroParam {
1033                name: name.to_string(),
1034                span: Some(span.into()),
1035            };
1036        }
1037        if self.locals.iter().any(|local| local == name) {
1038            return HirExpr::Local {
1039                name: name.to_string(),
1040                span: Some(span.into()),
1041            };
1042        }
1043        match name {
1044            "eventPlayer" => HirExpr::EventPlayer {
1045                span: Some(span.into()),
1046            },
1047            _ if self.globals.contains(name) => HirExpr::GlobalVar {
1048                name: name.to_string(),
1049                span: Some(span.into()),
1050            },
1051            _ if self.players.contains(name) => HirExpr::PlayerVar {
1052                player: Box::new(HirExpr::EventPlayer { span: None }),
1053                name: name.to_string(),
1054                span: Some(span.into()),
1055            },
1056            _ if self.enums.contains_key(name) => {
1057                self.error_at(
1058                    "enum-type-without-member",
1059                    format!("enum type '{name}' must be used with a member (e.g. {name}.MEMBER)"),
1060                    span,
1061                );
1062                HirExpr::Null { span: None }
1063            }
1064            // OverPy default variable names (A–Z, AA–…, DX): implicit global
1065            // variables at fixed Workshop slots. The pinned reference accepts
1066            // these without a `globalvar` declaration anywhere a variable may
1067            // appear, including as a `for ... in range(...)` loop binder
1068            // (#114). Custom enums take precedence over default-var names,
1069            // matching the reference's identifier resolution order.
1070            _ if default_var_index(name).is_some() => HirExpr::GlobalVar {
1071                name: name.to_string(),
1072                span: Some(span.into()),
1073            },
1074            _ => {
1075                self.error_at(
1076                    "unknown-identifier",
1077                    format!("unknown identifier '{name}'"),
1078                    span,
1079                );
1080                HirExpr::Null { span: None }
1081            }
1082        }
1083    }
1084
1085    fn lower_member(
1086        &mut self,
1087        receiver: &Expr,
1088        member: &str,
1089        member_span: Span,
1090        span: Span,
1091        _macro_params: &[String],
1092    ) -> HirExpr {
1093        if let Expr::Name { name, .. } = receiver {
1094            // Custom enum member: folds to its numeric constant.
1095            if let Some(members) = self.enums.get(name) {
1096                return match members.iter().position(|candidate| candidate == member) {
1097                    Some(index) => HirExpr::Number {
1098                        value: index as f64,
1099                        text: index.to_string(),
1100                        span: Some(span.into()),
1101                    },
1102                    None => {
1103                        self.error_at(
1104                            "unknown-enum-member",
1105                            format!("enum '{name}' has no member '{member}'"),
1106                            span,
1107                        );
1108                        HirExpr::Null { span: None }
1109                    }
1110                };
1111            }
1112            // Builtin Workshop enum: the domain name is a declared OPY
1113            // signature identity (manifest `param.domain`); the member list
1114            // is Workshop-owned catalog content, so the member access
1115            // resolves as an opaque identity after validating the member
1116            // against the canonical Workshop catalog.
1117            if self.manifest.domain_identity(name) {
1118                if let Some(domain) = self.catalog.enum_domain(name)
1119                    && !domain
1120                        .members
1121                        .iter()
1122                        .any(|candidate| candidate.member == *member)
1123                {
1124                    self.error_at(
1125                        "unknown-enum-member",
1126                        format!("enum '{name}' has no member '{member}'"),
1127                        span,
1128                    );
1129                    return HirExpr::Null { span: None };
1130                }
1131                return HirExpr::Enum {
1132                    value_type: name.clone(),
1133                    value: member.to_string(),
1134                    span: Some(span.into()),
1135                };
1136            }
1137            // Event-player member: a player-variable reference.
1138            if name == "eventPlayer" {
1139                return HirExpr::PlayerVar {
1140                    player: Box::new(HirExpr::EventPlayer { span: None }),
1141                    name: member.to_string(),
1142                    span: Some(span.into()),
1143                };
1144            }
1145            // A module member used without a call (`random.uniform` alone).
1146            if name == "random" {
1147                self.error_at(
1148                    "unsupported-member",
1149                    format!("module member '{name}.{member}' must be called"),
1150                    span,
1151                );
1152                return HirExpr::Null { span: None };
1153            }
1154            // A bare variable receiver member is valid OPY source syntax even
1155            // when canonical member existence is deferred to Workshop. Keep
1156            // both the resolved variable receiver and the source member
1157            // identity in HIR instead of treating it as an unknown member.
1158            if self.globals.contains(name)
1159                || self.players.contains(name)
1160                || default_var_index(name).is_some()
1161            {
1162                return HirExpr::Member {
1163                    receiver: Box::new(self.lower_name(name, receiver.span(), &[])),
1164                    member: member.to_string(),
1165                    member_span: Some(member_span.into()),
1166                    span: Some(span.into()),
1167                };
1168            }
1169        }
1170        self.error_at(
1171            "unsupported-member",
1172            "unsupported member access on this expression".to_string(),
1173            span,
1174        );
1175        HirExpr::Null { span: None }
1176    }
1177
1178    fn lower_call(
1179        &mut self,
1180        name: &str,
1181        args: &[cst::CallArg],
1182        span: Span,
1183        macro_params: &[String],
1184        position: CallPosition,
1185    ) -> HirExpr {
1186        // Builtin identity and position checks run before the special forms
1187        // so that a misplaced `wait`/`vect` still diagnoses its position.
1188        if !self.macros.contains(name) && !self.subroutines.contains(name) && name != "sorted" {
1189            match self.manifest.resolve_function(name) {
1190                Some(entry) => self.check_call_position(name, entry, position, span),
1191                None => {
1192                    let (code, message) = match position {
1193                        CallPosition::Statement => {
1194                            ("unknown-action", format!("unknown action '{name}'"))
1195                        }
1196                        CallPosition::Value => ("unknown-value", format!("unknown value '{name}'")),
1197                        CallPosition::ForIterable => (
1198                            "invalid-iterable",
1199                            format!("for-loop iterable '{name}' must be a range(...) call"),
1200                        ),
1201                        CallPosition::LambdaArgument => {
1202                            ("unknown-value", format!("unknown value '{name}'"))
1203                        }
1204                    };
1205                    self.error_at(code, message, span);
1206                }
1207            }
1208        }
1209        match name {
1210            "sorted" => HirExpr::Call {
1211                name: name.to_string(),
1212                args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| {
1213                    index == 1 || arg.keyword.as_ref().is_some_and(|(name, _)| name == "key")
1214                }),
1215                span: Some(span.into()),
1216            },
1217            "vect" => {
1218                // `vect` goes through the generic argument binder so its
1219                // keyword forms (`vect(x=1, y=2, z=3)`) bind like any other
1220                // manifest signature; the result must fill exactly the three
1221                // declared parameters (x, y, z).
1222                let (bound, _) = match self.manifest.resolve_function(name) {
1223                    Some(entry) => self.bind_args(entry, args, macro_params),
1224                    None => (self.lower_arg_values(args, macro_params), None),
1225                };
1226                if bound.len() < 3 {
1227                    self.error_at(
1228                        "vect-arity",
1229                        format!(
1230                            "vect() expects 3 arguments (x, y, z) but got {}",
1231                            args.len()
1232                        ),
1233                        span,
1234                    );
1235                    return HirExpr::Null { span: None };
1236                }
1237                HirExpr::Vector {
1238                    x: Box::new(bound[0].clone()),
1239                    y: Box::new(bound[1].clone()),
1240                    z: Box::new(bound[2].clone()),
1241                    span: Some(span.into()),
1242                }
1243            }
1244            _ => {
1245                if self.macros.contains(name) {
1246                    // A declared `macro` invocation is recorded as a macroCall
1247                    // (positional-only; keyword arguments are an explicit
1248                    // diagnostic).
1249                    for arg in args {
1250                        if let Some((keyword, span)) = &arg.keyword {
1251                            self.error_at(
1252                                "keyword-unsupported",
1253                                format!(
1254                                    "macro '{name}' does not accept keyword \
1255                                     arguments ('{keyword}')"
1256                                ),
1257                                *span,
1258                            );
1259                        }
1260                    }
1261                    return HirExpr::MacroCall {
1262                        name: name.to_string(),
1263                        args: self.lower_arg_values(args, macro_params),
1264                        span: Some(span.into()),
1265                    };
1266                }
1267                match self.manifest.resolve_function(name) {
1268                    Some(entry) => {
1269                        // Declared subroutines with arguments stay generic
1270                        // calls; builtins get keyword binding, arity, and
1271                        // domain/default handling.
1272                        if self.subroutines.contains(name) {
1273                            return HirExpr::Call {
1274                                name: name.to_string(),
1275                                args: self.lower_arg_values(args, macro_params),
1276                                span: Some(span.into()),
1277                            };
1278                        }
1279                        let (bound, selector) = self.bind_args(entry, args, macro_params);
1280                        let (call_name, bound) =
1281                            self.resolve_contextual_domain(entry, bound, selector.as_deref());
1282                        HirExpr::Call {
1283                            name: call_name,
1284                            args: bound,
1285                            span: Some(span.into()),
1286                        }
1287                    }
1288                    None => HirExpr::Call {
1289                        name: name.to_string(),
1290                        args: self.lower_arg_values(args, macro_params),
1291                        span: Some(span.into()),
1292                    },
1293                }
1294            }
1295        }
1296    }
1297
1298    /// Lower call arguments to HIR values in source order (used for macro
1299    /// calls and unresolved names; keyword values lose their name).
1300    fn lower_arg_values(&mut self, args: &[cst::CallArg], macro_params: &[String]) -> Vec<HirExpr> {
1301        self.lower_arg_values_with_lambda(args, macro_params, |_, _| false)
1302    }
1303
1304    fn lower_arg_values_with_lambda(
1305        &mut self,
1306        args: &[cst::CallArg],
1307        macro_params: &[String],
1308        allows_lambda: impl Fn(usize, &cst::CallArg) -> bool,
1309    ) -> Vec<HirExpr> {
1310        args.iter()
1311            .enumerate()
1312            .map(|(index, arg)| {
1313                let position = if allows_lambda(index, arg) {
1314                    CallPosition::LambdaArgument
1315                } else {
1316                    CallPosition::Value
1317                };
1318                self.lower_expr(&arg.value, macro_params, position)
1319            })
1320            .collect()
1321    }
1322
1323    /// Bind positional and keyword arguments against a manifest signature
1324    /// (issue #110), producing lowered values in parameter order with
1325    /// declared defaults filled. Diagnostics are structured and
1326    /// source-located: `unknown-keyword`, `duplicate-argument`,
1327    /// `keyword-required`, `positional-after-keyword`, `missing-argument`,
1328    /// `keyword-unsupported`, `invalid-arity` (overflow), and
1329    /// `invalid-argument` (variable-required parameters).
1330    ///
1331    /// The returned `selector` is the keyword spelling used to bind the
1332    /// entry's contextual-domain selector parameter (the `chase` form's
1333    /// `rate`/`duration`), when the entry declares one.
1334    fn bind_args(
1335        &mut self,
1336        entry: &Function,
1337        args: &[cst::CallArg],
1338        macro_params: &[String],
1339    ) -> (Vec<HirExpr>, Option<String>) {
1340        let mut slots: Vec<Option<HirExpr>> = vec![None; entry.params.len()];
1341        let mut selector = None;
1342        let mut has_keyword = false;
1343        let mut binding_error = false;
1344        let contextual = entry.contextual_domain.as_ref();
1345
1346        // Keyword spellings resolve through the declared parameter names
1347        // (alternate spellings included) — generic binding, no per-spelling
1348        // branches.
1349        let mut by_spelling: HashMap<&str, usize> = HashMap::new();
1350        for (index, param) in entry.params.iter().enumerate() {
1351            by_spelling.insert(param.name.as_str(), index);
1352            for alternate in &param.alternate_names {
1353                by_spelling.insert(alternate.as_str(), index);
1354            }
1355        }
1356
1357        for (arg_index, arg) in args.iter().enumerate() {
1358            match &arg.keyword {
1359                Some((keyword, name_span)) => {
1360                    if !entry.keyword_args {
1361                        binding_error = true;
1362                        self.error_at(
1363                            "keyword-unsupported",
1364                            format!(
1365                                "function '{}' does not accept keyword arguments ('{keyword}')",
1366                                entry.id
1367                            ),
1368                            *name_span,
1369                        );
1370                        continue;
1371                    }
1372                    has_keyword = true;
1373                    match by_spelling.get(keyword.as_str()) {
1374                        None => {
1375                            binding_error = true;
1376                            self.error_at(
1377                                "unknown-keyword",
1378                                format!(
1379                                    "unknown keyword argument '{keyword}' for function '{}'",
1380                                    entry.id
1381                                ),
1382                                *name_span,
1383                            );
1384                        }
1385                        Some(&index) => {
1386                            let param = &entry.params[index];
1387                            if param.positional_only {
1388                                binding_error = true;
1389                                self.error_at(
1390                                    "unknown-keyword",
1391                                    format!(
1392                                        "parameter '{}' of '{}' cannot be bound by keyword",
1393                                        param.name, entry.id
1394                                    ),
1395                                    *name_span,
1396                                );
1397                            } else if slots[index].is_some() {
1398                                binding_error = true;
1399                                self.error_at(
1400                                    "duplicate-argument",
1401                                    format!(
1402                                        "argument '{}' of function '{}' is defined twice",
1403                                        keyword, entry.id
1404                                    ),
1405                                    *name_span,
1406                                );
1407                            } else {
1408                                slots[index] = Some(self.lower_call_arg_value(
1409                                    entry,
1410                                    index,
1411                                    arg,
1412                                    macro_params,
1413                                ));
1414                                if contextual.is_some_and(|c| c.by == param.name) {
1415                                    selector = Some(keyword.clone());
1416                                }
1417                            }
1418                        }
1419                    }
1420                }
1421                None => {
1422                    // The reference's generic binder rejects positional
1423                    // arguments after keyword arguments; its special forms
1424                    // (the contextual-domain entries, e.g. `chase`) bind the
1425                    // trailing positionals by slot and skip the ordering
1426                    // rule.
1427                    if has_keyword && entry.contextual_domain.is_none() {
1428                        binding_error = true;
1429                        self.error_at(
1430                            "positional-after-keyword",
1431                            format!(
1432                                "cannot use positional arguments after keyword \
1433                                 arguments in call to '{}'",
1434                                entry.id
1435                            ),
1436                            arg.value.span(),
1437                        );
1438                    }
1439                    // Positional arguments fill the slot at their argument
1440                    // index (keywords occupy their named slots), matching
1441                    // the reference binder.
1442                    let index = arg_index;
1443                    if index < entry.params.len() {
1444                        let param = &entry.params[index];
1445                        if param.keyword_only {
1446                            binding_error = true;
1447                            self.error_at(
1448                                "keyword-required",
1449                                format!(
1450                                    "argument {} of '{}' must be passed as a keyword \
1451                                     (name = value; accepted names: {})",
1452                                    index + 1,
1453                                    entry.id,
1454                                    keyword_spellings(param).join(", ")
1455                                ),
1456                                arg.value.span(),
1457                            );
1458                        }
1459                        if slots[index].is_none() {
1460                            slots[index] =
1461                                Some(self.lower_call_arg_value(entry, index, arg, macro_params));
1462                        }
1463                    } else {
1464                        self.lower_expr(&arg.value, macro_params, CallPosition::Value);
1465                    }
1466                }
1467            }
1468        }
1469
1470        // Positional overflow: the declared arity bounds report the
1471        // reference's "takes N arguments, received M" rejection. A binding
1472        // error already reported (duplicate keyword, unknown keyword, …)
1473        // suppresses the secondary arity noise, matching the reference's
1474        // first-error behavior.
1475        if !binding_error && args.len() > entry.params.len() {
1476            self.check_arity(entry, args.len(), arg_span(args));
1477        }
1478
1479        // Unbound parameters: declared defaults fill; required parameters
1480        // without a default are the reference's missing-argument rejection;
1481        // `optional` parameters stay omittable without an emitted expansion.
1482        let mut bound: Vec<HirExpr> = Vec::with_capacity(entry.params.len());
1483        for (index, param) in entry.params.iter().enumerate() {
1484            match &slots[index] {
1485                Some(value) => bound.push(value.clone()),
1486                None => match &param.default {
1487                    Some(ParamDefault::EnumMember(member)) => {
1488                        let domain = param.domain.clone().unwrap_or_default();
1489                        bound.push(HirExpr::Enum {
1490                            value_type: domain,
1491                            value: member.clone(),
1492                            span: None,
1493                        });
1494                    }
1495                    Some(ParamDefault::Number(number)) => {
1496                        bound.push(HirExpr::Number {
1497                            value: *number,
1498                            text: format!("{number}"),
1499                            span: None,
1500                        });
1501                    }
1502                    None if param.optional => {
1503                        // Omitted entirely (the reference's short forms keep
1504                        // the argument list short, e.g. `range(3)`).
1505                    }
1506                    None => {
1507                        self.error_at(
1508                            "missing-argument",
1509                            format!(
1510                                "missing argument '{}' for function '{}'",
1511                                param.name, entry.id
1512                            ),
1513                            arg_span(args),
1514                        );
1515                        bound.push(HirExpr::Null { span: None });
1516                    }
1517                },
1518            }
1519        }
1520
1521        // Variable-required parameters (the chase family's first argument)
1522        // must resolve to a variable reference.
1523        for (index, param) in entry.params.iter().enumerate() {
1524            if !param.variable {
1525                continue;
1526            }
1527            if let Some(Some(value)) = slots.get(index) {
1528                if !matches!(value, HirExpr::GlobalVar { .. } | HirExpr::PlayerVar { .. }) {
1529                    self.error_at(
1530                        "invalid-argument",
1531                        format!(
1532                            "argument {} of '{}' must be a variable (globalvar or \
1533                             playervar)",
1534                            index + 1,
1535                            entry.id
1536                        ),
1537                        arg_span(args),
1538                    );
1539                }
1540            }
1541        }
1542
1543        (bound, selector)
1544    }
1545
1546    /// Lower one call argument's value; the contextual-domain parameter (the
1547    /// `chase` form's `ChaseReeval` member) is recorded as a pending enum
1548    /// without validating the domain — it resolves only against the concrete
1549    /// domain selected by the call's keyword selector (issue #110). Outside
1550    /// that signature context `ChaseReeval` never resolves because it is not
1551    /// a declared enum domain.
1552    fn lower_call_arg_value(
1553        &mut self,
1554        entry: &Function,
1555        param_index: usize,
1556        arg: &cst::CallArg,
1557        macro_params: &[String],
1558    ) -> HirExpr {
1559        if let Some(contextual) = &entry.contextual_domain {
1560            let is_contextual = entry.params[param_index]
1561                .domain
1562                .as_deref()
1563                .is_some_and(|domain| domain == contextual.domain);
1564            if is_contextual {
1565                if let Expr::Member {
1566                    receiver,
1567                    member,
1568                    span,
1569                    ..
1570                } = &arg.value
1571                {
1572                    if let Expr::Name { name, .. } = receiver.as_ref() {
1573                        if name == &contextual.domain {
1574                            return HirExpr::Enum {
1575                                value_type: contextual.domain.clone(),
1576                                value: member.clone(),
1577                                span: Some((*span).into()),
1578                            };
1579                        }
1580                    }
1581                }
1582            }
1583        }
1584        self.lower_expr(&arg.value, macro_params, CallPosition::Value)
1585    }
1586
1587    /// Resolve a contextual enum-domain parameter (the `chase` form's
1588    /// `ChaseReeval` member, issue #110): the keyword spelling bound to the
1589    /// selector parameter selects the concrete domain and the function the
1590    /// call lowers to. This is pure catalog-identity dispatch — member
1591    /// *existence* in the selected domain is Workshop-owned knowledge and is
1592    /// not validated here (lowering-dependent, issue #8). Outside this
1593    /// signature context `ChaseReeval` never resolves (it is not a standalone
1594    /// domain identity). A call that does not bind a contextual member keeps
1595    /// its declared name and arguments without a domain diagnostic.
1596    fn resolve_contextual_domain(
1597        &mut self,
1598        entry: &Function,
1599        mut bound: Vec<HirExpr>,
1600        selector: Option<&str>,
1601    ) -> (String, Vec<HirExpr>) {
1602        let Some(contextual) = &entry.contextual_domain else {
1603            return (entry.id.clone(), bound);
1604        };
1605        let Some(contextual_param) = entry
1606            .params
1607            .iter()
1608            .position(|param| param.domain.as_deref() == Some(contextual.domain.as_str()))
1609        else {
1610            return (entry.id.clone(), bound);
1611        };
1612        // Dispatch only when the argument is written as a member of the
1613        // contextual domain; anything else keeps the generic call (the
1614        // reference's "expected an enum" rejection is lowering-dependent).
1615        let HirExpr::Enum {
1616            value_type,
1617            value,
1618            span: value_span,
1619        } = &bound[contextual_param]
1620        else {
1621            return (entry.id.clone(), bound);
1622        };
1623        if value_type != &contextual.domain {
1624            return (entry.id.clone(), bound);
1625        }
1626        let Some(keyword) = selector else {
1627            return (entry.id.clone(), bound);
1628        };
1629        let Some(option) = contextual.options.get(keyword) else {
1630            return (entry.id.clone(), bound);
1631        };
1632        bound[contextual_param] = HirExpr::Enum {
1633            value_type: option.domain.clone(),
1634            value: value.clone(),
1635            span: *value_span,
1636        };
1637        (option.target.clone(), bound)
1638    }
1639
1640    fn lower_receiver_call(
1641        &mut self,
1642        receiver: &Expr,
1643        name: &str,
1644        args: &[cst::CallArg],
1645        span: Span,
1646        macro_params: &[String],
1647        position: CallPosition,
1648    ) -> HirExpr {
1649        if matches!(name, "map" | "filter" | "all" | "any") {
1650            let lowered = HirExpr::ReceiverCall {
1651                receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1652                name: name.to_string(),
1653                args: self.lower_arg_values_with_lambda(args, macro_params, |index, _| index == 0),
1654                span: Some(span.into()),
1655            };
1656            return lowered;
1657        }
1658        // `random.uniform(...)` etc. are dotted generic calls.
1659        if let Expr::Name { name: root, .. } = receiver {
1660            if root == "random" {
1661                return self.lower_call(
1662                    &format!("random.{name}"),
1663                    args,
1664                    span,
1665                    macro_params,
1666                    position,
1667                );
1668            }
1669        }
1670        // `.format` on a string literal is the format special form; it is
1671        // also a declared member value (receiver category `String`), so
1672        // position misuse diagnoses here.
1673        if let Expr::String { value, .. } = receiver {
1674            if name == "format" {
1675                if args.iter().any(|arg| arg.keyword.is_some()) {
1676                    for arg in args {
1677                        if let Some((keyword, span)) = &arg.keyword {
1678                            self.error_at(
1679                                "keyword-unsupported",
1680                                format!(
1681                                    "function 'format' does not accept keyword \
1682                                     arguments ('{keyword}')"
1683                                ),
1684                                *span,
1685                            );
1686                        }
1687                    }
1688                }
1689                let lowered: Vec<HirExpr> = self.lower_arg_values(args, macro_params);
1690                if let Some(entry) = self.manifest.resolve_member("format") {
1691                    self.check_call_position("format", entry, position, span);
1692                }
1693                return HirExpr::Format {
1694                    text: value.clone(),
1695                    args: lowered,
1696                    span: Some(span.into()),
1697                };
1698            }
1699        }
1700        // Member calls resolve through the manifest (receiver category,
1701        // explicit-argument signatures, keyword binding).
1702        let (member_name, lowered) = match self.manifest.resolve_member(name) {
1703            Some(entry) => {
1704                self.check_call_position(name, entry, position, span);
1705                if let Some(category) = entry.receiver {
1706                    self.check_receiver(receiver, category, entry, span);
1707                }
1708                let (bound, _) = self.bind_args(entry, args, macro_params);
1709                (entry.id.clone(), bound)
1710            }
1711            None => {
1712                self.error_at("unknown-member", format!("unknown member '{name}'"), span);
1713                (name.to_string(), self.lower_arg_values(args, macro_params))
1714            }
1715        };
1716        // `eventPlayer.member(...)` → receiver call on the event player.
1717        if let Expr::Name { name: root, .. } = receiver {
1718            if root == "eventPlayer" {
1719                return HirExpr::ReceiverCall {
1720                    receiver: Box::new(HirExpr::EventPlayer { span: None }),
1721                    name: member_name,
1722                    args: lowered,
1723                    span: Some(span.into()),
1724                };
1725            }
1726        }
1727        // Any other receiver: resolve it and keep the receiver call.
1728        HirExpr::ReceiverCall {
1729            receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)),
1730            name: member_name,
1731            args: lowered,
1732            span: Some(span.into()),
1733        }
1734    }
1735
1736    /// Check a builtin entry against its call position: action/value
1737    /// identity and for-iterable context.
1738    fn check_call_position(
1739        &mut self,
1740        name: &str,
1741        entry: &Function,
1742        position: CallPosition,
1743        span: Span,
1744    ) {
1745        match position {
1746            CallPosition::Statement => {
1747                if entry.context == Some(FunctionContext::ForIterable) {
1748                    self.error_at(
1749                        "invalid-call-context",
1750                        format!("'{name}' is only valid as a for-loop iterable"),
1751                        span,
1752                    );
1753                } else if entry.kind.is_value() {
1754                    self.error_at(
1755                        "value-in-action-position",
1756                        format!("value function '{name}' cannot be used as an action"),
1757                        span,
1758                    );
1759                }
1760            }
1761            CallPosition::Value => {
1762                if entry.kind.is_action() {
1763                    self.error_at(
1764                        "action-in-value-position",
1765                        format!("action function '{name}' cannot be used as a value"),
1766                        span,
1767                    );
1768                } else if entry.context == Some(FunctionContext::ForIterable) {
1769                    self.error_at(
1770                        "invalid-call-context",
1771                        format!("'{name}' is only valid as a for-loop iterable"),
1772                        span,
1773                    );
1774                }
1775            }
1776            CallPosition::ForIterable => {
1777                if entry.context != Some(FunctionContext::ForIterable) {
1778                    self.error_at(
1779                        "invalid-iterable",
1780                        format!("for-loop iterable '{name}' must be a range(...) call"),
1781                        span,
1782                    );
1783                }
1784            }
1785            CallPosition::LambdaArgument => {
1786                if entry.kind.is_action() {
1787                    self.error_at(
1788                        "action-in-value-position",
1789                        format!("action function '{name}' cannot be used as a value"),
1790                        span,
1791                    );
1792                } else if entry.context == Some(FunctionContext::ForIterable) {
1793                    self.error_at(
1794                        "invalid-call-context",
1795                        format!("'{name}' is only valid as a for-loop iterable"),
1796                        span,
1797                    );
1798                }
1799            }
1800        }
1801    }
1802
1803    /// Check a member call's receiver against its declared category. Only
1804    /// the reference-enforced categories reject: `.append` requires an
1805    /// assignable receiver and `.format` a string literal; player-oriented
1806    /// members accept any receiver (the pinned reference does not type-check
1807    /// them).
1808    fn check_receiver(
1809        &mut self,
1810        receiver: &Expr,
1811        category: ReceiverCategory,
1812        entry: &Function,
1813        span: Span,
1814    ) {
1815        let mismatch = match category {
1816            ReceiverCategory::String => !matches!(receiver, Expr::String { .. }),
1817            ReceiverCategory::Variable => !assignable_receiver(receiver),
1818            ReceiverCategory::Player | ReceiverCategory::Any => false,
1819        };
1820        if mismatch {
1821            self.error_at(
1822                "invalid-receiver",
1823                format!(
1824                    "member '{}' requires {} as its receiver",
1825                    entry.id,
1826                    category.describe()
1827                ),
1828                span,
1829            );
1830        }
1831    }
1832
1833    /// Check a builtin call's argument count against its declared arity.
1834    fn check_arity(&mut self, entry: &Function, got: usize, span: Span) {
1835        let (min, max) = entry.arity_bounds();
1836        let valid = got >= min && max.is_none_or(|max| got <= max);
1837        if !valid {
1838            let expects = match max {
1839                Some(max) if min == max => format!("exactly {min}"),
1840                Some(max) => format!("{min} to {max}"),
1841                None => format!("at least {min}"),
1842            };
1843            let role = match entry.kind {
1844                FunctionKind::Action => "action",
1845                FunctionKind::Value => "value",
1846                FunctionKind::MemberAction => "member action",
1847                FunctionKind::MemberValue => "member value",
1848            };
1849            self.error_at(
1850                "invalid-arity",
1851                format!(
1852                    "{role} '{}' expects {expects} arguments but got {got}",
1853                    entry.id
1854                ),
1855                span,
1856            );
1857        }
1858    }
1859
1860    fn error_at(&mut self, code: &str, message: String, span: Span) {
1861        self.errors.push(OpyError::at(code, message, span));
1862    }
1863}
1864
1865/// The keyword spellings a parameter accepts (its name plus alternates).
1866fn keyword_spellings(param: &Param) -> Vec<String> {
1867    let mut spellings = vec![param.name.clone()];
1868    spellings.extend(param.alternate_names.iter().cloned());
1869    spellings
1870}
1871
1872/// The source span covering a call's argument list (the start of the first
1873/// argument through the last argument).
1874fn arg_span(args: &[CallArg]) -> Span {
1875    args.first().map(CallArg::span).unwrap_or_else(|| {
1876        Span::new(
1877            0,
1878            crate::diag::Position::new(1, 1),
1879            crate::diag::Position::new(1, 1),
1880        )
1881    })
1882}
1883
1884/// Whether a CST receiver is assignable (the `.append` receiver rule): a
1885/// variable name (including macro parameters), an array literal, or an index
1886/// expression — matching the pinned reference, which rejects constant and
1887/// function receivers ("Cannot modify or assign to …").
1888fn assignable_receiver(receiver: &Expr) -> bool {
1889    match receiver {
1890        Expr::Name { name, .. } => name != "eventPlayer",
1891        Expr::Array { .. } | Expr::Index { .. } => true,
1892        _ => false,
1893    }
1894}
1895
1896impl From<Span> for HirSpan {
1897    fn from(span: Span) -> HirSpan {
1898        HirSpan {
1899            file: span.file,
1900            start: Position {
1901                line: span.start.line,
1902                col: span.start.col,
1903            },
1904            end: Position {
1905                line: span.end.line,
1906                col: span.end.col,
1907            },
1908        }
1909    }
1910}
1911
1912impl From<&Span> for HirSpan {
1913    fn from(span: &Span) -> HirSpan {
1914        (*span).into()
1915    }
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920    use super::*;
1921    use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1922    use crate::lexer::{LexInput, lex};
1923    use crate::parser::parse;
1924
1925    fn lower_ok(text: &str) -> HirProgram {
1926        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1927        let output = parse(&tokens);
1928        assert!(
1929            output.errors.is_empty(),
1930            "unexpected parse errors: {:?}",
1931            output.errors
1932        );
1933        let program = output.program.expect("parse produces a program");
1934        lower(&program, vec![], vec![]).expect("lowers without errors")
1935    }
1936
1937    fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1938        let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1939            panic!("expected a rule");
1940        };
1941        (&rule.conditions, &rule.actions)
1942    }
1943
1944    #[test]
1945    fn producer_emits_the_v2_ordered_switch_contract() {
1946        let hir = lower_ok(
1947            "globalvar value\nrule \"r\":\n    @Event global\n    switch value:\n        default:\n            value = 1\n        case 2:\n            value = 2\n",
1948        );
1949        assert_eq!(hir.protocol.name, "wright/opy-hir");
1950        assert_eq!(hir.protocol.version, "2.0.0");
1951        let value = serde_json::to_value(&hir).expect("HIR must serialize");
1952        let switch = &value["rules"][0]["actions"][0];
1953        assert!(switch.get("arms").is_some());
1954        assert!(switch.get("cases").is_none());
1955        assert!(switch.get("default").is_none());
1956    }
1957
1958    #[test]
1959    fn receiver_calls_lower_to_receiver_call_hir() {
1960        // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the
1961        // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a
1962        // global-variable receiver (#104).
1963        let hir = lower_ok(
1964            "globalvar target\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n    target.setMoveSpeed(50)\n",
1965        );
1966        let (_, actions) = rule_conditions_and_actions(&hir);
1967        assert_eq!(actions.len(), 2);
1968
1969        let HirStmt::Expr { expr, .. } = &actions[0] else {
1970            panic!("expected expression statement");
1971        };
1972        let HirExpr::ReceiverCall {
1973            receiver,
1974            name,
1975            args,
1976            ..
1977        } = expr.as_ref()
1978        else {
1979            panic!("expected receiver call, got {expr:?}");
1980        };
1981        assert_eq!(name, "setMoveSpeed");
1982        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1983        assert_eq!(args.len(), 1);
1984        assert!(matches!(&args[0], HirExpr::Number { .. }));
1985
1986        let HirStmt::Expr { expr, .. } = &actions[1] else {
1987            panic!("expected expression statement");
1988        };
1989        let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1990            panic!("expected receiver call, got {expr:?}");
1991        };
1992        assert_eq!(name, "setMoveSpeed");
1993        assert!(
1994            matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1995            "globalvar receiver must resolve to a GlobalVar"
1996        );
1997    }
1998
1999    #[test]
2000    fn bare_variable_member_expression_preserves_receiver_and_member() {
2001        let hir = lower_ok(
2002            "globalvar A\nplayervar B\nrule \"receiver\":\n    @Event eachPlayer\n    A = B.C\n",
2003        );
2004        let HirStmt::Assign { value, .. } = &hir
2005            .rules
2006            .iter()
2007            .find_map(|entry| {
2008                let RuleEntry::Rule(rule) = entry else {
2009                    return None;
2010                };
2011                rule.actions.first()
2012            })
2013            .expect("assignment")
2014        else {
2015            panic!("expected assignment");
2016        };
2017        let HirExpr::Member {
2018            receiver, member, ..
2019        } = value.as_ref()
2020        else {
2021            panic!("expected opaque member expression, got {value:?}");
2022        };
2023        assert_eq!(member, "C");
2024        assert!(matches!(receiver.as_ref(), HirExpr::PlayerVar { name, .. } if name == "B"));
2025    }
2026
2027    #[test]
2028    fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
2029        let text = "rule \"before\":\n    pass\ndef source_name():\n    @Name \"Friendly\"\n    pass\nrule \"after\":\n    pass\n";
2030        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
2031        let output = parse(&tokens);
2032        assert!(
2033            output.errors.is_empty(),
2034            "unexpected parse errors: {:?}",
2035            output.errors
2036        );
2037        let program = output.program.expect("program");
2038        let preprocessing = PreprocessingState {
2039            rule_prefix_template: Some(crate::hir::types::DirectiveValue {
2040                value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
2041                span: None,
2042            }),
2043            ..PreprocessingState::default()
2044        };
2045        let hir = lower_with_preprocessing(
2046            &program,
2047            vec![SourceFile {
2048                id: 0,
2049                path: "main.opy".to_string(),
2050            }],
2051            vec![],
2052            &preprocessing,
2053        )
2054        .expect("lowers");
2055        let names: Vec<_> = hir
2056            .rules
2057            .iter()
2058            .map(|entry| match entry {
2059                HirRuleEntry::Rule(rule) => rule.name.clone(),
2060                HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
2061            })
2062            .collect();
2063        assert_eq!(
2064            names,
2065            vec!["[Main] before", "[Main] Friendly", "[Main] after"]
2066        );
2067        let HirRuleEntry::SubroutineDef {
2068            name, source_name, ..
2069        } = &hir.rules[1]
2070        else {
2071            panic!("expected subroutine definition");
2072        };
2073        assert_eq!(name, "[Main] Friendly");
2074        assert_eq!(source_name, "source_name");
2075    }
2076
2077    #[test]
2078    fn receiver_call_values_lower_in_conditions() {
2079        // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value;
2080        // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver
2081        // call inside another receiver call's arguments (#104).
2082        let hir = lower_ok(
2083            "rule \"r\":\n    @Event eachPlayer\n    @Condition eventPlayer.isAlive()\n    eventPlayer.teleport(eventPlayer.getPosition())\n",
2084        );
2085        let (conditions, actions) = rule_conditions_and_actions(&hir);
2086        assert_eq!(conditions.len(), 1);
2087        let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
2088            panic!("expected receiver call condition, got {:?}", conditions[0]);
2089        };
2090        assert_eq!(name, "isAlive");
2091        assert_eq!(args.len(), 0);
2092
2093        let HirStmt::Expr { expr, .. } = &actions[0] else {
2094            panic!("expected expression statement");
2095        };
2096        let HirExpr::ReceiverCall {
2097            name,
2098            args,
2099            receiver,
2100            ..
2101        } = expr.as_ref()
2102        else {
2103            panic!("expected receiver call, got {expr:?}");
2104        };
2105        assert_eq!(name, "teleport");
2106        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
2107        assert_eq!(args.len(), 1);
2108        assert!(matches!(
2109            &args[0],
2110            HirExpr::ReceiverCall { name, .. } if name == "getPosition"
2111        ));
2112    }
2113
2114    #[test]
2115    fn format_string_receiver_stays_a_format_node() {
2116        // `.format()` on a string receiver is unaffected by the receiver-call
2117        // path (existing supported form).
2118        let hir = lower_ok(
2119            "rule \"r\":\n    @Event global\n    print(\"{} points\".format(len([1, 2])))\n",
2120        );
2121        let (_, actions) = rule_conditions_and_actions(&hir);
2122        let HirStmt::Expr { expr, .. } = &actions[0] else {
2123            panic!("expected expression statement");
2124        };
2125        assert!(
2126            has_format(expr),
2127            "string `.format()` must lower to a Format node"
2128        );
2129    }
2130
2131    fn has_format(expr: &HirExpr) -> bool {
2132        match expr {
2133            HirExpr::Format { .. } => true,
2134            HirExpr::Call { args, .. } => args.iter().any(has_format),
2135            HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
2136            _ => false,
2137        }
2138    }
2139
2140    /// Lower one rule action and return the assignment's value expression.
2141    fn lowered_value(source: &str) -> HirExpr {
2142        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
2143            .unwrap_or_else(|error| panic!("compile failed: {error}"));
2144        let RuleEntry::Rule(rule) = &program.rules[0] else {
2145            panic!("expected a rule");
2146        };
2147        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
2148            panic!("expected an assign statement");
2149        };
2150        (**value).clone()
2151    }
2152
2153    #[test]
2154    fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
2155        let value = lowered_value(
2156            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NONE\n",
2157        );
2158        assert_enum(&value, "ChaseTimeReeval", "NONE");
2159    }
2160
2161    #[test]
2162    fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
2163        let value = lowered_value(
2164            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
2165        );
2166        assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
2167    }
2168
2169    #[test]
2170    fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
2171        for member in ["NONE", "DESTINATION_AND_RATE"] {
2172            let source = format!(
2173                "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseRateReeval.{member}\n"
2174            );
2175            assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
2176        }
2177    }
2178
2179    /// Assert the expression is the catalog enum `(domain, member)` node,
2180    /// ignoring its source span (the span is frontend-internal provenance).
2181    fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
2182        match value {
2183            HirExpr::Enum {
2184                value_type, value, ..
2185            } => {
2186                assert_eq!(value_type, domain);
2187                assert_eq!(value, member);
2188            }
2189            other => panic!("expected enum {domain}.{member}, got {other:?}"),
2190        }
2191    }
2192
2193    #[test]
2194    fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
2195        let error = crate::compile(
2196            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NOPE\n",
2197            "test.opy",
2198            std::path::Path::new(""),
2199        )
2200        .expect_err("unknown catalog member must be rejected");
2201        assert_eq!(error.code, "unknown-enum-member");
2202    }
2203
2204    #[test]
2205    fn unknown_enum_receiver_is_an_unsupported_member_error() {
2206        let error = crate::compile(
2207            "globalvar g\nrule \"r\":\n    @Event global\n    g = NotARealEnum.MEMBER\n",
2208            "test.opy",
2209            std::path::Path::new(""),
2210        )
2211        .expect_err("an unknown enum type must fail");
2212        assert_eq!(error.code, "unsupported-member");
2213        let span = error.span.expect("the error is source-located");
2214        assert_eq!(span.start.line, 4);
2215    }
2216
2217    // --- Builtin semantic manifest coverage (#109) ---
2218
2219    /// Assert a compile failure has the given code at the given line.
2220    fn compile_error(source: &str, line: u32) -> OpyError {
2221        let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2222            .expect_err("expected a compile failure");
2223        let span = error.span.expect("the error is source-located");
2224        assert_eq!(span.start.line, line, "code '{}'", error.code);
2225        error
2226    }
2227
2228    fn action_source(statement: &str) -> String {
2229        format!("globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n")
2230    }
2231
2232    #[test]
2233    fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
2234        // 4-argument form with an explicit reevaluation member (#106).
2235        let hir = crate::compile(
2236            &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
2237            "test.opy",
2238            std::path::Path::new(""),
2239        )
2240        .expect("reference-supported chaseOverTime compiles");
2241        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2242            panic!("expected a rule");
2243        };
2244        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2245            panic!("expected expression statement");
2246        };
2247        let HirExpr::Call { name, args, .. } = expr.as_ref() else {
2248            panic!("expected a call, got {expr:?}");
2249        };
2250        assert_eq!(name, "chaseOverTime");
2251        assert_eq!(args.len(), 4);
2252        assert!(matches!(
2253            &args[3],
2254            HirExpr::Enum { value_type, value, .. }
2255                if value_type == "ChaseTimeReeval" && value == "NONE"
2256        ));
2257
2258        // 3-argument form fills the reference default member.
2259        let hir = crate::compile(
2260            &action_source("chaseOverTime(g, 10, 3)"),
2261            "test.opy",
2262            std::path::Path::new(""),
2263        )
2264        .expect("default-reevaluation chaseOverTime compiles");
2265        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2266            panic!("expected a rule");
2267        };
2268        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2269            panic!("expected expression statement");
2270        };
2271        let HirExpr::Call { args, .. } = expr.as_ref() else {
2272            panic!("expected a call");
2273        };
2274        assert_eq!(args.len(), 4);
2275        assert!(matches!(
2276            &args[3],
2277            HirExpr::Enum { value_type, value, .. }
2278                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
2279        ));
2280    }
2281
2282    #[test]
2283    fn is_game_in_progress_resolves_as_a_builtin_value() {
2284        // Generic value gap from #106: `isGameInProgress()` in a condition.
2285        let hir = crate::compile(
2286            &action_source("@Condition isGameInProgress() == true"),
2287            "test.opy",
2288            std::path::Path::new(""),
2289        )
2290        .expect("reference-supported isGameInProgress compiles");
2291        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2292            panic!("expected a rule");
2293        };
2294        assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
2295    }
2296
2297    #[test]
2298    fn enum_gated_members_resolve_through_the_manifest() {
2299        // Enum-gated members from #106: setInvisibility (Invis), getThrottle
2300        // (member value), worldVector (Transform arg), setStatusEffect
2301        // (Status arg).
2302        let source = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2303            @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n    \
2304            @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n    \
2305            eventPlayer.setInvisibility(Invis.ALL)\n    \
2306            eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
2307        let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
2308            .expect("enum-gated members compile");
2309        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2310            panic!("expected a rule");
2311        };
2312        assert_eq!(rule.actions.len(), 2);
2313    }
2314
2315    #[test]
2316    fn get_players_in_radius_fills_reference_enum_defaults() {
2317        // 2-argument form fills Team.ALL and LosCheck.OFF (reference
2318        // emission: `Players Within Radius(..., All Teams, Off)`).
2319        let hir = crate::compile(
2320            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2321             @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n    \
2322             disableInspector()\n",
2323            "test.opy",
2324            std::path::Path::new(""),
2325        )
2326        .expect("getPlayersInRadius with defaults compiles");
2327        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2328            panic!("expected a rule");
2329        };
2330        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2331            panic!("expected a comparison");
2332        };
2333        let HirExpr::Call { name, args, .. } = left.as_ref() else {
2334            panic!("expected len call");
2335        };
2336        assert_eq!(name, "len");
2337        let HirExpr::Call { name, args, .. } = &args[0] else {
2338            panic!("expected getPlayersInRadius call");
2339        };
2340        assert_eq!(name, "getPlayersInRadius");
2341        assert_eq!(args.len(), 4);
2342        assert!(matches!(
2343            &args[2],
2344            HirExpr::Enum { value_type, value, .. }
2345                if value_type == "Team" && value == "ALL"
2346        ));
2347        assert!(matches!(
2348            &args[3],
2349            HirExpr::Enum { value_type, value, .. }
2350                if value_type == "LosCheck" && value == "OFF"
2351        ));
2352    }
2353
2354    #[test]
2355    fn value_call_in_action_position_is_rejected() {
2356        let error = compile_error(&action_source("isGameInProgress()"), 4);
2357        assert_eq!(error.code, "value-in-action-position");
2358    }
2359
2360    #[test]
2361    fn value_member_in_action_position_is_rejected() {
2362        // The #106 baseline records the oracle rejecting `B.isAlive()` as a
2363        // statement; the manifest enforces that contract (#109).
2364        let error = compile_error(
2365            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isAlive()\n",
2366            4,
2367        );
2368        assert_eq!(error.code, "value-in-action-position");
2369    }
2370
2371    // --- Named/keyword argument binding and chase call context (#110) ---
2372
2373    /// Compile a program and return the lowered first action's expression
2374    /// (the statement expression, or the value of a leading assignment).
2375    fn first_action_expr(source: &str) -> HirExpr {
2376        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
2377            .unwrap_or_else(|error| panic!("compile failed: {error}"));
2378        let RuleEntry::Rule(rule) = &program.rules[0] else {
2379            panic!("expected a rule");
2380        };
2381        match &rule.actions[0] {
2382            HirStmt::Expr { expr, .. } => (**expr).clone(),
2383            HirStmt::Assign { value, .. } => (**value).clone(),
2384            other => panic!("expected an expression or assignment, got {other:?}"),
2385        }
2386    }
2387
2388    /// Remove every `span`/`name_span` key from a serialized expression (the
2389    /// differential suite's normalization).
2390    fn strip_spans(value: &mut serde_json::Value) {
2391        match value {
2392            serde_json::Value::Object(map) => {
2393                map.remove("span");
2394                map.remove("name_span");
2395                for nested in map.values_mut() {
2396                    strip_spans(nested);
2397                }
2398            }
2399            serde_json::Value::Array(items) => {
2400                for item in items {
2401                    strip_spans(item);
2402                }
2403            }
2404            _ => {}
2405        }
2406    }
2407
2408    #[test]
2409    fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
2410        // The reference `chase` form: `rate = …` dispatches to chaseAtRate
2411        // with the ChaseRateReeval domain, `duration = …` to chaseOverTime
2412        // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves
2413        // only through this call context (issue #110).
2414        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
2415        let HirExpr::Call { name, args, .. } = &expr else {
2416            panic!("expected a call, got {expr:?}");
2417        };
2418        assert_eq!(name, "chaseAtRate");
2419        assert!(matches!(
2420            &args[3],
2421            HirExpr::Enum { value_type, value, .. }
2422                if value_type == "ChaseRateReeval" && value == "NONE"
2423        ));
2424
2425        let expr = first_action_expr(&action_source(
2426            "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
2427        ));
2428        let HirExpr::Call { name, args, .. } = &expr else {
2429            panic!("expected a call, got {expr:?}");
2430        };
2431        assert_eq!(name, "chaseOverTime");
2432        assert!(matches!(
2433            &args[3],
2434            HirExpr::Enum { value_type, value, .. }
2435                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
2436        ));
2437
2438        // Player-variable first arguments resolve too (the emission layer
2439        // picks the player form).
2440        let expr = first_action_expr(
2441            "playervar P\nrule \"r\":\n    @Event eachPlayer\n    \
2442             chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
2443        );
2444        let HirExpr::Call { name, args, .. } = &expr else {
2445            panic!("expected a call, got {expr:?}");
2446        };
2447        assert_eq!(name, "chaseAtRate");
2448        assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
2449    }
2450
2451    #[test]
2452    fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
2453        // `ChaseReeval` is a contextual domain, not a standalone domain
2454        // identity: a bare member access outside the chase signature is
2455        // rejected.
2456        let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
2457        assert_eq!(error.code, "unsupported-member");
2458
2459        // Inside the chase context the member is an opaque identity
2460        // dispatched to the concrete domain selected by the keyword
2461        // selector; member existence in that domain is not validated here
2462        // (lowering-dependent, #8).
2463        let expr = first_action_expr(&action_source(
2464            "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
2465        ));
2466        let HirExpr::Call { name, args, .. } = &expr else {
2467            panic!("expected a call, got {expr:?}");
2468        };
2469        assert_eq!(name, "chaseAtRate");
2470        assert!(matches!(
2471            &args[3],
2472            HirExpr::Enum { value_type, value, .. }
2473                if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
2474        ));
2475
2476        // A non-enum 4th argument is carried structurally; the reference's
2477        // "expected an enum member" rejection is lowering-dependent.
2478        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
2479        let HirExpr::Call { name, args, .. } = &expr else {
2480            panic!("expected a call, got {expr:?}");
2481        };
2482        assert_eq!(name, "chase");
2483        assert!(matches!(&args[3], HirExpr::Number { .. }));
2484    }
2485
2486    #[test]
2487    fn chase_requires_the_keyword_rate_or_duration_third_argument() {
2488        let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
2489        assert_eq!(error.code, "keyword-required");
2490        assert!(error.message.contains("rate"));
2491    }
2492
2493    #[test]
2494    fn chase_family_requires_a_variable_first_argument() {
2495        // The reference rejects non-variable first arguments for the chase
2496        // family ("Expected variable for 1st argument of function
2497        // 'chaseOverTime'", issue #110) — the variable kind also selects
2498        // the global/player emission form.
2499        let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
2500        assert_eq!(error.code, "invalid-argument");
2501
2502        let error = compile_error(
2503            &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
2504            4,
2505        );
2506        assert_eq!(error.code, "invalid-argument");
2507    }
2508
2509    #[test]
2510    fn keyword_binding_matches_positional_binding_in_hir() {
2511        // Keyword binding consumes the manifest signatures: the bound HIR is
2512        // identical to the positional form's (defaults filled the same way),
2513        // modulo source spans (the keyword values sit at different columns).
2514        fn without_spans(expr: &HirExpr) -> serde_json::Value {
2515            let mut value = serde_json::to_value(expr).unwrap();
2516            strip_spans(&mut value);
2517            value
2518        }
2519        let keyword = without_spans(&first_action_expr(&action_source(
2520            "chaseOverTime(g, 10, duration=3)",
2521        )));
2522        let positional = without_spans(&first_action_expr(&action_source(
2523            "chaseOverTime(g, 10, 3)",
2524        )));
2525        assert_eq!(keyword, positional);
2526
2527        let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
2528        let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
2529        assert_eq!(keyword, positional);
2530
2531        // Out-of-order keywords bind by name.
2532        let keyword = without_spans(&first_action_expr(&action_source(
2533            "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
2534        )));
2535        let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
2536        assert_eq!(keyword, positional);
2537
2538        let keyword = without_spans(&first_action_expr(&action_source(
2539            "g = vect(x=1, y=2, z=3)",
2540        )));
2541        let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
2542        assert_eq!(keyword, positional);
2543    }
2544
2545    #[test]
2546    fn keyword_binding_diagnostics_are_structured_and_source_located() {
2547        // Unknown keyword name.
2548        let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
2549        assert_eq!(error.code, "unknown-keyword");
2550        assert!(error.message.contains("bogus"));
2551
2552        // Duplicate (positional slot filled again by keyword).
2553        let error = compile_error(
2554            &action_source(
2555                "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
2556                 reevaluation=ChaseTimeReeval.NONE)",
2557            ),
2558            4,
2559        );
2560        assert_eq!(error.code, "duplicate-argument");
2561
2562        // Positional after keyword.
2563        let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
2564        assert_eq!(error.code, "positional-after-keyword");
2565
2566        // Missing required argument (reference: "Missing argument 'duration'").
2567        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2568        assert_eq!(error.code, "missing-argument");
2569
2570        // Positional-only parameter bound by keyword (`chase`'s leading
2571        // arguments; the reference rejects the keyword form).
2572        let error = compile_error(
2573            &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
2574            4,
2575        );
2576        assert_eq!(error.code, "unknown-keyword");
2577    }
2578
2579    #[test]
2580    fn keyword_arguments_are_rejected_for_reference_special_cases() {
2581        // The reference routes `range`, `random.*`, and `.format` around its
2582        // generic keyword binder; keyword arguments fail deterministically.
2583        let error = compile_error(
2584            "globalvar g\nrule \"r\":\n    @Event global\n    \
2585             for I in range(start=0, stop=3):\n        debug(I)\n",
2586            4,
2587        );
2588        assert_eq!(error.code, "keyword-unsupported");
2589
2590        let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
2591        assert_eq!(error.code, "keyword-unsupported");
2592
2593        let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
2594        assert_eq!(error.code, "keyword-unsupported");
2595    }
2596
2597    #[test]
2598    fn wait_uses_the_reference_keyword_names() {
2599        // The manifest's `wait` parameter names match the pinned reference
2600        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
2601        // keyword exactly like the oracle.
2602        let error = compile_error(&action_source("wait(duration=1)"), 4);
2603        assert_eq!(error.code, "unknown-keyword");
2604        assert!(error.message.contains("duration"));
2605    }
2606
2607    #[test]
2608    fn action_call_in_value_position_is_rejected() {
2609        let error = compile_error(&action_source("g = wait(1)"), 4);
2610        assert_eq!(error.code, "action-in-value-position");
2611    }
2612
2613    #[test]
2614    fn missing_required_argument_is_a_source_located_diagnostic() {
2615        // Too-few calls reject with the reference's missing-argument
2616        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
2617        // issue #110); positional overflow keeps `invalid-arity`.
2618        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2619        assert_eq!(error.code, "missing-argument");
2620        assert!(error.message.contains("duration"));
2621
2622        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2623        assert_eq!(error.code, "invalid-arity");
2624    }
2625
2626    #[test]
2627    fn missing_member_argument_is_a_source_located_diagnostic() {
2628        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
2629        // 30)` must reject like the oracle (the `status` argument is
2630        // missing; the reference: "Missing argument 'status' for function
2631        // '.setStatusEffect'", issue #110).
2632        let error = compile_error(
2633            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2634             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2635            4,
2636        );
2637        assert_eq!(error.code, "missing-argument");
2638        assert!(error.message.contains("duration"));
2639    }
2640
2641    #[test]
2642    fn invalid_receiver_categories_are_rejected() {
2643        // `.append` requires an assignable receiver; `.format` a string
2644        // literal (both reference-enforced categories).
2645        let error = compile_error(&action_source("3.append(1)"), 4);
2646        assert_eq!(error.code, "invalid-receiver");
2647        assert!(error.message.contains("append"));
2648
2649        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2650        assert_eq!(error.code, "invalid-receiver");
2651        assert!(error.message.contains("format"));
2652    }
2653
2654    #[test]
2655    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2656        // Domain mismatches need canonical Workshop enum knowledge: a member
2657        // of the wrong domain is carried as an opaque identity and the check
2658        // is lowering-dependent (#8).
2659        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2660        let HirExpr::Call { args, .. } = &expr else {
2661            panic!("expected a call, got {expr:?}");
2662        };
2663        assert!(matches!(
2664            &args[3],
2665            HirExpr::Enum { value_type, value, .. }
2666                if value_type == "Invis" && value == "ALL"
2667        ));
2668
2669        let expr = first_action_expr(&action_source(
2670            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2671        ));
2672        let HirExpr::ReceiverCall { args, .. } = &expr else {
2673            panic!("expected a receiver call, got {expr:?}");
2674        };
2675        assert!(matches!(
2676            &args[0],
2677            HirExpr::Enum { value_type, value, .. }
2678                if value_type == "ChaseTimeReeval" && value == "NONE"
2679        ));
2680    }
2681
2682    #[test]
2683    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2684        // Whether a non-enum value is acceptable for an enum parameter is
2685        // Workshop catalog knowledge; the frontend carries the argument
2686        // structurally and leaves the check to lowering (#8).
2687        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2688        let HirExpr::ReceiverCall { args, .. } = &expr else {
2689            panic!("expected a receiver call, got {expr:?}");
2690        };
2691        assert!(matches!(
2692            &args[0],
2693            HirExpr::GlobalVar { name, .. } if name == "g"
2694        ));
2695
2696        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2697        let HirExpr::ReceiverCall { args, .. } = &expr else {
2698            panic!("expected a receiver call, got {expr:?}");
2699        };
2700        assert!(matches!(&args[0], HirExpr::Number { .. }));
2701    }
2702
2703    #[test]
2704    fn unknown_builtins_fail_at_resolution_not_emission() {
2705        let error = compile_error(&action_source("frobnicate()"), 4);
2706        assert_eq!(error.code, "unknown-action");
2707
2708        let error = compile_error(&action_source("g = frobnicate()"), 4);
2709        assert_eq!(error.code, "unknown-value");
2710
2711        let error = compile_error(
2712            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
2713            4,
2714        );
2715        assert_eq!(error.code, "unknown-member");
2716    }
2717
2718    #[test]
2719    fn wright_only_catalog_names_are_rejected() {
2720        // `createHudText` and `squareRoot` are Workshop emission spellings,
2721        // not OPY source functions; the pinned reference rejects them, so
2722        // the manifest does not preserve the accidental acceptance.
2723        let error = compile_error(&action_source("createHudText(1)"), 4);
2724        assert_eq!(error.code, "unknown-action");
2725
2726        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2727        assert_eq!(error.code, "unknown-value");
2728    }
2729
2730    #[test]
2731    fn generic_member_only_actions_are_rejected() {
2732        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
2733        // member form is the reference surface.
2734        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2735        assert_eq!(error.code, "unknown-action");
2736    }
2737
2738    #[test]
2739    fn range_is_for_iterables_only() {
2740        // Standalone `range(...)` is rejected by the reference; the
2741        // for-header form keeps 1-3 arguments.
2742        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2743        assert_eq!(error.code, "invalid-call-context");
2744
2745        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
2746        assert_eq!(error.code, "invalid-iterable");
2747
2748        crate::compile(
2749            &action_source("for g in range(3):\n        debug(g)"),
2750            "test.opy",
2751            std::path::Path::new(""),
2752        )
2753        .expect("the for-header range form compiles");
2754    }
2755
2756    #[test]
2757    fn source_aliases_resolve_to_canonical_names() {
2758        // Non-contextual aliases rewrite to the canonical entry so identity,
2759        // position, and emission use the target name.
2760        let hir = crate::compile(
2761            &action_source("stopChasingVariable(g)"),
2762            "test.opy",
2763            std::path::Path::new(""),
2764        )
2765        .expect("the alias target compiles");
2766        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2767            panic!("expected a rule");
2768        };
2769        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2770            panic!("expected expression statement");
2771        };
2772        let HirExpr::Call { name, .. } = expr.as_ref() else {
2773            panic!("expected a call");
2774        };
2775        assert_eq!(name, "stopChasing");
2776
2777        let hir = crate::compile(
2778            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2779             @Condition eventPlayer.getCurrentHero() != null\n    \
2780             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
2781             disableInspector()\n",
2782            "test.opy",
2783            std::path::Path::new(""),
2784        )
2785        .expect("member aliases compile");
2786        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2787            panic!("expected a rule");
2788        };
2789        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2790            panic!("expected a comparison");
2791        };
2792        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2793            panic!("expected a receiver call");
2794        };
2795        assert_eq!(name, "getHero");
2796    }
2797
2798    #[test]
2799    fn unknown_catalog_enum_members_are_rejected() {
2800        for source in [
2801            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
2802            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
2803        ] {
2804            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2805                .expect_err("unknown catalog member must be rejected");
2806            assert_eq!(error.code, "unknown-enum-member");
2807        }
2808    }
2809
2810    #[test]
2811    fn default_var_for_binder_resolves_at_all_range_arities() {
2812        // The agent-lab regression: `for I in range(0, 10):` with `I` not
2813        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
2814        // the pinned reference accepts as an implicit global loop binder
2815        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
2816        for (binder, iterable) in [
2817            ("I", "range(0, 10)"),
2818            ("I", "range(3)"),
2819            ("I", "range(1, 5, 2)"),
2820        ] {
2821            let hir = lower_ok(&format!(
2822                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
2823            ));
2824            let (_, actions) = rule_conditions_and_actions(&hir);
2825            let HirStmt::For { variable, body, .. } = &actions[0] else {
2826                panic!("expected a for statement");
2827            };
2828            assert!(
2829                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2830                "the binder resolves to the implicit global 'I', got {variable:?}"
2831            );
2832            assert!(!body.is_empty(), "the loop body lowers");
2833            // The binder use in the body resolves too: `total += I` has a
2834            // GlobalVar operand.
2835            let HirStmt::Assign { value, .. } = &body[0] else {
2836                panic!("expected an assignment in the body");
2837            };
2838            let HirExpr::Binary { right, .. } = value.as_ref() else {
2839                panic!("expected a binary expression");
2840            };
2841            assert!(
2842                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2843                "the binder use inside the body resolves to the implicit global"
2844            );
2845        }
2846    }
2847
2848    #[test]
2849    fn default_var_names_resolve_as_implicit_globals() {
2850        // Default variable names resolve anywhere a variable may appear,
2851        // matching the pinned reference (no `globalvar` declaration needed).
2852        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
2853        let (_, actions) = rule_conditions_and_actions(&hir);
2854        let HirStmt::Assign { target, .. } = &actions[0] else {
2855            panic!("expected an assignment");
2856        };
2857        assert!(
2858            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2859            "the implicit global resolves, got {target:?}"
2860        );
2861        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
2862        assert_eq!(default_var_index("I"), Some(8));
2863        assert_eq!(default_var_index("AA"), Some(26));
2864        assert_eq!(default_var_index("Z"), Some(25));
2865        assert_eq!(default_var_index("DX"), Some(127));
2866        assert_eq!(default_var_index("DY"), None);
2867        assert_eq!(default_var_index("i"), None);
2868    }
2869
2870    #[test]
2871    fn nested_same_name_for_binders_reuse_the_implicit_global() {
2872        // Nested loops with the same default-var binder reuse the single
2873        // implicit variable, matching the pinned reference (the inner loop
2874        // overwrites the same Workshop global — no separate binding).
2875        let hir = lower_ok(
2876            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
2877        );
2878        let (_, actions) = rule_conditions_and_actions(&hir);
2879        let HirStmt::For {
2880            variable: outer,
2881            body,
2882            ..
2883        } = &actions[0]
2884        else {
2885            panic!("expected an outer for statement");
2886        };
2887        let HirStmt::For {
2888            variable: inner, ..
2889        } = &body[0]
2890        else {
2891            panic!("expected an inner for statement");
2892        };
2893        assert!(
2894            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2895                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2896            "both loops bind the same implicit global (spans differ per binder site)"
2897        );
2898    }
2899
2900    #[test]
2901    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2902        // A lowercase undeclared binder is not a default variable name; the
2903        // pinned reference rejects the program ("Unknown function name"), and
2904        // Wright reports the same reject with the structured
2905        // `unknown-identifier` diagnostic (#114).
2906        let error = compile_error(
2907            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
2908            3,
2909        );
2910        assert_eq!(error.code, "unknown-identifier");
2911        let span = error.span.expect("the error is source-located");
2912        assert_eq!(span.start.line, 3);
2913    }
2914
2915    #[test]
2916    fn issue_28_constructs_lower_to_provenance_preserving_hir() {
2917        let hir = lower_ok(
2918            "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",
2919        );
2920        let (_, actions) = rule_conditions_and_actions(&hir);
2921        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2922            panic!("expected do-while");
2923        };
2924        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2925        let HirStmt::Switch { arms, .. } = &actions[1] else {
2926            panic!("expected switch");
2927        };
2928        assert_eq!(arms.len(), 2);
2929        let HirSwitchArm::Case {
2930            value: case_value,
2931            body,
2932            ..
2933        } = &arms[0]
2934        else {
2935            panic!("expected case arm");
2936        };
2937        assert!(
2938            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2939        );
2940        let HirStmt::Assign { value, .. } = &body[0] else {
2941            panic!("expected case assignment");
2942        };
2943        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2944        let HirSwitchArm::Default { body, .. } = &arms[1] else {
2945            panic!("expected default arm");
2946        };
2947        assert!(matches!(body[0], HirStmt::Assign { .. }));
2948        let HirStmt::Assign { value, .. } = &actions[2] else {
2949            panic!("expected comprehension assignment");
2950        };
2951        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2952        let HirStmt::Assign { value, .. } = &actions[3] else {
2953            panic!("expected sorted assignment");
2954        };
2955        assert!(
2956            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")))
2957        );
2958        let HirStmt::Assign { value, .. } = &actions[4] else {
2959            panic!("expected string assignment");
2960        };
2961        assert!(
2962            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2963        );
2964    }
2965
2966    #[test]
2967    fn issue_28_rejects_reference_invalid_bare_dict_and_lambda() {
2968        let dict_error = compile_error(
2969            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
2970            4,
2971        );
2972        assert_eq!(dict_error.code, "dict-access");
2973        let lambda_error = compile_error(
2974            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
2975            4,
2976        );
2977        assert_eq!(lambda_error.code, "lambda-context");
2978    }
2979
2980    #[test]
2981    fn do_while_requires_rule_or_definition_prefix_position() {
2982        let error = compile_error(
2983            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
2984            5,
2985        );
2986        assert_eq!(error.code, "do-while-placement");
2987        assert_eq!(
2988            error.message,
2989            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2990        );
2991    }
2992}