Skip to main content

opy_rs/
lower.rs

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