Skip to main content

opy_rs/
lower.rs

1//! Semantic resolution and HIR lowering (#45).
2//!
3//! Resolves the parsed CST into the opy-rs-owned Opy HIR contract
4//! ([`crate::hir::Program`]): declarations and references resolve to typed
5//! HIR nodes, custom enums fold to constants, `vect` becomes a vector,
6//! `.format()` becomes a format node, `wait` default arguments are filled,
7//! and subroutine calls become `CallSubroutine` statements. Semantic errors
8//! (unknown identifiers, unknown custom-enum members, invalid `vect` arity)
9//! are structured and source-located.
10//!
11//! Builtin action/value/member identity, action/value position, signatures
12//! and arity, receiver categories, parameter enum-domain identities, and
13//! non-contextual source aliases resolve through the OPY semantic
14//! compatibility manifest ([`crate::manifest`], issue #109) before Workshop
15//! emission: unknown or misplaced builtins fail here with structured,
16//! source-located diagnostics instead of surfacing as emitter catalog
17//! misses.
18//!
19//! Ownership boundary: enum *domains* are catalog-identity links carried by
20//! the manifest signatures, but enum *member lists* are Workshop-owned
21//! catalog content. A member access on a declared domain identity resolves
22//! as an opaque `Enum` node and member-existence/domain checks
23//! (`unknown-enum-member` for Workshop enums, `enum-domain-mismatch`) are
24//! `lowering-dependent` (issue #8) — they are not approximated here. Custom
25//! (user-declared) enum member checks are OPY-level source semantics and
26//! stay in this frontend.
27
28use std::collections::{HashMap, HashSet};
29
30use crate::hir::types::{
31    Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define,
32    DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, PROTOCOL_VERSION,
33    Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry,
34    Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan,
35    Stmt as HirStmt, SwitchArm as HirSwitchArm, default_var_index,
36};
37
38use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt, TopLevel};
39use crate::diag::{OpyError, OpyResult, Span};
40use crate::manifest::{Function, FunctionKind, Manifest, Param, ParamDefault};
41use workshop_rs::catalog::{Catalog, Locale};
42
43/// The protocol envelope this frontend produces.
44const PROTOCOL_NAME: &str = "wright/opy-hir";
45
46/// The call-position context of an expression being lowered; builtin
47/// resolution checks action/value identity against this context.
48#[derive(Clone, Copy, PartialEq, Eq)]
49enum CallPosition {
50    /// A statement position (a bare expression statement).
51    Statement,
52    /// A value position (conditions, assignments, call arguments, …).
53    Value,
54    /// A `for ... in` iterable (only `range` is a valid builtin here).
55    ForIterable,
56    /// An expression occupying a signature-approved lambda argument slot.
57    LambdaArgument,
58    /// An expression in a macro body, whose final call position is determined
59    /// when the macro is expanded.
60    MacroBody,
61}
62
63/// The lowerer's symbol context, built from the CST declarations.
64struct Lowerer {
65    global_declarations: HashMap<String, usize>,
66    player_declarations: HashMap<String, usize>,
67    subroutine_declarations: HashMap<String, usize>,
68    subroutine_definitions: Vec<(String, usize)>,
69    constant_declarations: HashMap<String, usize>,
70    macro_declarations: HashMap<String, usize>,
71    enums: HashMap<String, Vec<String>>,
72    enum_declarations: HashMap<String, usize>,
73    locals: Vec<String>,
74    current_order: usize,
75    allow_dict_literal: bool,
76    /// The authoritative builtin semantic table (issue #109).
77    manifest: &'static Manifest,
78    /// The canonical Workshop catalog linked by the manifest.
79    catalog: Catalog,
80    texture_used: bool,
81    setup_tags: bool,
82    errors: Vec<OpyError>,
83}
84
85impl Lowerer {
86    fn new(manifest: &'static Manifest, catalog: Catalog) -> Self {
87        Self {
88            global_declarations: HashMap::new(),
89            player_declarations: HashMap::new(),
90            subroutine_declarations: HashMap::new(),
91            subroutine_definitions: Vec::new(),
92            constant_declarations: HashMap::new(),
93            macro_declarations: HashMap::new(),
94            enums: HashMap::new(),
95            enum_declarations: HashMap::new(),
96            locals: Vec::new(),
97            current_order: 0,
98            allow_dict_literal: false,
99            manifest,
100            catalog,
101            texture_used: false,
102            setup_tags: false,
103            errors: Vec::new(),
104        }
105    }
106}
107
108mod declarations;
109mod expressions;
110pub(crate) mod policy;
111mod special_forms;
112mod statements;
113mod textures;
114
115pub(crate) fn compressed_component_mode(values: &[Vec<f64>]) -> Option<(bool, f64)> {
116    let component_count = values.first()?.len();
117    let is_vector = component_count == 3;
118    (matches!(component_count, 1 | 3) && values.iter().all(|value| value.len() == component_count))
119        .then_some((is_vector, if is_vector { 4999.0 } else { 49999.0 }))
120}
121
122/// Lower a parsed program into the Opy HIR contract.
123pub fn lower(
124    program: &cst::Program,
125    files: Vec<SourceFile>,
126    defines: Vec<Define>,
127) -> OpyResult<HirProgram> {
128    lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
129}
130
131pub fn lower_with_preprocessing(
132    program: &cst::Program,
133    files: Vec<SourceFile>,
134    defines: Vec<Define>,
135    preprocessing: &PreprocessingState,
136) -> OpyResult<HirProgram> {
137    let manifest = match Manifest::builtin() {
138        Ok(manifest) => manifest,
139        Err(error) => {
140            return Err(OpyError::new(
141                "manifest-error",
142                format!("cannot load the OPY semantic compatibility manifest: {error}"),
143            ));
144        }
145    };
146    let catalog = match Catalog::builtin() {
147        Ok(catalog) => catalog,
148        Err(error) => {
149            return Err(OpyError::new(
150                "catalog-error",
151                format!("cannot load the Workshop catalog: {error}"),
152            ));
153        }
154    };
155    lower_program(program, files, defines, preprocessing, manifest, catalog)
156}
157
158fn lower_program(
159    program: &cst::Program,
160    files: Vec<SourceFile>,
161    defines: Vec<Define>,
162    preprocessing: &PreprocessingState,
163    manifest: &'static Manifest,
164    catalog: Catalog,
165) -> OpyResult<HirProgram> {
166    let mut lowerer = Lowerer::new(manifest, catalog);
167    lowerer.setup_tags = preprocessing
168        .directives
169        .iter()
170        .any(|directive| matches!(directive.name.as_str(), "setupTags" | "setupTx"));
171    lowerer.collect_symbols(program);
172
173    let mut declarations = Vec::new();
174    let mut rules = Vec::new();
175    let mut implicit_subroutines = HashSet::new();
176    for (order, item) in program.top_level.iter().enumerate() {
177        lowerer.current_order = order;
178        match item {
179            TopLevel::Declaration(decl) => {
180                if let Some(declaration) = lowerer.lower_declaration(decl) {
181                    declarations.push(declaration);
182                }
183            }
184            TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
185                lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
186            )),
187            TopLevel::Rule(CstRuleEntry::SubroutineDef {
188                name,
189                presentation_name,
190                span,
191                name_span,
192                body,
193                annotations,
194                rule_prefix,
195            }) => {
196                if !lowerer.subroutine_declarations.contains_key(name)
197                    && implicit_subroutines.insert(name.clone())
198                {
199                    declarations.push(Declaration::Subroutine {
200                        name: name.clone(),
201                        index: None,
202                        span: Some(span.into()),
203                        name_span: Some(name_span.into()),
204                    });
205                }
206                let base_name = presentation_name
207                    .as_deref()
208                    .map(str::to_string)
209                    .unwrap_or_else(|| name.clone());
210                let generated_name = render_rule_name(
211                    &base_name,
212                    rule_prefix.as_deref(),
213                    false,
214                    *span,
215                    files.as_slice(),
216                    preprocessing,
217                )?;
218                rules.push(RuleEntry::SubroutineDef {
219                    kind: "subroutineDef".to_string(),
220                    name: generated_name,
221                    source_name: name.clone(),
222                    span: Some(span.into()),
223                    name_span: Some(name_span.into()),
224                    body: lowerer.lower_block(body, &[], false, true, false),
225                    annotations: lower_annotations(annotations),
226                });
227            }
228        }
229    }
230
231    if !lowerer.errors.is_empty() {
232        return Err(lowerer.errors.swap_remove(0));
233    }
234
235    if lowerer.texture_used || lowerer.setup_tags {
236        declarations.insert(
237            0,
238            Declaration::GlobalVariable {
239                name: "__holygrail__".to_string(),
240                index: Some(127),
241                span: None,
242                name_span: None,
243                initializer: None,
244            },
245        );
246        rules.insert(0, texture_setup_rule());
247    }
248
249    Ok(HirProgram {
250        protocol: Protocol {
251            name: PROTOCOL_NAME.to_string(),
252            version: PROTOCOL_VERSION.to_string(),
253        },
254        generator: Generator {
255            name: crate::LANGUAGE_NAME.to_string(),
256            version: crate::LANGUAGE_VERSION.to_string(),
257            frontend: crate::LANGUAGE_NAME.to_string(),
258        },
259        files,
260        defines,
261        declarations,
262        rules,
263        settings: program.settings.as_ref().map(lower_settings),
264        preprocessing: preprocessing.clone(),
265    })
266}
267
268fn texture_setup_rule() -> RuleEntry {
269    RuleEntry::Rule(Rule {
270        name: format!("OverPy <{}tx> / <{}fg> setup code", '\u{00ad}', '\u{00ad}'),
271        span: None,
272        name_span: None,
273        disabled: false,
274        delimiter: false,
275        new_page: None,
276        annotations: Vec::new(),
277        event: Event {
278            name: "global".to_string(),
279            args: Vec::new(),
280            span: None,
281        },
282        conditions: Vec::new(),
283        actions: vec![
284            synthetic_statement(synthetic_call(
285                "createDummy",
286                vec![
287                    synthetic_call("getAllHeroes", Vec::new()),
288                    texture_dummy_team(),
289                    synthetic_boolean(false),
290                    HirExpr::Null { span: None },
291                    HirExpr::Null { span: None },
292                ],
293            )),
294            synthetic_statement(synthetic_method(
295                texture_dummy_player(),
296                "startForcingName",
297                vec![texture_marker('\u{303c}')],
298            )),
299            texture_state_assignment(synthetic_method(
300                texture_dummy_first_value(),
301                "split",
302                vec![HirExpr::Array {
303                    elements: Vec::new(),
304                    span: None,
305                }],
306            )),
307            synthetic_statement(synthetic_method(
308                texture_dummy_player(),
309                "startForcingName",
310                vec![texture_marker('\u{840}')],
311            )),
312            texture_state_assignment(synthetic_method(
313                synthetic_method(
314                    texture_marker('\u{303c}'),
315                    "replace",
316                    vec![texture_state_variable(), texture_dummy_first_value()],
317                ),
318                "substring",
319                vec![synthetic_number(126.0), synthetic_boolean(true)],
320            )),
321            synthetic_statement(synthetic_call("destroyAllDummies", Vec::new())),
322        ],
323    })
324}
325
326fn synthetic_call(name: &str, args: Vec<HirExpr>) -> HirExpr {
327    HirExpr::Call {
328        name: name.to_string(),
329        args,
330        debug_source: None,
331        span: None,
332    }
333}
334
335fn synthetic_method(receiver: HirExpr, name: &str, args: Vec<HirExpr>) -> HirExpr {
336    HirExpr::ReceiverCall {
337        receiver: Box::new(receiver),
338        name: name.to_string(),
339        args,
340        span: None,
341    }
342}
343
344fn synthetic_enum(value_type: &str, value: &str) -> HirExpr {
345    HirExpr::Enum {
346        value_type: value_type.to_string(),
347        value: value.to_string(),
348        span: None,
349    }
350}
351
352fn synthetic_number(value: f64) -> HirExpr {
353    HirExpr::Number {
354        value,
355        text: value.to_string(),
356        span: None,
357    }
358}
359
360fn synthetic_boolean(value: bool) -> HirExpr {
361    HirExpr::Bool { value, span: None }
362}
363
364fn synthetic_index(array: HirExpr, index: HirExpr) -> HirExpr {
365    HirExpr::Index {
366        array: Box::new(array),
367        index: Box::new(index),
368        span: None,
369    }
370}
371
372fn synthetic_conditional(then_value: HirExpr, condition: HirExpr, else_value: HirExpr) -> HirExpr {
373    HirExpr::Conditional {
374        then_value: Box::new(then_value),
375        condition: Box::new(condition),
376        else_value: Box::new(else_value),
377        span: None,
378    }
379}
380
381fn synthetic_statement(expr: HirExpr) -> HirStmt {
382    HirStmt::Expr {
383        expr: Box::new(expr),
384        span: None,
385    }
386}
387
388fn texture_state_variable() -> HirExpr {
389    HirExpr::GlobalVar {
390        name: "__holygrail__".to_string(),
391        span: None,
392    }
393}
394
395fn texture_state_assignment(value: HirExpr) -> HirStmt {
396    HirStmt::Assign {
397        target: Box::new(texture_state_variable()),
398        value: Box::new(value),
399        span: None,
400    }
401}
402
403fn texture_dummy_player() -> HirExpr {
404    synthetic_call("getLastCreatedEntity", Vec::new())
405}
406
407fn texture_dummy_first_value() -> HirExpr {
408    synthetic_index(texture_dummy_player(), synthetic_number(0.0))
409}
410
411fn texture_dummy_team() -> HirExpr {
412    let team_1 = synthetic_enum("Team", "TEAM_1");
413    let team_2 = synthetic_enum("Team", "TEAM_2");
414    synthetic_conditional(
415        team_1.clone(),
416        synthetic_call("getNumberOfSlots", vec![team_1]),
417        synthetic_conditional(
418            team_2.clone(),
419            synthetic_call("getNumberOfSlots", vec![team_2]),
420            synthetic_boolean(true),
421        ),
422    )
423}
424
425fn texture_marker(suffix: char) -> HirExpr {
426    HirExpr::String {
427        value: format!("{}{}", "_".repeat(126), suffix),
428        span: None,
429    }
430}
431
432/// Lower a settings expression through the same CST-to-HIR semantic path as
433/// ordinary OPY expressions.
434pub(crate) fn lower_settings_expression(
435    program: &cst::Program,
436    text: &str,
437    file: u32,
438    origin: crate::diag::Position,
439) -> OpyResult<HirExpr> {
440    let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
441    let manifest = Manifest::builtin().map_err(|error| {
442        OpyError::new(
443            "manifest-error",
444            format!("cannot load the OPY semantic compatibility manifest: {error}"),
445        )
446    })?;
447    let catalog = Catalog::builtin().map_err(|error| {
448        OpyError::new(
449            "catalog-error",
450            format!("cannot load the Workshop catalog: {error}"),
451        )
452    })?;
453    let mut lowerer = Lowerer::new(manifest, catalog);
454    lowerer.current_order = program.top_level.len();
455    lowerer.allow_dict_literal = true;
456    lowerer.collect_symbols(program);
457    let lowered = lowerer.lower_expr(&expression, &[], CallPosition::Value);
458    lowerer.errors.into_iter().next().map_or(Ok(lowered), Err)
459}
460
461fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
462    match prefix {
463        Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
464            format!("[{prefix}] {name}")
465        }
466        _ => name.to_string(),
467    }
468}
469
470pub(crate) fn strip_rule_name_formatting(text: &str) -> impl Iterator<Item = char> + '_ {
471    text.chars().filter(|character| {
472        !matches!(
473            character,
474            '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}' | '\u{061C}'
475        )
476    })
477}
478
479#[derive(Clone, Debug)]
480enum TemplateValue {
481    String(String),
482    Bool(bool),
483}
484
485fn rule_template_values(
486    name: &str,
487    prefix: &str,
488    file: &str,
489    path: &str,
490    delimiter: bool,
491) -> [(&'static str, TemplateValue); 14] {
492    [
493        ("$rule", TemplateValue::String(name.to_string())),
494        ("$prefix", TemplateValue::String(prefix.to_string())),
495        ("$file", TemplateValue::String(file.to_string())),
496        ("$path", TemplateValue::String(path.to_string())),
497        ("$isDelimiter", TemplateValue::Bool(delimiter)),
498        ("$prefixTitle", TemplateValue::String(title_case(prefix))),
499        ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
500        ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
501        ("$fileTitle", TemplateValue::String(title_case(file))),
502        ("$fileUpper", TemplateValue::String(file.to_uppercase())),
503        ("$fileLower", TemplateValue::String(file.to_lowercase())),
504        ("$pathTitle", TemplateValue::String(title_case(path))),
505        ("$pathUpper", TemplateValue::String(path.to_uppercase())),
506        ("$pathLower", TemplateValue::String(path.to_lowercase())),
507    ]
508}
509
510fn render_rule_name(
511    name: &str,
512    prefix: Option<&str>,
513    delimiter: bool,
514    span: Span,
515    files: &[SourceFile],
516    preprocessing: &PreprocessingState,
517) -> OpyResult<String> {
518    let Some(template) = preprocessing
519        .rule_prefix_template
520        .as_ref()
521        .map(|value| value.value.as_str())
522    else {
523        return Ok(
524            strip_rule_name_formatting(&prefixed_rule_name(name, prefix, delimiter)).collect(),
525        );
526    };
527    let (file, path) = rule_file_parts(span.file, files);
528    let prefix = prefix.unwrap_or_default();
529    let values = rule_template_values(name, prefix, &file, &path, delimiter);
530    evaluate_template(template, &values)
531        .map(|name| strip_rule_name_formatting(&name).collect())
532        .map_err(|message| {
533            OpyError::at(
534                "rule-prefix-template-invalid",
535                format!("could not resolve rule prefix template: {message}"),
536                span,
537            )
538        })
539}
540
541/// The name of a compiler-generated rule under the active rule prefix
542/// template: it has no prefix, no file, and is not a delimiter.
543pub(crate) fn render_generated_rule_name(name: &str, preprocessing: &PreprocessingState) -> String {
544    let Some(template) = preprocessing
545        .rule_prefix_template
546        .as_ref()
547        .map(|value| value.value.as_str())
548    else {
549        return name.to_string();
550    };
551    let values = rule_template_values(name, "", "", "", false);
552    evaluate_template(template, &values)
553        .map(|rendered| strip_rule_name_formatting(&rendered).collect())
554        .unwrap_or_else(|_| name.to_string())
555}
556
557fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
558    let path = files
559        .iter()
560        .find(|file| file.id == file_id)
561        .map(|file| file.path.replace('\\', "/"))
562        .unwrap_or_default();
563    let without_extension = path
564        .strip_suffix(".opy")
565        .or_else(|| path.strip_suffix(".OPY"))
566        .unwrap_or(&path)
567        .to_string();
568    let file = without_extension
569        .rsplit('/')
570        .next()
571        .unwrap_or_default()
572        .to_string();
573    (file, without_extension)
574}
575
576fn title_case(value: &str) -> String {
577    let mut result = String::with_capacity(value.len());
578    let mut capitalize = true;
579    for ch in value.chars() {
580        if ch == '_' {
581            result.push(' ');
582            capitalize = true;
583        } else if capitalize && ch.is_ascii_alphabetic() {
584            result.push(ch.to_ascii_uppercase());
585            capitalize = false;
586        } else {
587            result.push(ch);
588            if !ch.is_whitespace() && ch != '/' {
589                capitalize = false;
590            }
591        }
592        if ch == '/' || ch.is_whitespace() {
593            capitalize = true;
594        }
595    }
596    result
597}
598
599fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
600    if let Some((then_value, condition, else_value)) = split_conditional(template) {
601        let branch = if evaluate_condition(condition, values)? {
602            then_value
603        } else {
604            else_value
605        };
606        return evaluate_string(branch, values);
607    }
608    evaluate_string(template, values)
609}
610
611fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
612    let mut quote = None;
613    let mut depth = 0usize;
614    let mut if_start = None;
615    let mut else_start = None;
616    for (index, ch) in value.char_indices() {
617        match (ch, quote) {
618            ('"' | '\'', None) => quote = Some(ch),
619            (ch, Some(current)) if ch == current => quote = None,
620            ('{', None) => depth += 1,
621            ('}', None) => depth = depth.saturating_sub(1),
622            _ => {}
623        }
624        if quote.is_none() && depth == 0 {
625            if value[index..].starts_with(" if ") && if_start.is_none() {
626                if_start = Some(index);
627            } else if value[index..].starts_with(" else ") && else_start.is_none() {
628                else_start = Some(index);
629            }
630        }
631    }
632    let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
633        return None;
634    };
635    Some((
636        value[..if_start].trim(),
637        value[if_start + 4..else_start].trim(),
638        value[else_start + 6..].trim(),
639    ))
640}
641
642fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
643    let value = value.trim();
644    if let Some(rest) = value.strip_prefix("not ") {
645        return Ok(!evaluate_condition(rest, values)?);
646    }
647    if let Some((left, right)) = value.split_once(" or ") {
648        return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
649    }
650    if let Some((left, right)) = value.split_once(" and ") {
651        return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
652    }
653    match lookup_template_value(value, values)? {
654        TemplateValue::Bool(value) => Ok(value),
655        TemplateValue::String(value) => Ok(!value.is_empty()),
656    }
657}
658
659fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
660    let value = value.trim();
661    if let Some(body) = value
662        .strip_prefix("f\"")
663        .and_then(|body| body.strip_suffix('"'))
664    {
665        return interpolate_fstring(body, values);
666    }
667    if let Some(body) = value
668        .strip_prefix("f'")
669        .and_then(|body| body.strip_suffix('\''))
670    {
671        return interpolate_fstring(body, values);
672    }
673    if value.len() >= 2
674        && ((value.starts_with('"') && value.ends_with('"'))
675            || (value.starts_with('\'') && value.ends_with('\'')))
676    {
677        return Ok(value[1..value.len() - 1].to_string());
678    }
679    match lookup_template_value(value, values)? {
680        TemplateValue::String(value) => Ok(value),
681        TemplateValue::Bool(value) => Ok(value.to_string()),
682    }
683}
684
685fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
686    let mut result = String::new();
687    let mut remaining = body;
688    while let Some(start) = remaining.find('{') {
689        result.push_str(&remaining[..start]);
690        let end = remaining[start + 1..]
691            .find('}')
692            .ok_or_else(|| "unterminated interpolation".to_string())?
693            + start
694            + 1;
695        result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
696        remaining = &remaining[end + 1..];
697    }
698    result.push_str(remaining);
699    Ok(result)
700}
701
702fn lookup_template_value(
703    value: &str,
704    values: &[(&str, TemplateValue)],
705) -> Result<TemplateValue, String> {
706    let value = value.trim();
707    let (base, mut methods) = value
708        .split_once('.')
709        .map_or((value, ""), |(base, methods)| (base, methods));
710    let mut result = values
711        .iter()
712        .find(|(name, _)| *name == base)
713        .map(|(_, value)| value.clone())
714        .ok_or_else(|| format!("unsupported expression '{value}'"))?;
715    while !methods.is_empty() {
716        let (method, rest) = methods
717            .split_once('.')
718            .map_or((methods, ""), |(method, rest)| (method, rest));
719        if method == "upper()" {
720            result = TemplateValue::String(as_string(&result).to_uppercase());
721        } else if method == "lower()" {
722            result = TemplateValue::String(as_string(&result).to_lowercase());
723        } else if let Some(args) = method
724            .strip_prefix("replace(")
725            .and_then(|v| v.strip_suffix(')'))
726        {
727            let (from, to) = args
728                .split_once(',')
729                .ok_or_else(|| "replace expects two arguments".to_string())?;
730            let from = unquote_template_arg(from.trim())?;
731            let to = unquote_template_arg(to.trim())?;
732            result = TemplateValue::String(as_string(&result).replace(&from, &to));
733        } else {
734            return Err(format!("unsupported method '{method}'"));
735        }
736        methods = rest;
737    }
738    Ok(result)
739}
740
741fn as_string(value: &TemplateValue) -> String {
742    match value {
743        TemplateValue::String(value) => value.clone(),
744        TemplateValue::Bool(value) => value.to_string(),
745    }
746}
747
748fn unquote_template_arg(value: &str) -> Result<String, String> {
749    if value.len() >= 2
750        && ((value.starts_with('"') && value.ends_with('"'))
751            || (value.starts_with('\'') && value.ends_with('\'')))
752    {
753        Ok(value[1..value.len() - 1].to_string())
754    } else {
755        Err(format!("expected a quoted string argument, got '{value}'"))
756    }
757}
758
759fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
760    annotations
761        .iter()
762        .map(|annotation| HirAnnotation {
763            name: annotation.name.clone(),
764            args: annotation
765                .args
766                .iter()
767                .map(|arg| HirAnnotationArg {
768                    text: arg.text.clone(),
769                    span: Some(arg.span.into()),
770                })
771                .collect(),
772            span: Some(annotation.span.into()),
773        })
774        .collect()
775}
776
777/// Map a parsed CST settings block onto the protocol settings tree (#86).
778fn lower_settings(settings: &cst::Settings) -> HirSettings {
779    HirSettings {
780        span: Some(settings.span.into()),
781        children: settings.children.iter().map(lower_settings_node).collect(),
782    }
783}
784
785fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
786    match node {
787        cst::SettingsNode::Group {
788            name,
789            children,
790            span,
791        } => HirSettingsNode::Group {
792            name: name.clone(),
793            children: children.iter().map(lower_settings_node).collect(),
794            span: Some((*span).into()),
795        },
796        cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
797            name: name.clone(),
798            value: *value,
799            span: Some((*span).into()),
800        },
801        cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
802            name: name.clone(),
803            value: *value,
804            span: Some((*span).into()),
805        },
806        cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
807            name: name.clone(),
808            value: value.clone(),
809            span: Some((*span).into()),
810        },
811        cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
812            name: name.clone(),
813            value: value.clone(),
814            span: Some((*span).into()),
815        },
816        cst::SettingsNode::List {
817            name,
818            elements,
819            span,
820        } => HirSettingsNode::List {
821            name: name.clone(),
822            elements: elements
823                .iter()
824                .map(|element| crate::hir::types::SettingsListElement {
825                    value: element.value.clone(),
826                    span: Some(element.span.into()),
827                })
828                .collect(),
829            span: Some((*span).into()),
830        },
831    }
832}
833
834impl Lowerer {
835    fn error_at(&mut self, code: &str, message: String, span: Span) {
836        self.errors.push(OpyError::at(code, message, span));
837    }
838}
839
840/// The keyword spellings a parameter accepts (its name plus alternates).
841fn keyword_spellings(param: &Param) -> Vec<String> {
842    let mut spellings = vec![param.name.clone()];
843    spellings.extend(param.alternate_names.iter().cloned());
844    spellings
845}
846
847/// The source span covering a call's argument list (the start of the first
848/// argument through the last argument).
849fn arg_span(args: &[CallArg]) -> Span {
850    args.first().map(CallArg::span).unwrap_or_else(|| {
851        Span::new(
852            0,
853            crate::diag::Position::new(1, 1),
854            crate::diag::Position::new(1, 1),
855        )
856    })
857}
858
859/// Lower a source-level player context value without adding a new HIR node
860/// kind. `attacker` and `victim` are catalog-backed zero-argument values;
861/// using the existing `Call` node keeps their source identity and span while
862/// leaving canonical value ownership with Workshop.
863fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
864    match name {
865        "eventPlayer" => Some(HirExpr::EventPlayer {
866            span: span.map(Into::into),
867        }),
868        "localPlayer" | "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
869            name: name.to_string(),
870            args: Vec::new(),
871            debug_source: None,
872            span: span.map(Into::into),
873        }),
874        "hostPlayer" => Some(HirExpr::HostPlayer {
875            span: span.map(Into::into),
876        }),
877        _ => None,
878    }
879}
880
881/// The declaration namespace an identifier would occupy: `globalvar`,
882/// `playervar`, or `subroutine`/`def`. The pinned upstream compiler reserves
883/// a different name set per namespace, so a name refused for a `globalvar`
884/// can still be a legal `playervar` name.
885#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum NameNamespace {
887    /// `globalvar` declarations — upstream `reservedNames`.
888    Global,
889    /// `playervar` declarations — upstream `reservedMemberNames`.
890    Player,
891    /// `subroutine`/`def` declarations — upstream `reservedSubroutineNames`.
892    Subroutine,
893}
894
895/// Whether the pinned upstream compiler (OverPy 9.7.10) reserves `name` for
896/// `namespace` declarations — such a declaration is never valid source.
897///
898/// Mirrors the upstream reservation rules: globals reserve the grammar
899/// keywords, the type lattice (`typeMatrix`), `AsyncBehavior`, and the
900/// bare-spelled builtins (`funcKw` entries with `args === null`: context
901/// names, literals, `ruleCondition`, …); subroutines reserve the keywords
902/// plus builtins callable with an empty argument list (`args.length === 0`,
903/// mirrored here by manifest functions that declare no parameters); player
904/// variables reserve only the member axis names `x`, `y`, `z`.
905pub fn is_reserved_name(name: &str, namespace: NameNamespace) -> bool {
906    match namespace {
907        NameNamespace::Player => matches!(name, "x" | "y" | "z"),
908        NameNamespace::Global => {
909            RESERVED_KEYWORDS.contains(&name) || RESERVED_GLOBAL_NAMES.contains(&name)
910        }
911        NameNamespace::Subroutine => {
912            RESERVED_KEYWORDS.contains(&name)
913                || Manifest::builtin().ok().is_some_and(|manifest| {
914                    manifest
915                        .resolve_function(name)
916                        .is_some_and(|function| function.params.is_empty())
917                })
918        }
919    }
920}
921
922/// The upstream `opyKeywords` set (OverPy 9.7.10): grammar keywords reserved
923/// in every declaration namespace.
924const RESERVED_KEYWORDS: &[&str] = &[
925    "and",
926    "bool",
927    "case",
928    "def",
929    "default",
930    "del",
931    "elif",
932    "else",
933    "enum",
934    "float",
935    "for",
936    "globalvar",
937    "goto",
938    "if",
939    "in",
940    "int",
941    "lambda",
942    "loc",
943    "macro",
944    "not",
945    "or",
946    "playervar",
947    "rule",
948    "self",
949    "settings",
950    "signed",
951    "subroutine",
952    "switch",
953    "unsigned",
954    "while",
955];
956
957/// Names upstream reserves for `globalvar` beyond the keyword set (OverPy
958/// 9.7.10 `reservedNames`): the type lattice (`typeMatrix` keys), and the
959/// bare-spelled builtins (`funcKw` entries with `args === null`) — context
960/// names, literals, and `ruleCondition`.
961const RESERVED_GLOBAL_NAMES: &[&str] = &[
962    "AccelReeval",
963    "Array",
964    "AssistId",
965    "AssistReeval",
966    "AsyncBehavior",
967    "BarrierLos",
968    "Beam",
969    "BoolLiteral",
970    "Button",
971    "ButtonLiteral",
972    "ChaseRateReeval",
973    "ChaseTimeReeval",
974    "Clip",
975    "Color",
976    "ColorLiteral",
977    "Comms",
978    "CustomStringLiteral",
979    "DamageModificationId",
980    "DamageReeval",
981    "Dict",
982    "DictElem",
983    "DictKey",
984    "Direction",
985    "DotId",
986    "DynamicEffect",
987    "Effect",
988    "EffectReeval",
989    "EntityId",
990    "FacingReeval",
991    "FloatLiteral",
992    "Gamemode",
993    "GamemodeLiteral",
994    "GlobalVariable",
995    "HealingModificationId",
996    "HealingReeval",
997    "Health",
998    "HealthPoolId",
999    "Hero",
1000    "HeroLiteral",
1001    "HeroStat",
1002    "HotId",
1003    "HudPosition",
1004    "HudReeval",
1005    "Icon",
1006    "IconReeval",
1007    "Impulse",
1008    "IntLiteral",
1009    "Invis",
1010    "Label",
1011    "Lambda",
1012    "LocalizedStringLiteral",
1013    "LosCheck",
1014    "Map",
1015    "MapLiteral",
1016    "ModifyHealth",
1017    "Object",
1018    "OutlineVisibility",
1019    "Player",
1020    "PlayerVariable",
1021    "Position",
1022    "ProgressHudReeval",
1023    "ProgressWorldTextReeval",
1024    "Projectile",
1025    "ProjectileEffectReeval",
1026    "Raycast",
1027    "Relativity",
1028    "SignedFloatLiteral",
1029    "SignedIntLiteral",
1030    "SpecVisibility",
1031    "StartRuleBehavior",
1032    "Stat",
1033    "Status",
1034    "String",
1035    "StringLiteral",
1036    "Subroutine",
1037    "Team",
1038    "TeamLiteral",
1039    "TextId",
1040    "Throttle",
1041    "ThrottleReeval",
1042    "Transform",
1043    "Type",
1044    "UnsignedFloatLiteral",
1045    "UnsignedIntLiteral",
1046    "Value",
1047    "Vector",
1048    "Velocity",
1049    "Wait",
1050    "WorldTextReeval",
1051    "__ChaseReeval__",
1052    "__Operation__",
1053    "__Operator__",
1054    "__Rounding__",
1055    "__global__",
1056    "attacker",
1057    "break",
1058    "continue",
1059    "eventAbility",
1060    "eventDamage",
1061    "eventDirection",
1062    "eventHealing",
1063    "eventPlayer",
1064    "eventWasCriticalHit",
1065    "eventWasEnvironment",
1066    "eventWasHealthPack",
1067    "false",
1068    "healee",
1069    "healer",
1070    "hostPlayer",
1071    "localPlayer",
1072    "null",
1073    "pass",
1074    "return",
1075    "ruleCondition",
1076    "true",
1077    "victim",
1078    "void",
1079];
1080
1081/// Whether a CST receiver is assignable (the `.append` receiver rule): a
1082/// variable name (including macro parameters), an array literal, or an index
1083/// expression — matching the pinned reference, which rejects constant and
1084/// function receivers ("Cannot modify or assign to …").
1085fn assignable_receiver(receiver: &Expr) -> bool {
1086    match receiver {
1087        Expr::Name { name, .. } => !matches!(
1088            name.as_str(),
1089            "eventPlayer" | "hostPlayer" | "attacker" | "victim"
1090        ),
1091        Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
1092        _ => false,
1093    }
1094}
1095
1096fn expr_identifier(expr: &Expr) -> Option<&str> {
1097    match expr {
1098        Expr::Name { name, .. } => Some(name.as_str()),
1099        _ => None,
1100    }
1101}
1102
1103fn indexed_expr_depth(expr: &Expr) -> usize {
1104    match expr {
1105        Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
1106        _ => 0,
1107    }
1108}
1109
1110impl From<Span> for HirSpan {
1111    fn from(span: Span) -> HirSpan {
1112        HirSpan {
1113            file: span.file,
1114            start: Position {
1115                line: span.start.line,
1116                col: span.start.col,
1117            },
1118            end: Position {
1119                line: span.end.line,
1120                col: span.end.col,
1121            },
1122        }
1123    }
1124}
1125
1126impl From<&Span> for HirSpan {
1127    fn from(span: &Span) -> HirSpan {
1128        (*span).into()
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
1136    use crate::lexer::{LexInput, lex};
1137    use crate::parser::parse;
1138
1139    fn lower_ok(text: &str) -> HirProgram {
1140        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1141        let output = parse(&tokens);
1142        assert!(
1143            output.errors.is_empty(),
1144            "unexpected parse errors: {:?}",
1145            output.errors
1146        );
1147        let program = output.program.expect("parse produces a program");
1148        lower(&program, vec![], vec![]).expect("lowers without errors")
1149    }
1150
1151    fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
1152        let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1153            panic!("expected a rule");
1154        };
1155        (&rule.conditions, &rule.actions)
1156    }
1157
1158    #[test]
1159    fn producer_emits_the_v2_ordered_switch_contract() {
1160        let hir = lower_ok(
1161            "globalvar value\nrule \"r\":\n    @Event global\n    switch value:\n        default:\n            value = 1\n        case 2:\n            value = 2\n",
1162        );
1163        assert_eq!(hir.protocol.name, "wright/opy-hir");
1164        assert_eq!(hir.protocol.version, "2.0.0");
1165        let value = serde_json::to_value(&hir).expect("HIR must serialize");
1166        let switch = &value["rules"][0]["actions"][0];
1167        assert!(switch.get("arms").is_some());
1168        assert!(switch.get("cases").is_none());
1169        assert!(switch.get("default").is_none());
1170    }
1171
1172    #[test]
1173    fn receiver_calls_lower_to_receiver_call_hir() {
1174        // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the
1175        // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a
1176        // global-variable receiver (#104).
1177        let hir = lower_ok(
1178            "globalvar target\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n    target.setMoveSpeed(50)\n",
1179        );
1180        let (_, actions) = rule_conditions_and_actions(&hir);
1181        assert_eq!(actions.len(), 2);
1182
1183        let HirStmt::Expr { expr, .. } = &actions[0] else {
1184            panic!("expected expression statement");
1185        };
1186        let HirExpr::ReceiverCall {
1187            receiver,
1188            name,
1189            args,
1190            ..
1191        } = expr.as_ref()
1192        else {
1193            panic!("expected receiver call, got {expr:?}");
1194        };
1195        assert_eq!(name, "setMoveSpeed");
1196        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1197        assert_eq!(args.len(), 1);
1198        assert!(matches!(&args[0], HirExpr::Number { .. }));
1199
1200        let HirStmt::Expr { expr, .. } = &actions[1] else {
1201            panic!("expected expression statement");
1202        };
1203        let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
1204            panic!("expected receiver call, got {expr:?}");
1205        };
1206        assert_eq!(name, "setMoveSpeed");
1207        assert!(
1208            matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
1209            "globalvar receiver must resolve to a GlobalVar"
1210        );
1211    }
1212
1213    #[test]
1214    fn bare_variable_member_expression_preserves_receiver_and_member() {
1215        let hir = lower_ok(
1216            "globalvar A\nplayervar B\nrule \"receiver\":\n    @Event eachPlayer\n    A = B.C\n",
1217        );
1218        let HirStmt::Assign { value, .. } = &hir
1219            .rules
1220            .iter()
1221            .find_map(|entry| {
1222                let RuleEntry::Rule(rule) = entry else {
1223                    return None;
1224                };
1225                rule.actions.first()
1226            })
1227            .expect("assignment")
1228        else {
1229            panic!("expected assignment");
1230        };
1231        let HirExpr::Member {
1232            receiver, member, ..
1233        } = value.as_ref()
1234        else {
1235            panic!("expected opaque member expression, got {value:?}");
1236        };
1237        assert_eq!(member, "C");
1238        assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1239    }
1240
1241    #[test]
1242    fn context_player_components_lower_to_member_expressions() {
1243        // `x`/`y`/`z` are reserved member names in the pinned reference: on a
1244        // context-player receiver they resolve unconditionally to the vector
1245        // component path instead of a player-variable lookup (#414).
1246        for (receiver_name, event) in [
1247            ("eventPlayer", "eachPlayer"),
1248            ("hostPlayer", "eachPlayer"),
1249            ("localPlayer", "eachPlayer"),
1250            ("attacker", "playerDied"),
1251            ("victim", "playerDied"),
1252        ] {
1253            for axis in ["x", "y", "z"] {
1254                let hir = lower_ok(&format!(
1255                    "globalvar g\nrule \"r\":\n    @Event {event}\n    g = {receiver_name}.{axis}\n"
1256                ));
1257                let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
1258                    panic!("expected a rule");
1259                };
1260                let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1261                    panic!("expected an assign statement");
1262                };
1263                let HirExpr::Member {
1264                    receiver, member, ..
1265                } = value.as_ref()
1266                else {
1267                    panic!("{receiver_name}.{axis}: expected member expression, got {value:?}");
1268                };
1269                assert_eq!(member, axis, "{receiver_name}.{axis}");
1270                match receiver_name {
1271                    "eventPlayer" => {
1272                        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }))
1273                    }
1274                    "hostPlayer" => {
1275                        assert!(matches!(receiver.as_ref(), HirExpr::HostPlayer { .. }))
1276                    }
1277                    _ => assert!(
1278                        matches!(receiver.as_ref(), HirExpr::Call { name, .. } if name == receiver_name),
1279                        "{receiver_name}.{axis}: receiver must keep its source identity"
1280                    ),
1281                }
1282            }
1283        }
1284    }
1285
1286    #[test]
1287    fn context_player_non_component_members_stay_player_variables() {
1288        // A declared player variable still resolves as a player-variable
1289        // reference, an uppercase default var is not a component access, and
1290        // an undeclared member keeps the unknown-member diagnostic.
1291        let value = lowered_value(
1292            "globalvar g\nplayervar pos\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.pos\n",
1293        );
1294        assert!(
1295            matches!(&value, HirExpr::PlayerVar { name, .. } if name == "pos"),
1296            "declared playervar member must stay a player-variable reference, got {value:?}"
1297        );
1298
1299        let value = lowered_value(
1300            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.X\n",
1301        );
1302        assert!(
1303            matches!(&value, HirExpr::PlayerVar { name, .. } if name == "X"),
1304            "default-var member must stay a player-variable reference, got {value:?}"
1305        );
1306
1307        let error = compile_error(&action_source("g = eventPlayer.pos"), 4);
1308        assert_eq!(error.code, "unknown-member");
1309    }
1310
1311    #[test]
1312    fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1313        let text = "rule \"before\":\n    pass\ndef source_name():\n    @Name \"Friendly\"\n    pass\nrule \"after\":\n    pass\n";
1314        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1315        let output = parse(&tokens);
1316        assert!(
1317            output.errors.is_empty(),
1318            "unexpected parse errors: {:?}",
1319            output.errors
1320        );
1321        let program = output.program.expect("program");
1322        let preprocessing = PreprocessingState {
1323            rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1324                value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1325                span: None,
1326            }),
1327            ..PreprocessingState::default()
1328        };
1329        let hir = lower_with_preprocessing(
1330            &program,
1331            vec![SourceFile {
1332                id: 0,
1333                path: "main.opy".to_string(),
1334            }],
1335            vec![],
1336            &preprocessing,
1337        )
1338        .expect("lowers");
1339        let names: Vec<_> = hir
1340            .rules
1341            .iter()
1342            .map(|entry| match entry {
1343                HirRuleEntry::Rule(rule) => rule.name.clone(),
1344                HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1345            })
1346            .collect();
1347        assert_eq!(
1348            names,
1349            vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1350        );
1351        let HirRuleEntry::SubroutineDef {
1352            name, source_name, ..
1353        } = &hir.rules[1]
1354        else {
1355            panic!("expected subroutine definition");
1356        };
1357        assert_eq!(name, "[Main] Friendly");
1358        assert_eq!(source_name, "source_name");
1359    }
1360
1361    #[test]
1362    fn receiver_call_values_lower_in_conditions() {
1363        // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value;
1364        // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver
1365        // call inside another receiver call's arguments (#104).
1366        let hir = lower_ok(
1367            "rule \"r\":\n    @Event eachPlayer\n    @Condition eventPlayer.isAlive()\n    eventPlayer.teleport(eventPlayer.getPosition())\n",
1368        );
1369        let (conditions, actions) = rule_conditions_and_actions(&hir);
1370        assert_eq!(conditions.len(), 1);
1371        let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1372            panic!("expected receiver call condition, got {:?}", conditions[0]);
1373        };
1374        assert_eq!(name, "isAlive");
1375        assert_eq!(args.len(), 0);
1376
1377        let HirStmt::Expr { expr, .. } = &actions[0] else {
1378            panic!("expected expression statement");
1379        };
1380        let HirExpr::ReceiverCall {
1381            name,
1382            args,
1383            receiver,
1384            ..
1385        } = expr.as_ref()
1386        else {
1387            panic!("expected receiver call, got {expr:?}");
1388        };
1389        assert_eq!(name, "teleport");
1390        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1391        assert_eq!(args.len(), 1);
1392        assert!(matches!(
1393            &args[0],
1394            HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1395        ));
1396    }
1397
1398    #[test]
1399    fn format_string_receiver_stays_a_format_node() {
1400        // `.format()` on a string receiver is unaffected by the receiver-call
1401        // path (existing supported form).
1402        let hir = lower_ok(
1403            "rule \"r\":\n    @Event global\n    print(\"{} points\".format(len([1, 2])))\n",
1404        );
1405        let (_, actions) = rule_conditions_and_actions(&hir);
1406        let HirStmt::Expr { expr, .. } = &actions[0] else {
1407            panic!("expected expression statement");
1408        };
1409        assert!(
1410            has_format(expr),
1411            "string `.format()` must lower to a Format node"
1412        );
1413    }
1414
1415    fn has_format(expr: &HirExpr) -> bool {
1416        match expr {
1417            HirExpr::Format { .. } => true,
1418            HirExpr::Call { args, .. } => args.iter().any(has_format),
1419            HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1420            _ => false,
1421        }
1422    }
1423
1424    /// Lower one rule action and return the assignment's value expression.
1425    fn lowered_value(source: &str) -> HirExpr {
1426        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1427            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1428        let RuleEntry::Rule(rule) = &program.rules[0] else {
1429            panic!("expected a rule");
1430        };
1431        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1432            panic!("expected an assign statement");
1433        };
1434        (**value).clone()
1435    }
1436
1437    #[test]
1438    fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1439        let value = lowered_value(
1440            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NONE\n",
1441        );
1442        assert_enum(&value, "ChaseTimeReeval", "NONE");
1443    }
1444
1445    #[test]
1446    fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1447        let value = lowered_value(
1448            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1449        );
1450        assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1451    }
1452
1453    #[test]
1454    fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1455        for member in ["NONE", "DESTINATION_AND_RATE"] {
1456            let source = format!(
1457                "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseRateReeval.{member}\n"
1458            );
1459            assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1460        }
1461    }
1462
1463    /// Assert the expression is the catalog enum `(domain, member)` node,
1464    /// ignoring its source span (the span is frontend-internal provenance).
1465    fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1466        match value {
1467            HirExpr::Enum {
1468                value_type, value, ..
1469            } => {
1470                assert_eq!(value_type, domain);
1471                assert_eq!(value, member);
1472            }
1473            other => panic!("expected enum {domain}.{member}, got {other:?}"),
1474        }
1475    }
1476
1477    #[test]
1478    fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1479        let error = crate::compile(
1480            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NOPE\n",
1481            "test.opy",
1482            std::path::Path::new(""),
1483        )
1484        .expect_err("unknown catalog member must be rejected");
1485        assert_eq!(error.code, "unknown-enum-member");
1486    }
1487
1488    #[test]
1489    fn unknown_enum_receiver_is_an_unsupported_member_error() {
1490        let error = crate::compile(
1491            "globalvar g\nrule \"r\":\n    @Event global\n    g = NotARealEnum.MEMBER\n",
1492            "test.opy",
1493            std::path::Path::new(""),
1494        )
1495        .expect_err("an unknown enum type must fail");
1496        assert_eq!(error.code, "unsupported-member");
1497        let span = error.span.expect("the error is source-located");
1498        assert_eq!(span.start.line, 4);
1499    }
1500
1501    #[test]
1502    fn unknown_texture_member_is_rejected_by_the_source_surface() {
1503        let error = crate::compile(
1504            "globalvar g\nrule \"r\":\n    @Event global\n    g = Texture.NOT_A_TEXTURE\n",
1505            "test.opy",
1506            std::path::Path::new(""),
1507        )
1508        .expect_err("unknown texture member must be rejected");
1509        assert_eq!(error.code, "unknown-texture-member");
1510    }
1511
1512    // --- Builtin semantic manifest coverage (#109) ---
1513
1514    /// Assert a compile failure has the given code at the given line.
1515    fn compile_error(source: &str, line: u32) -> OpyError {
1516        let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1517            .expect_err("expected a compile failure");
1518        let span = error.span.expect("the error is source-located");
1519        assert_eq!(span.start.line, line, "code '{}'", error.code);
1520        error
1521    }
1522
1523    fn action_source(statement: &str) -> String {
1524        format!("globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n")
1525    }
1526
1527    #[test]
1528    fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1529        // 4-argument form with an explicit reevaluation member (#106).
1530        let hir = crate::compile(
1531            &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1532            "test.opy",
1533            std::path::Path::new(""),
1534        )
1535        .expect("reference-supported chaseOverTime compiles");
1536        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1537            panic!("expected a rule");
1538        };
1539        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1540            panic!("expected expression statement");
1541        };
1542        let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1543            panic!("expected a call, got {expr:?}");
1544        };
1545        assert_eq!(name, "chaseOverTime");
1546        assert_eq!(args.len(), 4);
1547        assert!(matches!(
1548            &args[3],
1549            HirExpr::Enum { value_type, value, .. }
1550                if value_type == "ChaseTimeReeval" && value == "NONE"
1551        ));
1552
1553        // 3-argument form fills the reference default member.
1554        let hir = crate::compile(
1555            &action_source("chaseOverTime(g, 10, 3)"),
1556            "test.opy",
1557            std::path::Path::new(""),
1558        )
1559        .expect("default-reevaluation chaseOverTime compiles");
1560        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1561            panic!("expected a rule");
1562        };
1563        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1564            panic!("expected expression statement");
1565        };
1566        let HirExpr::Call { args, .. } = expr.as_ref() else {
1567            panic!("expected a call");
1568        };
1569        assert_eq!(args.len(), 4);
1570        assert!(matches!(
1571            &args[3],
1572            HirExpr::Enum { value_type, value, .. }
1573                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1574        ));
1575    }
1576
1577    #[test]
1578    fn is_game_in_progress_resolves_as_a_builtin_value() {
1579        // Generic value gap from #106: `isGameInProgress()` in a condition.
1580        let hir = crate::compile(
1581            &action_source("@Condition isGameInProgress() == true"),
1582            "test.opy",
1583            std::path::Path::new(""),
1584        )
1585        .expect("reference-supported isGameInProgress compiles");
1586        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1587            panic!("expected a rule");
1588        };
1589        assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1590    }
1591
1592    #[test]
1593    fn enum_gated_members_resolve_through_the_manifest() {
1594        // Enum-gated members from #106: setInvisibility (Invis), getThrottle
1595        // (member value), worldVector (Transform arg), setStatusEffect
1596        // (Status arg).
1597        let source = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1598            @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n    \
1599            @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n    \
1600            eventPlayer.setInvisibility(Invis.ALL)\n    \
1601            eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1602        let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1603            .expect("enum-gated members compile");
1604        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1605            panic!("expected a rule");
1606        };
1607        assert_eq!(rule.actions.len(), 2);
1608    }
1609
1610    #[test]
1611    fn get_players_in_radius_fills_reference_enum_defaults() {
1612        // 2-argument form fills Team.ALL and LosCheck.OFF (reference
1613        // emission: `Players Within Radius(..., All Teams, Off)`).
1614        let hir = crate::compile(
1615            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1616             @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n    \
1617             disableInspector()\n",
1618            "test.opy",
1619            std::path::Path::new(""),
1620        )
1621        .expect("getPlayersInRadius with defaults compiles");
1622        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1623            panic!("expected a rule");
1624        };
1625        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1626            panic!("expected a comparison");
1627        };
1628        let HirExpr::Call { name, args, .. } = left.as_ref() else {
1629            panic!("expected len call");
1630        };
1631        assert_eq!(name, "len");
1632        let HirExpr::Call { name, args, .. } = &args[0] else {
1633            panic!("expected getPlayersInRadius call");
1634        };
1635        assert_eq!(name, "getPlayersInRadius");
1636        assert_eq!(args.len(), 4);
1637        assert!(matches!(
1638            &args[2],
1639            HirExpr::Enum { value_type, value, .. }
1640                if value_type == "Team" && value == "ALL"
1641        ));
1642        assert!(matches!(
1643            &args[3],
1644            HirExpr::Enum { value_type, value, .. }
1645                if value_type == "LosCheck" && value == "OFF"
1646        ));
1647    }
1648
1649    #[test]
1650    fn value_call_in_action_position_is_rejected() {
1651        let error = compile_error(&action_source("isGameInProgress()"), 4);
1652        assert_eq!(error.code, "value-in-action-position");
1653    }
1654
1655    #[test]
1656    fn value_member_in_action_position_is_rejected() {
1657        // The #106 baseline records the oracle rejecting `B.isAlive()` as a
1658        // statement; the manifest enforces that contract (#109).
1659        let error = compile_error(
1660            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isAlive()\n",
1661            4,
1662        );
1663        assert_eq!(error.code, "value-in-action-position");
1664    }
1665
1666    // --- Named/keyword argument binding and chase call context (#110) ---
1667
1668    /// Compile a program and return the lowered first action's expression
1669    /// (the statement expression, or the value of a leading assignment).
1670    fn first_action_expr(source: &str) -> HirExpr {
1671        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1672            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1673        let RuleEntry::Rule(rule) = &program.rules[0] else {
1674            panic!("expected a rule");
1675        };
1676        match &rule.actions[0] {
1677            HirStmt::Expr { expr, .. } => (**expr).clone(),
1678            HirStmt::Assign { value, .. } => (**value).clone(),
1679            other => panic!("expected an expression or assignment, got {other:?}"),
1680        }
1681    }
1682
1683    /// Remove every `span`/`name_span` key from a serialized expression (the
1684    /// differential suite's normalization).
1685    fn strip_spans(value: &mut serde_json::Value) {
1686        match value {
1687            serde_json::Value::Object(map) => {
1688                map.remove("span");
1689                map.remove("name_span");
1690                for nested in map.values_mut() {
1691                    strip_spans(nested);
1692                }
1693            }
1694            serde_json::Value::Array(items) => {
1695                for item in items {
1696                    strip_spans(item);
1697                }
1698            }
1699            _ => {}
1700        }
1701    }
1702
1703    #[test]
1704    fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1705        // The reference `chase` form: `rate = …` dispatches to chaseAtRate
1706        // with the ChaseRateReeval domain, `duration = …` to chaseOverTime
1707        // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves
1708        // only through this call context (issue #110).
1709        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1710        let HirExpr::Call { name, args, .. } = &expr else {
1711            panic!("expected a call, got {expr:?}");
1712        };
1713        assert_eq!(name, "chaseAtRate");
1714        assert!(matches!(
1715            &args[3],
1716            HirExpr::Enum { value_type, value, .. }
1717                if value_type == "ChaseRateReeval" && value == "NONE"
1718        ));
1719
1720        let expr = first_action_expr(&action_source(
1721            "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1722        ));
1723        let HirExpr::Call { name, args, .. } = &expr else {
1724            panic!("expected a call, got {expr:?}");
1725        };
1726        assert_eq!(name, "chaseOverTime");
1727        assert!(matches!(
1728            &args[3],
1729            HirExpr::Enum { value_type, value, .. }
1730                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1731        ));
1732
1733        // Player-variable first arguments resolve too (the emission layer
1734        // picks the player form).
1735        let expr = first_action_expr(
1736            "playervar P\nrule \"r\":\n    @Event eachPlayer\n    \
1737             chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1738        );
1739        let HirExpr::Call { name, args, .. } = &expr else {
1740            panic!("expected a call, got {expr:?}");
1741        };
1742        assert_eq!(name, "chaseAtRate");
1743        assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1744    }
1745
1746    #[test]
1747    fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1748        // `ChaseReeval` is a contextual domain, not a standalone domain
1749        // identity: a bare member access outside the chase signature is
1750        // rejected.
1751        let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1752        assert_eq!(error.code, "unsupported-member");
1753
1754        // Inside the chase context the member is an opaque identity
1755        // dispatched to the concrete domain selected by the keyword
1756        // selector; member existence in that domain is not validated here
1757        // (lowering-dependent, #8).
1758        let expr = first_action_expr(&action_source(
1759            "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1760        ));
1761        let HirExpr::Call { name, args, .. } = &expr else {
1762            panic!("expected a call, got {expr:?}");
1763        };
1764        assert_eq!(name, "chaseAtRate");
1765        assert!(matches!(
1766            &args[3],
1767            HirExpr::Enum { value_type, value, .. }
1768                if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1769        ));
1770
1771        // A non-enum 4th argument is carried structurally; the reference's
1772        // "expected an enum member" rejection is lowering-dependent.
1773        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1774        let HirExpr::Call { name, args, .. } = &expr else {
1775            panic!("expected a call, got {expr:?}");
1776        };
1777        assert_eq!(name, "chase");
1778        assert!(matches!(&args[3], HirExpr::Number { .. }));
1779    }
1780
1781    #[test]
1782    fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1783        let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1784        assert_eq!(error.code, "keyword-required");
1785        assert!(error.message.contains("rate"));
1786    }
1787
1788    #[test]
1789    fn chase_family_requires_a_variable_first_argument() {
1790        // The reference rejects non-variable first arguments for the chase
1791        // family ("Expected variable for 1st argument of function
1792        // 'chaseOverTime'", issue #110) — the variable kind also selects
1793        // the global/player emission form.
1794        let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1795        assert_eq!(error.code, "invalid-argument");
1796
1797        let error = compile_error(
1798            &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1799            4,
1800        );
1801        assert_eq!(error.code, "invalid-argument");
1802
1803        let error = compile_error(
1804            &action_source("chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)"),
1805            4,
1806        );
1807        assert_eq!(error.code, "invalid-argument");
1808
1809        // A variable first argument binds (positive case).
1810        crate::compile(
1811            &action_source("chaseAtRate(g, 0, 2, ChaseRateReeval.NONE)"),
1812            "test.opy",
1813            std::path::Path::new(""),
1814        )
1815        .expect("a variable first argument compiles");
1816    }
1817
1818    #[test]
1819    fn keyword_binding_matches_positional_binding_in_hir() {
1820        // Keyword binding consumes the manifest signatures: the bound HIR is
1821        // identical to the positional form's (defaults filled the same way),
1822        // modulo source spans (the keyword values sit at different columns).
1823        fn without_spans(expr: &HirExpr) -> serde_json::Value {
1824            let mut value = serde_json::to_value(expr).unwrap();
1825            strip_spans(&mut value);
1826            value
1827        }
1828        let keyword = without_spans(&first_action_expr(&action_source(
1829            "chaseOverTime(g, 10, duration=3)",
1830        )));
1831        let positional = without_spans(&first_action_expr(&action_source(
1832            "chaseOverTime(g, 10, 3)",
1833        )));
1834        assert_eq!(keyword, positional);
1835
1836        let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
1837        let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
1838        assert_eq!(keyword, positional);
1839
1840        // Out-of-order keywords bind by name.
1841        let keyword = without_spans(&first_action_expr(&action_source(
1842            "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
1843        )));
1844        let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
1845        assert_eq!(keyword, positional);
1846
1847        let keyword = without_spans(&first_action_expr(&action_source(
1848            "g = vect(x=1, y=2, z=3)",
1849        )));
1850        let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
1851        assert_eq!(keyword, positional);
1852    }
1853
1854    #[test]
1855    fn keyword_binding_diagnostics_are_structured_and_source_located() {
1856        // Unknown keyword name.
1857        let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
1858        assert_eq!(error.code, "unknown-keyword");
1859        assert!(error.message.contains("bogus"));
1860
1861        // Duplicate (positional slot filled again by keyword).
1862        let error = compile_error(
1863            &action_source(
1864                "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
1865                 reevaluation=ChaseTimeReeval.NONE)",
1866            ),
1867            4,
1868        );
1869        assert_eq!(error.code, "duplicate-argument");
1870
1871        // Positional after keyword.
1872        let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
1873        assert_eq!(error.code, "positional-after-keyword");
1874
1875        // Missing required argument (reference: "Missing argument 'duration'").
1876        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1877        assert_eq!(error.code, "missing-argument");
1878
1879        // Positional-only parameter bound by keyword (`chase`'s leading
1880        // arguments; the reference rejects the keyword form).
1881        let error = compile_error(
1882            &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
1883            4,
1884        );
1885        assert_eq!(error.code, "unknown-keyword");
1886    }
1887
1888    #[test]
1889    fn keyword_arguments_are_rejected_for_reference_special_cases() {
1890        // The reference routes `range`, `random.*`, and `.format` around its
1891        // generic keyword binder; keyword arguments fail deterministically.
1892        let error = compile_error(
1893            "globalvar g\nrule \"r\":\n    @Event global\n    \
1894             for I in range(start=0, stop=3):\n        debug(I)\n",
1895            4,
1896        );
1897        assert_eq!(error.code, "keyword-unsupported");
1898
1899        let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
1900        assert_eq!(error.code, "keyword-unsupported");
1901
1902        let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
1903        assert_eq!(error.code, "keyword-unsupported");
1904    }
1905
1906    #[test]
1907    fn sorted_rejects_keyword_arguments_beyond_the_key_lambda() {
1908        // #437: the reference's special `sorted` parse path only strips a
1909        // `key=` prefix inside the second argument; other keyword spellings
1910        // and a misplaced `key=` fail upstream parsing.
1911        let error = compile_error(&action_source("g = sorted([1, 2], bogus=lambda v: v)"), 4);
1912        assert_eq!(error.code, "unknown-keyword");
1913
1914        let error = compile_error(
1915            &action_source("g = sorted(array=[1, 2], key=lambda v: -v)"),
1916            4,
1917        );
1918        assert_eq!(error.code, "unknown-keyword");
1919
1920        let error = compile_error(&action_source("g = sorted(key=lambda v: v)"), 4);
1921        assert_eq!(error.code, "unknown-keyword");
1922    }
1923
1924    #[test]
1925    fn call_args_reject_parenthesized_keyword_names() {
1926        // #443: the reference detects keyword arguments at the token level,
1927        // so `(name)=expr` never binds as a keyword.
1928        let error = compile_error(&action_source("g = vect((g)=1, y=2, z=3)"), 4);
1929        assert_eq!(error.code, "parse-error");
1930
1931        let error = compile_error(&action_source("g = sorted([1, 2], (key)=lambda v: -v)"), 4);
1932        assert_eq!(error.code, "parse-error");
1933
1934        // Bare `name=expr` keyword binding is unaffected, and a parenthesized
1935        // name without `=` still binds positionally.
1936        crate::compile(
1937            &action_source("g = vect(x=1, y=2, z=3) + vect((g), 2, 3)"),
1938            "test.opy",
1939            std::path::Path::new(""),
1940        )
1941        .expect("bare keyword arguments compile");
1942    }
1943
1944    #[test]
1945    fn call_args_reject_trailing_comma() {
1946        // #444: the reference keeps the empty segment after a trailing comma
1947        // and rejects it in call argument lists.
1948        for statement in [
1949            "wait(1,)",
1950            "g = vect(1, 2, 3,)",
1951            "g = sorted([1, 2],)",
1952            "g = sorted([1, 2], key=lambda v: -v,)",
1953        ] {
1954            let error = compile_error(&action_source(statement), 4);
1955            assert_eq!(error.code, "parse-error", "statement: {statement}");
1956        }
1957
1958        // Array and dictionary literals keep accepting a trailing comma on
1959        // both sides, as do parenthesized @Event argument lists.
1960        crate::compile(
1961            &action_source("g = [1, 2,]"),
1962            "test.opy",
1963            std::path::Path::new(""),
1964        )
1965        .expect("array literal trailing comma compiles");
1966        crate::compile(
1967            &action_source("g = {\"a\": 1,}[\"a\"]"),
1968            "test.opy",
1969            std::path::Path::new(""),
1970        )
1971        .expect("dict literal trailing comma compiles");
1972        crate::compile(
1973            "rule \"r\":\n    @Event playerDied(victim,)\n    wait(1)\n",
1974            "test.opy",
1975            std::path::Path::new(""),
1976        )
1977        .expect("@Event argument trailing comma compiles");
1978    }
1979
1980    #[test]
1981    fn binder_lambda_rejects_parenthesized() {
1982        // #445: the reference detects binder lambdas at the token level, so
1983        // `(lambda x: e)` never satisfies a lambda-argument position.
1984        for statement in [
1985            "g = sorted([1, 2], (lambda v: -v))",
1986            "g = sorted([1, 2], key=(lambda v: -v))",
1987            "g = [1, 2].map((lambda v: v))",
1988            "g = [1, 2].filter((lambda v: v))",
1989            "g = [1, 2].all((lambda v: v))",
1990            "g = [1, 2].any((lambda v: v))",
1991            "g = sorted([1, 2], ((lambda v: -v)))",
1992        ] {
1993            let error = compile_error(&action_source(statement), 4);
1994            assert_eq!(error.code, "lambda-context", "statement: {statement}");
1995        }
1996
1997        // Bare lambdas still bind.
1998        crate::compile(
1999            &action_source("g = sorted([1, 2], key=lambda v: -v)"),
2000            "test.opy",
2001            std::path::Path::new(""),
2002        )
2003        .expect("bare lambda key compiles");
2004    }
2005
2006    #[test]
2007    fn wait_uses_the_reference_keyword_names() {
2008        // The manifest's `wait` parameter names match the pinned reference
2009        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
2010        // keyword exactly like the oracle.
2011        let error = compile_error(&action_source("wait(duration=1)"), 4);
2012        assert_eq!(error.code, "unknown-keyword");
2013        assert!(error.message.contains("duration"));
2014    }
2015
2016    #[test]
2017    fn action_call_in_value_position_is_rejected() {
2018        let error = compile_error(&action_source("g = wait(1)"), 4);
2019        assert_eq!(error.code, "action-in-value-position");
2020    }
2021
2022    #[test]
2023    fn missing_required_argument_is_a_source_located_diagnostic() {
2024        // Too-few calls reject with the reference's missing-argument
2025        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
2026        // issue #110); positional overflow keeps `invalid-arity`.
2027        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
2028        assert_eq!(error.code, "missing-argument");
2029        assert!(error.message.contains("duration"));
2030
2031        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
2032        assert_eq!(error.code, "invalid-arity");
2033    }
2034
2035    #[test]
2036    fn missing_member_argument_is_a_source_located_diagnostic() {
2037        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
2038        // 30)` must reject like the oracle (the `status` argument is
2039        // missing; the reference: "Missing argument 'status' for function
2040        // '.setStatusEffect'", issue #110).
2041        let error = compile_error(
2042            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2043             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
2044            4,
2045        );
2046        assert_eq!(error.code, "missing-argument");
2047        assert!(error.message.contains("duration"));
2048    }
2049
2050    #[test]
2051    fn invalid_receiver_categories_are_rejected() {
2052        // `.append`/`.remove` require an assignable receiver; `.format` a
2053        // string literal — the reference-enforced requirements, now typed
2054        // member policy (`lower::policy`, issue #458).
2055        let error = compile_error(&action_source("3.append(1)"), 4);
2056        assert_eq!(error.code, "invalid-receiver");
2057        assert!(error.message.contains("append"));
2058
2059        let error = compile_error(&action_source("attacker.append(1)"), 4);
2060        assert_eq!(error.code, "invalid-receiver");
2061        assert!(error.message.contains("append"));
2062
2063        let error = compile_error(&action_source("3.remove(1)"), 4);
2064        assert_eq!(error.code, "invalid-receiver");
2065        assert!(error.message.contains("remove"));
2066
2067        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
2068        assert_eq!(error.code, "invalid-receiver");
2069        assert!(error.message.contains("format"));
2070
2071        // Positive cases: assignable receivers and string literals compile.
2072        crate::compile(
2073            &action_source("g.append(1)\n    g.remove(1)"),
2074            "test.opy",
2075            std::path::Path::new(""),
2076        )
2077        .expect("assignable receivers compile");
2078        crate::compile(
2079            &action_source("g = \"{}\".format(1)"),
2080            "test.opy",
2081            std::path::Path::new(""),
2082        )
2083        .expect("a string-literal receiver compiles");
2084    }
2085
2086    /// Lower `source` against an explicitly supplied manifest. The builtin
2087    /// manifest is immutable, so tests that mutate descriptive metadata use
2088    /// this entry instead of [`crate::compile`].
2089    fn lower_with_manifest(source: &str, manifest: &'static Manifest) -> OpyResult<HirProgram> {
2090        let tokens = lex(LexInput {
2091            file_id: 0,
2092            text: source,
2093        })
2094        .expect("lexes");
2095        let output = parse(&tokens);
2096        assert!(
2097            output.errors.is_empty(),
2098            "unexpected parse errors: {:?}",
2099            output.errors
2100        );
2101        let program = output.program.expect("parse produces a program");
2102        lower_program(
2103            &program,
2104            vec![],
2105            vec![],
2106            &PreprocessingState::default(),
2107            manifest,
2108            Catalog::builtin().expect("the builtin catalog loads"),
2109        )
2110    }
2111
2112    #[test]
2113    fn special_enforcement_is_typed_policy_not_manifest_metadata() {
2114        // #458: `param.variable` and `Function::receiver` are descriptive
2115        // signature metadata. Mutating them alone must not change
2116        // acceptance/rejection — enforcement is typed `lower::policy`.
2117        let mut file: serde_json::Value =
2118            serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap();
2119        for function in file["functions"].as_array_mut().unwrap() {
2120            match function["id"].as_str().unwrap() {
2121                // Strip the chase family's variable-argument markers.
2122                "chase" | "chaseAtRate" | "chaseOverTime" => {
2123                    for param in function["params"].as_array_mut().unwrap() {
2124                        param.as_object_mut().unwrap().remove("variable");
2125                    }
2126                }
2127                // Reclassify the enforced receivers to an unenforced
2128                // category.
2129                "append" | "remove" | "format" => {
2130                    function["receiver"] = "Any".into();
2131                }
2132                // Mark unrelated signatures with the metadata: if the flag
2133                // still selected behavior, these calls would start
2134                // rejecting.
2135                "wait" => function["params"][0]["variable"] = true.into(),
2136                "getPosition" => function["receiver"] = "Variable".into(),
2137                _ => {}
2138            }
2139        }
2140        let manifest: &'static Manifest = Box::leak(Box::new(
2141            Manifest::load(
2142                &serde_json::to_string(&file).unwrap(),
2143                crate::manifest::PROBES_DATA,
2144            )
2145            .expect("mutated metadata still validates"),
2146        ));
2147
2148        // The chase family still rejects a non-variable first argument…
2149        for call in [
2150            "chase(10, 10, rate=2, ChaseReeval.NONE)",
2151            "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)",
2152            "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)",
2153        ] {
2154            let error = lower_with_manifest(&action_source(call), manifest)
2155                .expect_err("the chase family still requires a variable");
2156            assert_eq!(error.code, "invalid-argument", "{call}");
2157        }
2158        // …and still accepts a variable.
2159        lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest)
2160            .expect("a variable first argument compiles");
2161
2162        // `.append`/`.remove` still reject a non-assignable receiver and
2163        // `.format` a non-string receiver.
2164        for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] {
2165            let error = lower_with_manifest(&action_source(call), manifest)
2166                .expect_err("the enforced receiver requirement still rejects");
2167            assert_eq!(error.code, "invalid-receiver", "{call}");
2168        }
2169        lower_with_manifest(&action_source("g.append(1)"), manifest)
2170            .expect("an assignable receiver compiles");
2171        lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest)
2172            .expect("a string-literal receiver compiles");
2173
2174        // Conversely, the mutated markers create no new enforcement:
2175        // `wait`'s first argument and `getPosition`'s receiver are accepted.
2176        lower_with_manifest(&action_source("wait(1)"), manifest)
2177            .expect("`wait`'s first argument is not variable-required");
2178        lower_with_manifest(
2179            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2180             g = eventPlayer.getPosition()\n",
2181            manifest,
2182        )
2183        .expect("`getPosition`'s receiver is not assignable-enforced");
2184    }
2185
2186    #[test]
2187    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
2188        // Domain mismatches need canonical Workshop enum knowledge: a member
2189        // of the wrong domain is carried as an opaque identity and the check
2190        // is lowering-dependent (#8).
2191        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
2192        let HirExpr::Call { args, .. } = &expr else {
2193            panic!("expected a call, got {expr:?}");
2194        };
2195        assert!(matches!(
2196            &args[3],
2197            HirExpr::Enum { value_type, value, .. }
2198                if value_type == "Invis" && value == "ALL"
2199        ));
2200
2201        let expr = first_action_expr(&action_source(
2202            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
2203        ));
2204        let HirExpr::ReceiverCall { args, .. } = &expr else {
2205            panic!("expected a receiver call, got {expr:?}");
2206        };
2207        assert!(matches!(
2208            &args[0],
2209            HirExpr::Enum { value_type, value, .. }
2210                if value_type == "ChaseTimeReeval" && value == "NONE"
2211        ));
2212    }
2213
2214    #[test]
2215    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
2216        // Whether a non-enum value is acceptable for an enum parameter is
2217        // Workshop catalog knowledge; the frontend carries the argument
2218        // structurally and leaves the check to lowering (#8).
2219        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
2220        let HirExpr::ReceiverCall { args, .. } = &expr else {
2221            panic!("expected a receiver call, got {expr:?}");
2222        };
2223        assert!(matches!(
2224            &args[0],
2225            HirExpr::GlobalVar { name, .. } if name == "g"
2226        ));
2227
2228        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
2229        let HirExpr::ReceiverCall { args, .. } = &expr else {
2230            panic!("expected a receiver call, got {expr:?}");
2231        };
2232        assert!(matches!(&args[0], HirExpr::Number { .. }));
2233    }
2234
2235    #[test]
2236    fn unknown_builtins_fail_at_resolution_not_emission() {
2237        let error = compile_error(&action_source("frobnicate()"), 4);
2238        assert_eq!(error.code, "unknown-action");
2239
2240        let error = compile_error(&action_source("g = frobnicate()"), 4);
2241        assert_eq!(error.code, "unknown-value");
2242
2243        let error = compile_error(
2244            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
2245            4,
2246        );
2247        assert_eq!(error.code, "unknown-member");
2248    }
2249
2250    #[test]
2251    fn wright_only_catalog_names_are_rejected() {
2252        // `createHudText` and `squareRoot` are Workshop emission spellings,
2253        // not OPY source functions; the pinned reference rejects them, so
2254        // the manifest does not preserve the accidental acceptance.
2255        let error = compile_error(&action_source("createHudText(1)"), 4);
2256        assert_eq!(error.code, "unknown-action");
2257
2258        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
2259        assert_eq!(error.code, "unknown-value");
2260    }
2261
2262    #[test]
2263    fn canonical_ids_without_upstream_spellings_are_rejected() {
2264        // These canonical Workshop ids are not upstream OPY spellings (#410):
2265        // the oracle rejects them, so they are source diagnostics. Their
2266        // upstream spellings keep the same canonical identity via `catalogId`.
2267        for (statement, code) in [
2268            ("g = evaluateOnce(1)", "unknown-value"),
2269            ("g = lastCreatedEntity()", "unknown-value"),
2270            ("g = lastTextId()", "unknown-value"),
2271            ("g = allTankHeroes()", "unknown-value"),
2272            ("g = allDamageHeroes()", "unknown-value"),
2273            ("g = allSupportHeroes()", "unknown-value"),
2274            ("destroyAllHudText()", "unknown-action"),
2275        ] {
2276            let error = compile_error(&action_source(statement), 4);
2277            assert_eq!(error.code, code, "{statement}");
2278        }
2279
2280        let member = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n";
2281        let error = compile_error(member, 4);
2282        assert_eq!(error.code, "unknown-member");
2283
2284        // `.x`/`.y`/`.z` are vector component accessors, not member calls.
2285        for axis in ["x", "y", "z"] {
2286            let source = format!(
2287                "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.{axis}()\n"
2288            );
2289            let error = compile_error(&source, 4);
2290            assert_eq!(error.code, "unknown-member", "{axis}()");
2291        }
2292
2293        // The upstream spellings still resolve.
2294        crate::compile(
2295            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = evalOnce(1)\n    g = getLastCreatedEntity()\n    g = getLastCreatedText()\n    g = getTankHeroes()\n    g = getDamageHeroes()\n    g = getSupportHeroes()\n    g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n    destroyAllHudTexts()\n",
2296            "test.opy",
2297            std::path::Path::new(""),
2298        )
2299        .expect("the upstream spellings compile");
2300    }
2301
2302    #[test]
2303    fn generic_member_only_actions_are_rejected() {
2304        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
2305        // member form is the reference surface.
2306        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
2307        assert_eq!(error.code, "unknown-action");
2308    }
2309
2310    #[test]
2311    fn range_is_for_iterables_only() {
2312        // Standalone `range(...)` is rejected by the reference; the
2313        // for-header form keeps 1-3 arguments.
2314        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
2315        assert_eq!(error.code, "invalid-call-context");
2316
2317        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
2318        assert_eq!(error.code, "invalid-iterable");
2319
2320        crate::compile(
2321            &action_source("for g in range(3):\n        debug(g)"),
2322            "test.opy",
2323            std::path::Path::new(""),
2324        )
2325        .expect("the for-header range form compiles");
2326    }
2327
2328    #[test]
2329    fn source_aliases_resolve_to_canonical_names() {
2330        // Non-contextual aliases rewrite to the canonical entry so identity,
2331        // position, and emission use the target name.
2332        let hir = crate::compile(
2333            &action_source("stopChasingVariable(g)"),
2334            "test.opy",
2335            std::path::Path::new(""),
2336        )
2337        .expect("the alias target compiles");
2338        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2339            panic!("expected a rule");
2340        };
2341        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
2342            panic!("expected expression statement");
2343        };
2344        let HirExpr::Call { name, .. } = expr.as_ref() else {
2345            panic!("expected a call");
2346        };
2347        assert_eq!(name, "stopChasingVariable");
2348
2349        let hir = crate::compile(
2350            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
2351             @Condition eventPlayer.getCurrentHero() != null\n    \
2352             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
2353             disableInspector()\n",
2354            "test.opy",
2355            std::path::Path::new(""),
2356        )
2357        .expect("member aliases compile");
2358        let RuleEntry::Rule(rule) = &hir.rules[0] else {
2359            panic!("expected a rule");
2360        };
2361        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
2362            panic!("expected a comparison");
2363        };
2364        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
2365            panic!("expected a receiver call");
2366        };
2367        assert_eq!(name, "getHero");
2368    }
2369
2370    #[test]
2371    fn unknown_catalog_enum_members_are_rejected() {
2372        for source in [
2373            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
2374            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
2375        ] {
2376            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
2377                .expect_err("unknown catalog member must be rejected");
2378            assert_eq!(error.code, "unknown-enum-member");
2379        }
2380    }
2381
2382    #[test]
2383    fn default_var_for_binder_resolves_at_all_range_arities() {
2384        // The agent-lab regression: `for I in range(0, 10):` with `I` not
2385        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
2386        // the pinned reference accepts as an implicit global loop binder
2387        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
2388        for (binder, iterable) in [
2389            ("I", "range(0, 10)"),
2390            ("I", "range(3)"),
2391            ("I", "range(1, 5, 2)"),
2392        ] {
2393            let hir = lower_ok(&format!(
2394                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
2395            ));
2396            let (_, actions) = rule_conditions_and_actions(&hir);
2397            let HirStmt::For { variable, body, .. } = &actions[0] else {
2398                panic!("expected a for statement");
2399            };
2400            assert!(
2401                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2402                "the binder resolves to the implicit global 'I', got {variable:?}"
2403            );
2404            assert!(!body.is_empty(), "the loop body lowers");
2405            // The binder use in the body resolves too: `total += I` has a
2406            // GlobalVar operand.
2407            let HirStmt::Assign { value, .. } = &body[0] else {
2408                panic!("expected an assignment in the body");
2409            };
2410            let HirExpr::Binary { right, .. } = value.as_ref() else {
2411                panic!("expected a binary expression");
2412            };
2413            assert!(
2414                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2415                "the binder use inside the body resolves to the implicit global"
2416            );
2417        }
2418    }
2419
2420    #[test]
2421    fn player_variable_range_binder_preserves_host_player_receiver() {
2422        let hir = lower_ok(
2423            "playervar I\nrule \"r\":\n    @Event global\n    for hostPlayer.I in range(3):\n        hostPlayer.I = 1\n",
2424        );
2425        let (_, actions) = rule_conditions_and_actions(&hir);
2426        let HirStmt::For { variable, .. } = &actions[0] else {
2427            panic!("expected a for statement");
2428        };
2429        let HirExpr::PlayerVar {
2430            player,
2431            name,
2432            member_span,
2433            span,
2434        } = variable.as_ref()
2435        else {
2436            panic!("expected a player-variable binder, got {variable:?}");
2437        };
2438        assert_eq!(name, "I");
2439        assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
2440        assert_eq!(span.unwrap().start.line, 4);
2441        assert_eq!(span.unwrap().start.col, 9);
2442        assert_eq!(span.unwrap().end.line, 4);
2443        assert_eq!(span.unwrap().end.col, 21);
2444        let member_span = member_span.expect("player binder member span");
2445        assert_eq!(member_span.start.line, 4);
2446        assert_eq!(member_span.start.col, 20);
2447        assert_eq!(member_span.end.line, 4);
2448        assert_eq!(member_span.end.col, 21);
2449    }
2450
2451    #[test]
2452    fn default_var_names_resolve_as_implicit_globals() {
2453        // Default variable names resolve anywhere a variable may appear,
2454        // matching the pinned reference (no `globalvar` declaration needed).
2455        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
2456        let (_, actions) = rule_conditions_and_actions(&hir);
2457        let HirStmt::Assign { target, .. } = &actions[0] else {
2458            panic!("expected an assignment");
2459        };
2460        assert!(
2461            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2462            "the implicit global resolves, got {target:?}"
2463        );
2464        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
2465        assert_eq!(default_var_index("I"), Some(8));
2466        assert_eq!(default_var_index("AA"), Some(26));
2467        assert_eq!(default_var_index("Z"), Some(25));
2468        assert_eq!(default_var_index("DX"), Some(127));
2469        assert_eq!(default_var_index("DY"), None);
2470        assert_eq!(default_var_index("i"), None);
2471    }
2472
2473    #[test]
2474    fn nested_same_name_for_binders_reuse_the_implicit_global() {
2475        // Nested loops with the same default-var binder reuse the single
2476        // implicit variable, matching the pinned reference (the inner loop
2477        // overwrites the same Workshop global — no separate binding).
2478        let hir = lower_ok(
2479            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
2480        );
2481        let (_, actions) = rule_conditions_and_actions(&hir);
2482        let HirStmt::For {
2483            variable: outer,
2484            body,
2485            ..
2486        } = &actions[0]
2487        else {
2488            panic!("expected an outer for statement");
2489        };
2490        let HirStmt::For {
2491            variable: inner, ..
2492        } = &body[0]
2493        else {
2494            panic!("expected an inner for statement");
2495        };
2496        assert!(
2497            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
2498                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
2499            "both loops bind the same implicit global (spans differ per binder site)"
2500        );
2501    }
2502
2503    #[test]
2504    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
2505        // A lowercase undeclared binder is not a default variable name; the
2506        // pinned reference rejects the program ("Unknown function name"), and
2507        // Wright reports the same reject with the structured
2508        // `unknown-identifier` diagnostic (#114).
2509        let error = compile_error(
2510            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
2511            3,
2512        );
2513        assert_eq!(error.code, "unknown-identifier");
2514        let span = error.span.expect("the error is source-located");
2515        assert_eq!(span.start.line, 3);
2516    }
2517
2518    #[test]
2519    fn syntax_constructs_lower_to_provenance_preserving_hir() {
2520        let hir = lower_ok(
2521            "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",
2522        );
2523        let (_, actions) = rule_conditions_and_actions(&hir);
2524        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
2525            panic!("expected do-while");
2526        };
2527        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
2528        let HirStmt::Switch { arms, .. } = &actions[1] else {
2529            panic!("expected switch");
2530        };
2531        assert_eq!(arms.len(), 2);
2532        let HirSwitchArm::Case {
2533            value: case_value,
2534            body,
2535            ..
2536        } = &arms[0]
2537        else {
2538            panic!("expected case arm");
2539        };
2540        assert!(
2541            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
2542        );
2543        let HirStmt::Assign { value, .. } = &body[0] else {
2544            panic!("expected case assignment");
2545        };
2546        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
2547        let HirSwitchArm::Default { body, .. } = &arms[1] else {
2548            panic!("expected default arm");
2549        };
2550        assert!(matches!(body[0], HirStmt::Assign { .. }));
2551        let HirStmt::Assign { value, .. } = &actions[2] else {
2552            panic!("expected comprehension assignment");
2553        };
2554        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
2555        let HirStmt::Assign { value, .. } = &actions[3] else {
2556            panic!("expected sorted assignment");
2557        };
2558        assert!(
2559            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")))
2560        );
2561        let HirStmt::Assign { value, .. } = &actions[4] else {
2562            panic!("expected string assignment");
2563        };
2564        assert!(
2565            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2566        );
2567    }
2568
2569    #[test]
2570    fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2571        let dict_error = compile_error(
2572            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
2573            4,
2574        );
2575        assert_eq!(dict_error.code, "dict-access");
2576        let lambda_error = compile_error(
2577            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
2578            4,
2579        );
2580        assert_eq!(lambda_error.code, "lambda-context");
2581    }
2582
2583    #[test]
2584    fn do_while_requires_rule_or_definition_prefix_position() {
2585        let error = compile_error(
2586            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
2587            5,
2588        );
2589        assert_eq!(error.code, "do-while-placement");
2590        assert_eq!(
2591            error.message,
2592            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2593        );
2594    }
2595}