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, ReceiverCategory};
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
85mod declarations;
86mod expressions;
87pub(crate) mod policy;
88mod special_forms;
89mod statements;
90mod textures;
91
92/// Lower a parsed program into the Opy HIR contract.
93pub fn lower(
94    program: &cst::Program,
95    files: Vec<SourceFile>,
96    defines: Vec<Define>,
97) -> OpyResult<HirProgram> {
98    lower_with_preprocessing(program, files, defines, &PreprocessingState::default())
99}
100
101pub fn lower_with_preprocessing(
102    program: &cst::Program,
103    files: Vec<SourceFile>,
104    defines: Vec<Define>,
105    preprocessing: &PreprocessingState,
106) -> OpyResult<HirProgram> {
107    let manifest = match Manifest::builtin() {
108        Ok(manifest) => manifest,
109        Err(error) => {
110            return Err(OpyError::new(
111                "manifest-error",
112                format!("cannot load the OPY semantic compatibility manifest: {error}"),
113            ));
114        }
115    };
116    let catalog = match Catalog::builtin() {
117        Ok(catalog) => catalog,
118        Err(error) => {
119            return Err(OpyError::new(
120                "catalog-error",
121                format!("cannot load the Workshop catalog: {error}"),
122            ));
123        }
124    };
125    let mut lowerer = Lowerer {
126        global_declarations: HashMap::new(),
127        player_declarations: HashMap::new(),
128        subroutine_declarations: HashMap::new(),
129        subroutine_definitions: Vec::new(),
130        constant_declarations: HashMap::new(),
131        macro_declarations: HashMap::new(),
132        enums: HashMap::new(),
133        enum_declarations: HashMap::new(),
134        locals: Vec::new(),
135        current_order: 0,
136        allow_dict_literal: false,
137        manifest,
138        catalog,
139        texture_used: false,
140        setup_tags: preprocessing
141            .directives
142            .iter()
143            .any(|directive| matches!(directive.name.as_str(), "setupTags" | "setupTx")),
144        errors: Vec::new(),
145    };
146    lowerer.collect_symbols(program);
147
148    let mut declarations = Vec::new();
149    let mut rules = Vec::new();
150    let mut implicit_subroutines = HashSet::new();
151    for (order, item) in program.top_level.iter().enumerate() {
152        lowerer.current_order = order;
153        match item {
154            TopLevel::Declaration(decl) => {
155                if let Some(declaration) = lowerer.lower_declaration(decl) {
156                    declarations.push(declaration);
157                }
158            }
159            TopLevel::Rule(CstRuleEntry::Rule(rule)) => rules.push(RuleEntry::Rule(
160                lowerer.lower_rule(rule, files.as_slice(), preprocessing)?,
161            )),
162            TopLevel::Rule(CstRuleEntry::SubroutineDef {
163                name,
164                presentation_name,
165                span,
166                name_span,
167                body,
168                annotations,
169                rule_prefix,
170            }) => {
171                if !lowerer.subroutine_declarations.contains_key(name)
172                    && implicit_subroutines.insert(name.clone())
173                {
174                    declarations.push(Declaration::Subroutine {
175                        name: name.clone(),
176                        index: None,
177                        span: Some(span.into()),
178                        name_span: Some(name_span.into()),
179                    });
180                }
181                let base_name = presentation_name
182                    .as_deref()
183                    .map(str::to_string)
184                    .unwrap_or_else(|| name.clone());
185                let generated_name = render_rule_name(
186                    &base_name,
187                    rule_prefix.as_deref(),
188                    false,
189                    *span,
190                    files.as_slice(),
191                    preprocessing,
192                )?;
193                rules.push(RuleEntry::SubroutineDef {
194                    kind: "subroutineDef".to_string(),
195                    name: generated_name,
196                    source_name: name.clone(),
197                    span: Some(span.into()),
198                    name_span: Some(name_span.into()),
199                    body: lowerer.lower_block(body, &[], false, true, false),
200                    annotations: lower_annotations(annotations),
201                });
202            }
203        }
204    }
205
206    if !lowerer.errors.is_empty() {
207        return Err(lowerer.errors.swap_remove(0));
208    }
209
210    if lowerer.texture_used || lowerer.setup_tags {
211        declarations.insert(
212            0,
213            Declaration::GlobalVariable {
214                name: "__holygrail__".to_string(),
215                index: Some(127),
216                span: None,
217                name_span: None,
218                initializer: None,
219            },
220        );
221        rules.insert(0, texture_setup_rule());
222    }
223
224    Ok(HirProgram {
225        protocol: Protocol {
226            name: PROTOCOL_NAME.to_string(),
227            version: PROTOCOL_VERSION.to_string(),
228        },
229        generator: Generator {
230            name: crate::LANGUAGE_NAME.to_string(),
231            version: crate::LANGUAGE_VERSION.to_string(),
232            frontend: crate::LANGUAGE_NAME.to_string(),
233        },
234        files,
235        defines,
236        declarations,
237        rules,
238        settings: program.settings.as_ref().map(lower_settings),
239        preprocessing: preprocessing.clone(),
240    })
241}
242
243fn texture_setup_rule() -> RuleEntry {
244    RuleEntry::Rule(Rule {
245        name: format!("OverPy <{}tx> / <{}fg> setup code", '\u{00ad}', '\u{00ad}'),
246        span: None,
247        name_span: None,
248        disabled: false,
249        delimiter: false,
250        new_page: None,
251        annotations: Vec::new(),
252        event: Event {
253            name: "global".to_string(),
254            args: Vec::new(),
255            span: None,
256        },
257        conditions: Vec::new(),
258        actions: vec![
259            HirStmt::Expr {
260                expr: Box::new(HirExpr::Call {
261                    name: "createDummy".to_string(),
262                    args: vec![
263                        HirExpr::Call {
264                            name: "getAllHeroes".to_string(),
265                            args: Vec::new(),
266                            debug_source: None,
267                            span: None,
268                        },
269                        texture_dummy_team(),
270                        HirExpr::Bool {
271                            value: false,
272                            span: None,
273                        },
274                        HirExpr::Null { span: None },
275                        HirExpr::Null { span: None },
276                    ],
277                    debug_source: None,
278                    span: None,
279                }),
280                span: None,
281            },
282            HirStmt::Expr {
283                expr: Box::new(HirExpr::ReceiverCall {
284                    receiver: Box::new(texture_dummy_player()),
285                    name: "startForcingName".to_string(),
286                    args: vec![texture_marker('\u{303c}')],
287                    span: None,
288                }),
289                span: None,
290            },
291            HirStmt::Assign {
292                target: Box::new(HirExpr::GlobalVar {
293                    name: "__holygrail__".to_string(),
294                    span: None,
295                }),
296                value: Box::new(HirExpr::ReceiverCall {
297                    receiver: Box::new(texture_dummy_first_value()),
298                    name: "split".to_string(),
299                    args: vec![HirExpr::Array {
300                        elements: Vec::new(),
301                        span: None,
302                    }],
303                    span: None,
304                }),
305                span: None,
306            },
307            HirStmt::Expr {
308                expr: Box::new(HirExpr::ReceiverCall {
309                    receiver: Box::new(texture_dummy_player()),
310                    name: "startForcingName".to_string(),
311                    args: vec![texture_marker('\u{840}')],
312                    span: None,
313                }),
314                span: None,
315            },
316            HirStmt::Assign {
317                target: Box::new(HirExpr::GlobalVar {
318                    name: "__holygrail__".to_string(),
319                    span: None,
320                }),
321                value: Box::new(HirExpr::ReceiverCall {
322                    receiver: Box::new(HirExpr::ReceiverCall {
323                        receiver: Box::new(texture_marker('\u{303c}')),
324                        name: "replace".to_string(),
325                        args: vec![
326                            HirExpr::GlobalVar {
327                                name: "__holygrail__".to_string(),
328                                span: None,
329                            },
330                            texture_dummy_first_value(),
331                        ],
332                        span: None,
333                    }),
334                    name: "substring".to_string(),
335                    args: vec![
336                        HirExpr::Number {
337                            value: 126.0,
338                            text: "126".to_string(),
339                            span: None,
340                        },
341                        HirExpr::Bool {
342                            value: true,
343                            span: None,
344                        },
345                    ],
346                    span: None,
347                }),
348                span: None,
349            },
350            HirStmt::Expr {
351                expr: Box::new(HirExpr::Call {
352                    name: "destroyAllDummies".to_string(),
353                    args: Vec::new(),
354                    debug_source: None,
355                    span: None,
356                }),
357                span: None,
358            },
359        ],
360    })
361}
362
363fn texture_dummy_player() -> HirExpr {
364    HirExpr::Call {
365        name: "lastCreatedEntity".to_string(),
366        args: Vec::new(),
367        debug_source: None,
368        span: None,
369    }
370}
371
372fn texture_dummy_first_value() -> HirExpr {
373    HirExpr::Index {
374        array: Box::new(texture_dummy_player()),
375        index: Box::new(HirExpr::Number {
376            value: 0.0,
377            text: "0".to_string(),
378            span: None,
379        }),
380        span: None,
381    }
382}
383
384fn texture_dummy_team() -> HirExpr {
385    HirExpr::Conditional {
386        then_value: Box::new(HirExpr::Enum {
387            value_type: "Team".to_string(),
388            value: "TEAM_1".to_string(),
389            span: None,
390        }),
391        condition: Box::new(HirExpr::Call {
392            name: "getNumberOfSlots".to_string(),
393            args: vec![HirExpr::Enum {
394                value_type: "Team".to_string(),
395                value: "TEAM_1".to_string(),
396                span: None,
397            }],
398            debug_source: None,
399            span: None,
400        }),
401        else_value: Box::new(HirExpr::Conditional {
402            then_value: Box::new(HirExpr::Enum {
403                value_type: "Team".to_string(),
404                value: "TEAM_2".to_string(),
405                span: None,
406            }),
407            condition: Box::new(HirExpr::Call {
408                name: "getNumberOfSlots".to_string(),
409                args: vec![HirExpr::Enum {
410                    value_type: "Team".to_string(),
411                    value: "TEAM_2".to_string(),
412                    span: None,
413                }],
414                debug_source: None,
415                span: None,
416            }),
417            else_value: Box::new(HirExpr::Bool {
418                value: true,
419                span: None,
420            }),
421            span: None,
422        }),
423        span: None,
424    }
425}
426
427fn texture_marker(suffix: char) -> HirExpr {
428    HirExpr::String {
429        value: format!("{}{}", "_".repeat(126), suffix),
430        span: None,
431    }
432}
433
434/// Lower a settings expression through the same CST-to-HIR semantic path as
435/// ordinary OPY expressions.
436pub(crate) fn lower_settings_expression(
437    program: &cst::Program,
438    text: &str,
439    file: u32,
440    origin: crate::diag::Position,
441) -> OpyResult<HirExpr> {
442    let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
443    let manifest = Manifest::builtin().map_err(|error| {
444        OpyError::new(
445            "manifest-error",
446            format!("cannot load the OPY semantic compatibility manifest: {error}"),
447        )
448    })?;
449    let catalog = Catalog::builtin().map_err(|error| {
450        OpyError::new(
451            "catalog-error",
452            format!("cannot load the Workshop catalog: {error}"),
453        )
454    })?;
455    let mut lowerer = Lowerer {
456        global_declarations: HashMap::new(),
457        player_declarations: HashMap::new(),
458        subroutine_declarations: HashMap::new(),
459        subroutine_definitions: Vec::new(),
460        constant_declarations: HashMap::new(),
461        macro_declarations: HashMap::new(),
462        enums: HashMap::new(),
463        enum_declarations: HashMap::new(),
464        locals: Vec::new(),
465        current_order: program.top_level.len(),
466        allow_dict_literal: true,
467        manifest,
468        catalog,
469        texture_used: false,
470        setup_tags: false,
471        errors: Vec::new(),
472    };
473    lowerer.collect_symbols(program);
474    let lowered = lowerer.lower_expr(&expression, &[], CallPosition::Value);
475    lowerer.errors.into_iter().next().map_or(Ok(lowered), Err)
476}
477
478fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String {
479    match prefix {
480        Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => {
481            format!("[{prefix}] {name}")
482        }
483        _ => name.to_string(),
484    }
485}
486
487#[derive(Clone, Debug)]
488enum TemplateValue {
489    String(String),
490    Bool(bool),
491}
492
493fn render_rule_name(
494    name: &str,
495    prefix: Option<&str>,
496    delimiter: bool,
497    span: Span,
498    files: &[SourceFile],
499    preprocessing: &PreprocessingState,
500) -> OpyResult<String> {
501    let Some(template) = preprocessing
502        .rule_prefix_template
503        .as_ref()
504        .map(|value| value.value.as_str())
505    else {
506        return Ok(prefixed_rule_name(name, prefix, delimiter));
507    };
508    let (file, path) = rule_file_parts(span.file, files);
509    let prefix = prefix.unwrap_or_default();
510    let values = [
511        ("$rule", TemplateValue::String(name.to_string())),
512        ("$prefix", TemplateValue::String(prefix.to_string())),
513        ("$file", TemplateValue::String(file.clone())),
514        ("$path", TemplateValue::String(path.clone())),
515        ("$isDelimiter", TemplateValue::Bool(delimiter)),
516        ("$prefixTitle", TemplateValue::String(title_case(prefix))),
517        ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())),
518        ("$prefixLower", TemplateValue::String(prefix.to_lowercase())),
519        ("$fileTitle", TemplateValue::String(title_case(&file))),
520        ("$fileUpper", TemplateValue::String(file.to_uppercase())),
521        ("$fileLower", TemplateValue::String(file.to_lowercase())),
522        ("$pathTitle", TemplateValue::String(title_case(&path))),
523        ("$pathUpper", TemplateValue::String(path.to_uppercase())),
524        ("$pathLower", TemplateValue::String(path.to_lowercase())),
525    ];
526    evaluate_template(template, &values).map_err(|message| {
527        OpyError::at(
528            "rule-prefix-template-invalid",
529            format!("could not resolve rule prefix template: {message}"),
530            span,
531        )
532    })
533}
534
535fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) {
536    let path = files
537        .iter()
538        .find(|file| file.id == file_id)
539        .map(|file| file.path.replace('\\', "/"))
540        .unwrap_or_default();
541    let without_extension = path
542        .strip_suffix(".opy")
543        .or_else(|| path.strip_suffix(".OPY"))
544        .unwrap_or(&path)
545        .to_string();
546    let file = without_extension
547        .rsplit('/')
548        .next()
549        .unwrap_or_default()
550        .to_string();
551    (file, without_extension)
552}
553
554fn title_case(value: &str) -> String {
555    let mut result = String::with_capacity(value.len());
556    let mut capitalize = true;
557    for ch in value.chars() {
558        if ch == '_' {
559            result.push(' ');
560            capitalize = true;
561        } else if capitalize && ch.is_ascii_alphabetic() {
562            result.push(ch.to_ascii_uppercase());
563            capitalize = false;
564        } else {
565            result.push(ch);
566            if !ch.is_whitespace() && ch != '/' {
567                capitalize = false;
568            }
569        }
570        if ch == '/' || ch.is_whitespace() {
571            capitalize = true;
572        }
573    }
574    result
575}
576
577fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
578    if let Some((then_value, condition, else_value)) = split_conditional(template) {
579        let branch = if evaluate_condition(condition, values)? {
580            then_value
581        } else {
582            else_value
583        };
584        return evaluate_string(branch, values);
585    }
586    evaluate_string(template, values)
587}
588
589fn split_conditional(value: &str) -> Option<(&str, &str, &str)> {
590    let mut quote = None;
591    let mut depth = 0usize;
592    let mut if_start = None;
593    let mut else_start = None;
594    for (index, ch) in value.char_indices() {
595        match (ch, quote) {
596            ('"' | '\'', None) => quote = Some(ch),
597            (ch, Some(current)) if ch == current => quote = None,
598            ('{', None) => depth += 1,
599            ('}', None) => depth = depth.saturating_sub(1),
600            _ => {}
601        }
602        if quote.is_none() && depth == 0 {
603            if value[index..].starts_with(" if ") && if_start.is_none() {
604                if_start = Some(index);
605            } else if value[index..].starts_with(" else ") && else_start.is_none() {
606                else_start = Some(index);
607            }
608        }
609    }
610    let (Some(if_start), Some(else_start)) = (if_start, else_start) else {
611        return None;
612    };
613    Some((
614        value[..if_start].trim(),
615        value[if_start + 4..else_start].trim(),
616        value[else_start + 6..].trim(),
617    ))
618}
619
620fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result<bool, String> {
621    let value = value.trim();
622    if let Some(rest) = value.strip_prefix("not ") {
623        return Ok(!evaluate_condition(rest, values)?);
624    }
625    if let Some((left, right)) = value.split_once(" or ") {
626        return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?);
627    }
628    if let Some((left, right)) = value.split_once(" and ") {
629        return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?);
630    }
631    match lookup_template_value(value, values)? {
632        TemplateValue::Bool(value) => Ok(value),
633        TemplateValue::String(value) => Ok(!value.is_empty()),
634    }
635}
636
637fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
638    let value = value.trim();
639    if let Some(body) = value
640        .strip_prefix("f\"")
641        .and_then(|body| body.strip_suffix('"'))
642    {
643        return interpolate_fstring(body, values);
644    }
645    if let Some(body) = value
646        .strip_prefix("f'")
647        .and_then(|body| body.strip_suffix('\''))
648    {
649        return interpolate_fstring(body, values);
650    }
651    if value.len() >= 2
652        && ((value.starts_with('"') && value.ends_with('"'))
653            || (value.starts_with('\'') && value.ends_with('\'')))
654    {
655        return Ok(value[1..value.len() - 1].to_string());
656    }
657    match lookup_template_value(value, values)? {
658        TemplateValue::String(value) => Ok(value),
659        TemplateValue::Bool(value) => Ok(value.to_string()),
660    }
661}
662
663fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result<String, String> {
664    let mut result = String::new();
665    let mut remaining = body;
666    while let Some(start) = remaining.find('{') {
667        result.push_str(&remaining[..start]);
668        let end = remaining[start + 1..]
669            .find('}')
670            .ok_or_else(|| "unterminated interpolation".to_string())?
671            + start
672            + 1;
673        result.push_str(&evaluate_string(&remaining[start + 1..end], values)?);
674        remaining = &remaining[end + 1..];
675    }
676    result.push_str(remaining);
677    Ok(result)
678}
679
680fn lookup_template_value(
681    value: &str,
682    values: &[(&str, TemplateValue)],
683) -> Result<TemplateValue, String> {
684    let value = value.trim();
685    let (base, mut methods) = value
686        .split_once('.')
687        .map_or((value, ""), |(base, methods)| (base, methods));
688    let mut result = values
689        .iter()
690        .find(|(name, _)| *name == base)
691        .map(|(_, value)| value.clone())
692        .ok_or_else(|| format!("unsupported expression '{value}'"))?;
693    while !methods.is_empty() {
694        let (method, rest) = methods
695            .split_once('.')
696            .map_or((methods, ""), |(method, rest)| (method, rest));
697        if method == "upper()" {
698            result = TemplateValue::String(as_string(&result).to_uppercase());
699        } else if method == "lower()" {
700            result = TemplateValue::String(as_string(&result).to_lowercase());
701        } else if let Some(args) = method
702            .strip_prefix("replace(")
703            .and_then(|v| v.strip_suffix(')'))
704        {
705            let (from, to) = args
706                .split_once(',')
707                .ok_or_else(|| "replace expects two arguments".to_string())?;
708            let from = unquote_template_arg(from.trim())?;
709            let to = unquote_template_arg(to.trim())?;
710            result = TemplateValue::String(as_string(&result).replace(&from, &to));
711        } else {
712            return Err(format!("unsupported method '{method}'"));
713        }
714        methods = rest;
715    }
716    Ok(result)
717}
718
719fn as_string(value: &TemplateValue) -> String {
720    match value {
721        TemplateValue::String(value) => value.clone(),
722        TemplateValue::Bool(value) => value.to_string(),
723    }
724}
725
726fn unquote_template_arg(value: &str) -> Result<String, String> {
727    if value.len() >= 2
728        && ((value.starts_with('"') && value.ends_with('"'))
729            || (value.starts_with('\'') && value.ends_with('\'')))
730    {
731        Ok(value[1..value.len() - 1].to_string())
732    } else {
733        Err(format!("expected a quoted string argument, got '{value}'"))
734    }
735}
736
737fn lower_annotations(annotations: &[cst::Annotation]) -> Vec<HirAnnotation> {
738    annotations
739        .iter()
740        .map(|annotation| HirAnnotation {
741            name: annotation.name.clone(),
742            args: annotation
743                .args
744                .iter()
745                .map(|arg| HirAnnotationArg {
746                    text: arg.text.clone(),
747                    span: Some(arg.span.into()),
748                })
749                .collect(),
750            span: Some(annotation.span.into()),
751        })
752        .collect()
753}
754
755/// Map a parsed CST settings block onto the protocol settings tree (#86).
756fn lower_settings(settings: &cst::Settings) -> HirSettings {
757    HirSettings {
758        span: Some(settings.span.into()),
759        children: settings.children.iter().map(lower_settings_node).collect(),
760    }
761}
762
763fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode {
764    match node {
765        cst::SettingsNode::Group {
766            name,
767            children,
768            span,
769        } => HirSettingsNode::Group {
770            name: name.clone(),
771            children: children.iter().map(lower_settings_node).collect(),
772            span: Some((*span).into()),
773        },
774        cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number {
775            name: name.clone(),
776            value: *value,
777            span: Some((*span).into()),
778        },
779        cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool {
780            name: name.clone(),
781            value: *value,
782            span: Some((*span).into()),
783        },
784        cst::SettingsNode::String { name, value, span } => HirSettingsNode::String {
785            name: name.clone(),
786            value: value.clone(),
787            span: Some((*span).into()),
788        },
789        cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw {
790            name: name.clone(),
791            value: value.clone(),
792            span: Some((*span).into()),
793        },
794        cst::SettingsNode::List {
795            name,
796            elements,
797            span,
798        } => HirSettingsNode::List {
799            name: name.clone(),
800            elements: elements
801                .iter()
802                .map(|element| crate::hir::types::SettingsListElement {
803                    value: element.value.clone(),
804                    span: Some(element.span.into()),
805                })
806                .collect(),
807            span: Some((*span).into()),
808        },
809    }
810}
811
812impl Lowerer {
813    fn error_at(&mut self, code: &str, message: String, span: Span) {
814        self.errors.push(OpyError::at(code, message, span));
815    }
816}
817
818/// The keyword spellings a parameter accepts (its name plus alternates).
819fn keyword_spellings(param: &Param) -> Vec<String> {
820    let mut spellings = vec![param.name.clone()];
821    spellings.extend(param.alternate_names.iter().cloned());
822    spellings
823}
824
825/// The source span covering a call's argument list (the start of the first
826/// argument through the last argument).
827fn arg_span(args: &[CallArg]) -> Span {
828    args.first().map(CallArg::span).unwrap_or_else(|| {
829        Span::new(
830            0,
831            crate::diag::Position::new(1, 1),
832            crate::diag::Position::new(1, 1),
833        )
834    })
835}
836
837/// Lower a source-level player context value without adding a new HIR node
838/// kind. `attacker` and `victim` are catalog-backed zero-argument values;
839/// using the existing `Call` node keeps their source identity and span while
840/// leaving canonical value ownership with Workshop.
841fn context_player_expr(name: &str, span: Option<Span>) -> Option<HirExpr> {
842    match name {
843        "eventPlayer" => Some(HirExpr::EventPlayer {
844            span: span.map(Into::into),
845        }),
846        "localPlayer" => Some(HirExpr::Call {
847            name: name.to_string(),
848            args: Vec::new(),
849            debug_source: None,
850            span: span.map(Into::into),
851        }),
852        "hostPlayer" => Some(HirExpr::HostPlayer {
853            span: span.map(Into::into),
854        }),
855        "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call {
856            name: name.to_string(),
857            args: Vec::new(),
858            debug_source: None,
859            span: span.map(Into::into),
860        }),
861        _ => None,
862    }
863}
864
865/// Whether a CST receiver is assignable (the `.append` receiver rule): a
866/// variable name (including macro parameters), an array literal, or an index
867/// expression — matching the pinned reference, which rejects constant and
868/// function receivers ("Cannot modify or assign to …").
869fn assignable_receiver(receiver: &Expr) -> bool {
870    match receiver {
871        Expr::Name { name, .. } => !matches!(
872            name.as_str(),
873            "eventPlayer" | "hostPlayer" | "attacker" | "victim"
874        ),
875        Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true,
876        _ => false,
877    }
878}
879
880fn expr_identifier(expr: &Expr) -> Option<&str> {
881    match expr {
882        Expr::Name { name, .. } => Some(name.as_str()),
883        _ => None,
884    }
885}
886
887fn indexed_expr_depth(expr: &Expr) -> usize {
888    match expr {
889        Expr::Index { array, .. } => 1 + indexed_expr_depth(array),
890        _ => 0,
891    }
892}
893
894impl From<Span> for HirSpan {
895    fn from(span: Span) -> HirSpan {
896        HirSpan {
897            file: span.file,
898            start: Position {
899                line: span.start.line,
900                col: span.start.col,
901            },
902            end: Position {
903                line: span.end.line,
904                col: span.end.col,
905            },
906        }
907    }
908}
909
910impl From<&Span> for HirSpan {
911    fn from(span: &Span) -> HirSpan {
912        (*span).into()
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use crate::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt};
920    use crate::lexer::{LexInput, lex};
921    use crate::parser::parse;
922
923    fn lower_ok(text: &str) -> HirProgram {
924        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
925        let output = parse(&tokens);
926        assert!(
927            output.errors.is_empty(),
928            "unexpected parse errors: {:?}",
929            output.errors
930        );
931        let program = output.program.expect("parse produces a program");
932        lower(&program, vec![], vec![]).expect("lowers without errors")
933    }
934
935    fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec<HirExpr>, &Vec<HirStmt>) {
936        let HirRuleEntry::Rule(rule) = &hir.rules[0] else {
937            panic!("expected a rule");
938        };
939        (&rule.conditions, &rule.actions)
940    }
941
942    #[test]
943    fn producer_emits_the_v2_ordered_switch_contract() {
944        let hir = lower_ok(
945            "globalvar value\nrule \"r\":\n    @Event global\n    switch value:\n        default:\n            value = 1\n        case 2:\n            value = 2\n",
946        );
947        assert_eq!(hir.protocol.name, "wright/opy-hir");
948        assert_eq!(hir.protocol.version, "2.0.0");
949        let value = serde_json::to_value(&hir).expect("HIR must serialize");
950        let switch = &value["rules"][0]["actions"][0];
951        assert!(switch.get("arms").is_some());
952        assert!(switch.get("cases").is_none());
953        assert!(switch.get("default").is_none());
954    }
955
956    #[test]
957    fn receiver_calls_lower_to_receiver_call_hir() {
958        // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the
959        // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a
960        // global-variable receiver (#104).
961        let hir = lower_ok(
962            "globalvar target\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n    target.setMoveSpeed(50)\n",
963        );
964        let (_, actions) = rule_conditions_and_actions(&hir);
965        assert_eq!(actions.len(), 2);
966
967        let HirStmt::Expr { expr, .. } = &actions[0] else {
968            panic!("expected expression statement");
969        };
970        let HirExpr::ReceiverCall {
971            receiver,
972            name,
973            args,
974            ..
975        } = expr.as_ref()
976        else {
977            panic!("expected receiver call, got {expr:?}");
978        };
979        assert_eq!(name, "setMoveSpeed");
980        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
981        assert_eq!(args.len(), 1);
982        assert!(matches!(&args[0], HirExpr::Number { .. }));
983
984        let HirStmt::Expr { expr, .. } = &actions[1] else {
985            panic!("expected expression statement");
986        };
987        let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else {
988            panic!("expected receiver call, got {expr:?}");
989        };
990        assert_eq!(name, "setMoveSpeed");
991        assert!(
992            matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"),
993            "globalvar receiver must resolve to a GlobalVar"
994        );
995    }
996
997    #[test]
998    fn bare_variable_member_expression_preserves_receiver_and_member() {
999        let hir = lower_ok(
1000            "globalvar A\nplayervar B\nrule \"receiver\":\n    @Event eachPlayer\n    A = B.C\n",
1001        );
1002        let HirStmt::Assign { value, .. } = &hir
1003            .rules
1004            .iter()
1005            .find_map(|entry| {
1006                let RuleEntry::Rule(rule) = entry else {
1007                    return None;
1008                };
1009                rule.actions.first()
1010            })
1011            .expect("assignment")
1012        else {
1013            panic!("expected assignment");
1014        };
1015        let HirExpr::Member {
1016            receiver, member, ..
1017        } = value.as_ref()
1018        else {
1019            panic!("expected opaque member expression, got {value:?}");
1020        };
1021        assert_eq!(member, "C");
1022        assert!(matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "B"));
1023    }
1024
1025    #[test]
1026    fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() {
1027        let text = "rule \"before\":\n    pass\ndef source_name():\n    @Name \"Friendly\"\n    pass\nrule \"after\":\n    pass\n";
1028        let tokens = lex(LexInput { file_id: 0, text }).expect("lexes");
1029        let output = parse(&tokens);
1030        assert!(
1031            output.errors.is_empty(),
1032            "unexpected parse errors: {:?}",
1033            output.errors
1034        );
1035        let program = output.program.expect("program");
1036        let preprocessing = PreprocessingState {
1037            rule_prefix_template: Some(crate::hir::types::DirectiveValue {
1038                value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(),
1039                span: None,
1040            }),
1041            ..PreprocessingState::default()
1042        };
1043        let hir = lower_with_preprocessing(
1044            &program,
1045            vec![SourceFile {
1046                id: 0,
1047                path: "main.opy".to_string(),
1048            }],
1049            vec![],
1050            &preprocessing,
1051        )
1052        .expect("lowers");
1053        let names: Vec<_> = hir
1054            .rules
1055            .iter()
1056            .map(|entry| match entry {
1057                HirRuleEntry::Rule(rule) => rule.name.clone(),
1058                HirRuleEntry::SubroutineDef { name, .. } => name.clone(),
1059            })
1060            .collect();
1061        assert_eq!(
1062            names,
1063            vec!["[Main] before", "[Main] Friendly", "[Main] after"]
1064        );
1065        let HirRuleEntry::SubroutineDef {
1066            name, source_name, ..
1067        } = &hir.rules[1]
1068        else {
1069            panic!("expected subroutine definition");
1070        };
1071        assert_eq!(name, "[Main] Friendly");
1072        assert_eq!(source_name, "source_name");
1073    }
1074
1075    #[test]
1076    fn receiver_call_values_lower_in_conditions() {
1077        // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value;
1078        // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver
1079        // call inside another receiver call's arguments (#104).
1080        let hir = lower_ok(
1081            "rule \"r\":\n    @Event eachPlayer\n    @Condition eventPlayer.isAlive()\n    eventPlayer.teleport(eventPlayer.getPosition())\n",
1082        );
1083        let (conditions, actions) = rule_conditions_and_actions(&hir);
1084        assert_eq!(conditions.len(), 1);
1085        let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else {
1086            panic!("expected receiver call condition, got {:?}", conditions[0]);
1087        };
1088        assert_eq!(name, "isAlive");
1089        assert_eq!(args.len(), 0);
1090
1091        let HirStmt::Expr { expr, .. } = &actions[0] else {
1092            panic!("expected expression statement");
1093        };
1094        let HirExpr::ReceiverCall {
1095            name,
1096            args,
1097            receiver,
1098            ..
1099        } = expr.as_ref()
1100        else {
1101            panic!("expected receiver call, got {expr:?}");
1102        };
1103        assert_eq!(name, "teleport");
1104        assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. }));
1105        assert_eq!(args.len(), 1);
1106        assert!(matches!(
1107            &args[0],
1108            HirExpr::ReceiverCall { name, .. } if name == "getPosition"
1109        ));
1110    }
1111
1112    #[test]
1113    fn format_string_receiver_stays_a_format_node() {
1114        // `.format()` on a string receiver is unaffected by the receiver-call
1115        // path (existing supported form).
1116        let hir = lower_ok(
1117            "rule \"r\":\n    @Event global\n    print(\"{} points\".format(len([1, 2])))\n",
1118        );
1119        let (_, actions) = rule_conditions_and_actions(&hir);
1120        let HirStmt::Expr { expr, .. } = &actions[0] else {
1121            panic!("expected expression statement");
1122        };
1123        assert!(
1124            has_format(expr),
1125            "string `.format()` must lower to a Format node"
1126        );
1127    }
1128
1129    fn has_format(expr: &HirExpr) -> bool {
1130        match expr {
1131            HirExpr::Format { .. } => true,
1132            HirExpr::Call { args, .. } => args.iter().any(has_format),
1133            HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format),
1134            _ => false,
1135        }
1136    }
1137
1138    /// Lower one rule action and return the assignment's value expression.
1139    fn lowered_value(source: &str) -> HirExpr {
1140        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1141            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1142        let RuleEntry::Rule(rule) = &program.rules[0] else {
1143            panic!("expected a rule");
1144        };
1145        let HirStmt::Assign { value, .. } = &rule.actions[0] else {
1146            panic!("expected an assign statement");
1147        };
1148        (**value).clone()
1149    }
1150
1151    #[test]
1152    fn chase_time_reeval_none_lowers_to_the_catalog_enum() {
1153        let value = lowered_value(
1154            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NONE\n",
1155        );
1156        assert_enum(&value, "ChaseTimeReeval", "NONE");
1157    }
1158
1159    #[test]
1160    fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() {
1161        let value = lowered_value(
1162            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.DESTINATION_AND_DURATION\n",
1163        );
1164        assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION");
1165    }
1166
1167    #[test]
1168    fn chase_rate_reeval_members_lower_to_the_catalog_enum() {
1169        for member in ["NONE", "DESTINATION_AND_RATE"] {
1170            let source = format!(
1171                "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseRateReeval.{member}\n"
1172            );
1173            assert_enum(&lowered_value(&source), "ChaseRateReeval", member);
1174        }
1175    }
1176
1177    /// Assert the expression is the catalog enum `(domain, member)` node,
1178    /// ignoring its source span (the span is frontend-internal provenance).
1179    fn assert_enum(value: &HirExpr, domain: &str, member: &str) {
1180        match value {
1181            HirExpr::Enum {
1182                value_type, value, ..
1183            } => {
1184                assert_eq!(value_type, domain);
1185                assert_eq!(value, member);
1186            }
1187            other => panic!("expected enum {domain}.{member}, got {other:?}"),
1188        }
1189    }
1190
1191    #[test]
1192    fn unknown_chase_time_reeval_member_is_rejected_by_the_catalog() {
1193        let error = crate::compile(
1194            "globalvar g\nrule \"r\":\n    @Event global\n    g = ChaseTimeReeval.NOPE\n",
1195            "test.opy",
1196            std::path::Path::new(""),
1197        )
1198        .expect_err("unknown catalog member must be rejected");
1199        assert_eq!(error.code, "unknown-enum-member");
1200    }
1201
1202    #[test]
1203    fn unknown_enum_receiver_is_an_unsupported_member_error() {
1204        let error = crate::compile(
1205            "globalvar g\nrule \"r\":\n    @Event global\n    g = NotARealEnum.MEMBER\n",
1206            "test.opy",
1207            std::path::Path::new(""),
1208        )
1209        .expect_err("an unknown enum type must fail");
1210        assert_eq!(error.code, "unsupported-member");
1211        let span = error.span.expect("the error is source-located");
1212        assert_eq!(span.start.line, 4);
1213    }
1214
1215    #[test]
1216    fn unknown_texture_member_is_rejected_by_the_source_surface() {
1217        let error = crate::compile(
1218            "globalvar g\nrule \"r\":\n    @Event global\n    g = Texture.NOT_A_TEXTURE\n",
1219            "test.opy",
1220            std::path::Path::new(""),
1221        )
1222        .expect_err("unknown texture member must be rejected");
1223        assert_eq!(error.code, "unknown-texture-member");
1224    }
1225
1226    // --- Builtin semantic manifest coverage (#109) ---
1227
1228    /// Assert a compile failure has the given code at the given line.
1229    fn compile_error(source: &str, line: u32) -> OpyError {
1230        let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1231            .expect_err("expected a compile failure");
1232        let span = error.span.expect("the error is source-located");
1233        assert_eq!(span.start.line, line, "code '{}'", error.code);
1234        error
1235    }
1236
1237    fn action_source(statement: &str) -> String {
1238        format!("globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n")
1239    }
1240
1241    #[test]
1242    fn chase_over_time_resolves_and_compiles_with_reference_signatures() {
1243        // 4-argument form with an explicit reevaluation member (#106).
1244        let hir = crate::compile(
1245            &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"),
1246            "test.opy",
1247            std::path::Path::new(""),
1248        )
1249        .expect("reference-supported chaseOverTime compiles");
1250        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1251            panic!("expected a rule");
1252        };
1253        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1254            panic!("expected expression statement");
1255        };
1256        let HirExpr::Call { name, args, .. } = expr.as_ref() else {
1257            panic!("expected a call, got {expr:?}");
1258        };
1259        assert_eq!(name, "chaseOverTime");
1260        assert_eq!(args.len(), 4);
1261        assert!(matches!(
1262            &args[3],
1263            HirExpr::Enum { value_type, value, .. }
1264                if value_type == "ChaseTimeReeval" && value == "NONE"
1265        ));
1266
1267        // 3-argument form fills the reference default member.
1268        let hir = crate::compile(
1269            &action_source("chaseOverTime(g, 10, 3)"),
1270            "test.opy",
1271            std::path::Path::new(""),
1272        )
1273        .expect("default-reevaluation chaseOverTime compiles");
1274        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1275            panic!("expected a rule");
1276        };
1277        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1278            panic!("expected expression statement");
1279        };
1280        let HirExpr::Call { args, .. } = expr.as_ref() else {
1281            panic!("expected a call");
1282        };
1283        assert_eq!(args.len(), 4);
1284        assert!(matches!(
1285            &args[3],
1286            HirExpr::Enum { value_type, value, .. }
1287                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1288        ));
1289    }
1290
1291    #[test]
1292    fn is_game_in_progress_resolves_as_a_builtin_value() {
1293        // Generic value gap from #106: `isGameInProgress()` in a condition.
1294        let hir = crate::compile(
1295            &action_source("@Condition isGameInProgress() == true"),
1296            "test.opy",
1297            std::path::Path::new(""),
1298        )
1299        .expect("reference-supported isGameInProgress compiles");
1300        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1301            panic!("expected a rule");
1302        };
1303        assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. }));
1304    }
1305
1306    #[test]
1307    fn enum_gated_members_resolve_through_the_manifest() {
1308        // Enum-gated members from #106: setInvisibility (Invis), getThrottle
1309        // (member value), worldVector (Transform arg), setStatusEffect
1310        // (Status arg).
1311        let source = "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1312            @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n    \
1313            @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n    \
1314            eventPlayer.setInvisibility(Invis.ALL)\n    \
1315            eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n";
1316        let hir = crate::compile(source, "test.opy", std::path::Path::new(""))
1317            .expect("enum-gated members compile");
1318        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1319            panic!("expected a rule");
1320        };
1321        assert_eq!(rule.actions.len(), 2);
1322    }
1323
1324    #[test]
1325    fn get_players_in_radius_fills_reference_enum_defaults() {
1326        // 2-argument form fills Team.ALL and LosCheck.OFF (reference
1327        // emission: `Players Within Radius(..., All Teams, Off)`).
1328        let hir = crate::compile(
1329            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1330             @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n    \
1331             disableInspector()\n",
1332            "test.opy",
1333            std::path::Path::new(""),
1334        )
1335        .expect("getPlayersInRadius with defaults compiles");
1336        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1337            panic!("expected a rule");
1338        };
1339        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1340            panic!("expected a comparison");
1341        };
1342        let HirExpr::Call { name, args, .. } = left.as_ref() else {
1343            panic!("expected len call");
1344        };
1345        assert_eq!(name, "len");
1346        let HirExpr::Call { name, args, .. } = &args[0] else {
1347            panic!("expected getPlayersInRadius call");
1348        };
1349        assert_eq!(name, "getPlayersInRadius");
1350        assert_eq!(args.len(), 4);
1351        assert!(matches!(
1352            &args[2],
1353            HirExpr::Enum { value_type, value, .. }
1354                if value_type == "Team" && value == "ALL"
1355        ));
1356        assert!(matches!(
1357            &args[3],
1358            HirExpr::Enum { value_type, value, .. }
1359                if value_type == "LosCheck" && value == "OFF"
1360        ));
1361    }
1362
1363    #[test]
1364    fn value_call_in_action_position_is_rejected() {
1365        let error = compile_error(&action_source("isGameInProgress()"), 4);
1366        assert_eq!(error.code, "value-in-action-position");
1367    }
1368
1369    #[test]
1370    fn value_member_in_action_position_is_rejected() {
1371        // The #106 baseline records the oracle rejecting `B.isAlive()` as a
1372        // statement; the manifest enforces that contract (#109).
1373        let error = compile_error(
1374            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.isAlive()\n",
1375            4,
1376        );
1377        assert_eq!(error.code, "value-in-action-position");
1378    }
1379
1380    // --- Named/keyword argument binding and chase call context (#110) ---
1381
1382    /// Compile a program and return the lowered first action's expression
1383    /// (the statement expression, or the value of a leading assignment).
1384    fn first_action_expr(source: &str) -> HirExpr {
1385        let program = crate::compile(source, "test.opy", std::path::Path::new(""))
1386            .unwrap_or_else(|error| panic!("compile failed: {error}"));
1387        let RuleEntry::Rule(rule) = &program.rules[0] else {
1388            panic!("expected a rule");
1389        };
1390        match &rule.actions[0] {
1391            HirStmt::Expr { expr, .. } => (**expr).clone(),
1392            HirStmt::Assign { value, .. } => (**value).clone(),
1393            other => panic!("expected an expression or assignment, got {other:?}"),
1394        }
1395    }
1396
1397    /// Remove every `span`/`name_span` key from a serialized expression (the
1398    /// differential suite's normalization).
1399    fn strip_spans(value: &mut serde_json::Value) {
1400        match value {
1401            serde_json::Value::Object(map) => {
1402                map.remove("span");
1403                map.remove("name_span");
1404                for nested in map.values_mut() {
1405                    strip_spans(nested);
1406                }
1407            }
1408            serde_json::Value::Array(items) => {
1409                for item in items {
1410                    strip_spans(item);
1411                }
1412            }
1413            _ => {}
1414        }
1415    }
1416
1417    #[test]
1418    fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() {
1419        // The reference `chase` form: `rate = …` dispatches to chaseAtRate
1420        // with the ChaseRateReeval domain, `duration = …` to chaseOverTime
1421        // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves
1422        // only through this call context (issue #110).
1423        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)"));
1424        let HirExpr::Call { name, args, .. } = &expr else {
1425            panic!("expected a call, got {expr:?}");
1426        };
1427        assert_eq!(name, "chaseAtRate");
1428        assert!(matches!(
1429            &args[3],
1430            HirExpr::Enum { value_type, value, .. }
1431                if value_type == "ChaseRateReeval" && value == "NONE"
1432        ));
1433
1434        let expr = first_action_expr(&action_source(
1435            "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)",
1436        ));
1437        let HirExpr::Call { name, args, .. } = &expr else {
1438            panic!("expected a call, got {expr:?}");
1439        };
1440        assert_eq!(name, "chaseOverTime");
1441        assert!(matches!(
1442            &args[3],
1443            HirExpr::Enum { value_type, value, .. }
1444                if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION"
1445        ));
1446
1447        // Player-variable first arguments resolve too (the emission layer
1448        // picks the player form).
1449        let expr = first_action_expr(
1450            "playervar P\nrule \"r\":\n    @Event eachPlayer\n    \
1451             chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n",
1452        );
1453        let HirExpr::Call { name, args, .. } = &expr else {
1454            panic!("expected a call, got {expr:?}");
1455        };
1456        assert_eq!(name, "chaseAtRate");
1457        assert!(matches!(&args[0], HirExpr::PlayerVar { .. }));
1458    }
1459
1460    #[test]
1461    fn chase_reeval_is_only_a_standalone_identity_inside_the_chase_context() {
1462        // `ChaseReeval` is a contextual domain, not a standalone domain
1463        // identity: a bare member access outside the chase signature is
1464        // rejected.
1465        let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4);
1466        assert_eq!(error.code, "unsupported-member");
1467
1468        // Inside the chase context the member is an opaque identity
1469        // dispatched to the concrete domain selected by the keyword
1470        // selector; member existence in that domain is not validated here
1471        // (lowering-dependent, #8).
1472        let expr = first_action_expr(&action_source(
1473            "chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)",
1474        ));
1475        let HirExpr::Call { name, args, .. } = &expr else {
1476            panic!("expected a call, got {expr:?}");
1477        };
1478        assert_eq!(name, "chaseAtRate");
1479        assert!(matches!(
1480            &args[3],
1481            HirExpr::Enum { value_type, value, .. }
1482                if value_type == "ChaseRateReeval" && value == "DESTINATION_AND_DURATION"
1483        ));
1484
1485        // A non-enum 4th argument is carried structurally; the reference's
1486        // "expected an enum member" rejection is lowering-dependent.
1487        let expr = first_action_expr(&action_source("chase(g, 10, rate=2, 5)"));
1488        let HirExpr::Call { name, args, .. } = &expr else {
1489            panic!("expected a call, got {expr:?}");
1490        };
1491        assert_eq!(name, "chase");
1492        assert!(matches!(&args[3], HirExpr::Number { .. }));
1493    }
1494
1495    #[test]
1496    fn chase_requires_the_keyword_rate_or_duration_third_argument() {
1497        let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4);
1498        assert_eq!(error.code, "keyword-required");
1499        assert!(error.message.contains("rate"));
1500    }
1501
1502    #[test]
1503    fn chase_family_requires_a_variable_first_argument() {
1504        // The reference rejects non-variable first arguments for the chase
1505        // family ("Expected variable for 1st argument of function
1506        // 'chaseOverTime'", issue #110) — the variable kind also selects
1507        // the global/player emission form.
1508        let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4);
1509        assert_eq!(error.code, "invalid-argument");
1510
1511        let error = compile_error(
1512            &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"),
1513            4,
1514        );
1515        assert_eq!(error.code, "invalid-argument");
1516    }
1517
1518    #[test]
1519    fn keyword_binding_matches_positional_binding_in_hir() {
1520        // Keyword binding consumes the manifest signatures: the bound HIR is
1521        // identical to the positional form's (defaults filled the same way),
1522        // modulo source spans (the keyword values sit at different columns).
1523        fn without_spans(expr: &HirExpr) -> serde_json::Value {
1524            let mut value = serde_json::to_value(expr).unwrap();
1525            strip_spans(&mut value);
1526            value
1527        }
1528        let keyword = without_spans(&first_action_expr(&action_source(
1529            "chaseOverTime(g, 10, duration=3)",
1530        )));
1531        let positional = without_spans(&first_action_expr(&action_source(
1532            "chaseOverTime(g, 10, 3)",
1533        )));
1534        assert_eq!(keyword, positional);
1535
1536        let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)")));
1537        let positional = without_spans(&first_action_expr(&action_source("wait(1)")));
1538        assert_eq!(keyword, positional);
1539
1540        // Out-of-order keywords bind by name.
1541        let keyword = without_spans(&first_action_expr(&action_source(
1542            "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)",
1543        )));
1544        let positional = without_spans(&first_action_expr(&action_source("wait(2)")));
1545        assert_eq!(keyword, positional);
1546
1547        let keyword = without_spans(&first_action_expr(&action_source(
1548            "g = vect(x=1, y=2, z=3)",
1549        )));
1550        let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)")));
1551        assert_eq!(keyword, positional);
1552    }
1553
1554    #[test]
1555    fn keyword_binding_diagnostics_are_structured_and_source_located() {
1556        // Unknown keyword name.
1557        let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4);
1558        assert_eq!(error.code, "unknown-keyword");
1559        assert!(error.message.contains("bogus"));
1560
1561        // Duplicate (positional slot filled again by keyword).
1562        let error = compile_error(
1563            &action_source(
1564                "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \
1565                 reevaluation=ChaseTimeReeval.NONE)",
1566            ),
1567            4,
1568        );
1569        assert_eq!(error.code, "duplicate-argument");
1570
1571        // Positional after keyword.
1572        let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4);
1573        assert_eq!(error.code, "positional-after-keyword");
1574
1575        // Missing required argument (reference: "Missing argument 'duration'").
1576        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1577        assert_eq!(error.code, "missing-argument");
1578
1579        // Positional-only parameter bound by keyword (`chase`'s leading
1580        // arguments; the reference rejects the keyword form).
1581        let error = compile_error(
1582            &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"),
1583            4,
1584        );
1585        assert_eq!(error.code, "unknown-keyword");
1586    }
1587
1588    #[test]
1589    fn keyword_arguments_are_rejected_for_reference_special_cases() {
1590        // The reference routes `range`, `random.*`, and `.format` around its
1591        // generic keyword binder; keyword arguments fail deterministically.
1592        let error = compile_error(
1593            "globalvar g\nrule \"r\":\n    @Event global\n    \
1594             for I in range(start=0, stop=3):\n        debug(I)\n",
1595            4,
1596        );
1597        assert_eq!(error.code, "keyword-unsupported");
1598
1599        let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4);
1600        assert_eq!(error.code, "keyword-unsupported");
1601
1602        let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4);
1603        assert_eq!(error.code, "keyword-unsupported");
1604    }
1605
1606    #[test]
1607    fn wait_uses_the_reference_keyword_names() {
1608        // The manifest's `wait` parameter names match the pinned reference
1609        // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown
1610        // keyword exactly like the oracle.
1611        let error = compile_error(&action_source("wait(duration=1)"), 4);
1612        assert_eq!(error.code, "unknown-keyword");
1613        assert!(error.message.contains("duration"));
1614    }
1615
1616    #[test]
1617    fn action_call_in_value_position_is_rejected() {
1618        let error = compile_error(&action_source("g = wait(1)"), 4);
1619        assert_eq!(error.code, "action-in-value-position");
1620    }
1621
1622    #[test]
1623    fn missing_required_argument_is_a_source_located_diagnostic() {
1624        // Too-few calls reject with the reference's missing-argument
1625        // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'",
1626        // issue #110); positional overflow keeps `invalid-arity`.
1627        let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4);
1628        assert_eq!(error.code, "missing-argument");
1629        assert!(error.message.contains("duration"));
1630
1631        let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4);
1632        assert_eq!(error.code, "invalid-arity");
1633    }
1634
1635    #[test]
1636    fn missing_member_argument_is_a_source_located_diagnostic() {
1637        // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer,
1638        // 30)` must reject like the oracle (the `status` argument is
1639        // missing; the reference: "Missing argument 'status' for function
1640        // '.setStatusEffect'", issue #110).
1641        let error = compile_error(
1642            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1643             getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n",
1644            4,
1645        );
1646        assert_eq!(error.code, "missing-argument");
1647        assert!(error.message.contains("duration"));
1648    }
1649
1650    #[test]
1651    fn invalid_receiver_categories_are_rejected() {
1652        // `.append` requires an assignable receiver; `.format` a string
1653        // literal (both reference-enforced categories).
1654        let error = compile_error(&action_source("3.append(1)"), 4);
1655        assert_eq!(error.code, "invalid-receiver");
1656        assert!(error.message.contains("append"));
1657
1658        let error = compile_error(&action_source("attacker.append(1)"), 4);
1659        assert_eq!(error.code, "invalid-receiver");
1660        assert!(error.message.contains("append"));
1661
1662        let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4);
1663        assert_eq!(error.code, "invalid-receiver");
1664        assert!(error.message.contains("format"));
1665    }
1666
1667    #[test]
1668    fn cross_domain_enum_arguments_resolve_as_opaque_identities() {
1669        // Domain mismatches need canonical Workshop enum knowledge: a member
1670        // of the wrong domain is carried as an opaque identity and the check
1671        // is lowering-dependent (#8).
1672        let expr = first_action_expr(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"));
1673        let HirExpr::Call { args, .. } = &expr else {
1674            panic!("expected a call, got {expr:?}");
1675        };
1676        assert!(matches!(
1677            &args[3],
1678            HirExpr::Enum { value_type, value, .. }
1679                if value_type == "Invis" && value == "ALL"
1680        ));
1681
1682        let expr = first_action_expr(&action_source(
1683            "eventPlayer.setInvisibility(ChaseTimeReeval.NONE)",
1684        ));
1685        let HirExpr::ReceiverCall { args, .. } = &expr else {
1686            panic!("expected a receiver call, got {expr:?}");
1687        };
1688        assert!(matches!(
1689            &args[0],
1690            HirExpr::Enum { value_type, value, .. }
1691                if value_type == "ChaseTimeReeval" && value == "NONE"
1692        ));
1693    }
1694
1695    #[test]
1696    fn non_enum_arguments_for_enum_parameters_are_carried_structurally() {
1697        // Whether a non-enum value is acceptable for an enum parameter is
1698        // Workshop catalog knowledge; the frontend carries the argument
1699        // structurally and leaves the check to lowering (#8).
1700        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(g)"));
1701        let HirExpr::ReceiverCall { args, .. } = &expr else {
1702            panic!("expected a receiver call, got {expr:?}");
1703        };
1704        assert!(matches!(
1705            &args[0],
1706            HirExpr::GlobalVar { name, .. } if name == "g"
1707        ));
1708
1709        let expr = first_action_expr(&action_source("eventPlayer.setInvisibility(3)"));
1710        let HirExpr::ReceiverCall { args, .. } = &expr else {
1711            panic!("expected a receiver call, got {expr:?}");
1712        };
1713        assert!(matches!(&args[0], HirExpr::Number { .. }));
1714    }
1715
1716    #[test]
1717    fn unknown_builtins_fail_at_resolution_not_emission() {
1718        let error = compile_error(&action_source("frobnicate()"), 4);
1719        assert_eq!(error.code, "unknown-action");
1720
1721        let error = compile_error(&action_source("g = frobnicate()"), 4);
1722        assert_eq!(error.code, "unknown-value");
1723
1724        let error = compile_error(
1725            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    eventPlayer.frobnicate()\n",
1726            4,
1727        );
1728        assert_eq!(error.code, "unknown-member");
1729    }
1730
1731    #[test]
1732    fn wright_only_catalog_names_are_rejected() {
1733        // `createHudText` and `squareRoot` are Workshop emission spellings,
1734        // not OPY source functions; the pinned reference rejects them, so
1735        // the manifest does not preserve the accidental acceptance.
1736        let error = compile_error(&action_source("createHudText(1)"), 4);
1737        assert_eq!(error.code, "unknown-action");
1738
1739        let error = compile_error(&action_source("g = squareRoot(9)"), 4);
1740        assert_eq!(error.code, "unknown-value");
1741    }
1742
1743    #[test]
1744    fn generic_member_only_actions_are_rejected() {
1745        // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the
1746        // member form is the reference surface.
1747        let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4);
1748        assert_eq!(error.code, "unknown-action");
1749    }
1750
1751    #[test]
1752    fn range_is_for_iterables_only() {
1753        // Standalone `range(...)` is rejected by the reference; the
1754        // for-header form keeps 1-3 arguments.
1755        let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4);
1756        assert_eq!(error.code, "invalid-call-context");
1757
1758        let error = compile_error(&action_source("for g in [1, 2]:\n        debug(g)"), 4);
1759        assert_eq!(error.code, "invalid-iterable");
1760
1761        crate::compile(
1762            &action_source("for g in range(3):\n        debug(g)"),
1763            "test.opy",
1764            std::path::Path::new(""),
1765        )
1766        .expect("the for-header range form compiles");
1767    }
1768
1769    #[test]
1770    fn source_aliases_resolve_to_canonical_names() {
1771        // Non-contextual aliases rewrite to the canonical entry so identity,
1772        // position, and emission use the target name.
1773        let hir = crate::compile(
1774            &action_source("stopChasingVariable(g)"),
1775            "test.opy",
1776            std::path::Path::new(""),
1777        )
1778        .expect("the alias target compiles");
1779        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1780            panic!("expected a rule");
1781        };
1782        let HirStmt::Expr { expr, .. } = &rule.actions[0] else {
1783            panic!("expected expression statement");
1784        };
1785        let HirExpr::Call { name, .. } = expr.as_ref() else {
1786            panic!("expected a call");
1787        };
1788        assert_eq!(name, "stopChasingVariable");
1789
1790        let hir = crate::compile(
1791            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    \
1792             @Condition eventPlayer.getCurrentHero() != null\n    \
1793             @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n    \
1794             disableInspector()\n",
1795            "test.opy",
1796            std::path::Path::new(""),
1797        )
1798        .expect("member aliases compile");
1799        let RuleEntry::Rule(rule) = &hir.rules[0] else {
1800            panic!("expected a rule");
1801        };
1802        let HirExpr::Binary { left, .. } = &rule.conditions[0] else {
1803            panic!("expected a comparison");
1804        };
1805        let HirExpr::ReceiverCall { name, .. } = left.as_ref() else {
1806            panic!("expected a receiver call");
1807        };
1808        assert_eq!(name, "getHero");
1809    }
1810
1811    #[test]
1812    fn unknown_catalog_enum_members_are_rejected() {
1813        for source in [
1814            "globalvar g\nrule \"r\":\n    @Event global\n    g = Color.CYAN\n",
1815            "globalvar g\nrule \"r\":\n    @Event global\n    g = DynamicEffect.SPARKLES\n",
1816        ] {
1817            let error = crate::compile(source, "test.opy", std::path::Path::new(""))
1818                .expect_err("unknown catalog member must be rejected");
1819            assert_eq!(error.code, "unknown-enum-member");
1820        }
1821    }
1822
1823    #[test]
1824    fn default_var_for_binder_resolves_at_all_range_arities() {
1825        // The agent-lab regression: `for I in range(0, 10):` with `I` not
1826        // declared. `I` is an OverPy default variable name (A–Z, AA–…), which
1827        // the pinned reference accepts as an implicit global loop binder
1828        // (#114). All range arities keep compiling (1, 2, and 3 arguments).
1829        for (binder, iterable) in [
1830            ("I", "range(0, 10)"),
1831            ("I", "range(3)"),
1832            ("I", "range(1, 5, 2)"),
1833        ] {
1834            let hir = lower_ok(&format!(
1835                "globalvar total\nrule \"r\":\n    @Event global\n    for {binder} in {iterable}:\n        total += {binder}\n"
1836            ));
1837            let (_, actions) = rule_conditions_and_actions(&hir);
1838            let HirStmt::For { variable, body, .. } = &actions[0] else {
1839                panic!("expected a for statement");
1840            };
1841            assert!(
1842                matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1843                "the binder resolves to the implicit global 'I', got {variable:?}"
1844            );
1845            assert!(!body.is_empty(), "the loop body lowers");
1846            // The binder use in the body resolves too: `total += I` has a
1847            // GlobalVar operand.
1848            let HirStmt::Assign { value, .. } = &body[0] else {
1849                panic!("expected an assignment in the body");
1850            };
1851            let HirExpr::Binary { right, .. } = value.as_ref() else {
1852                panic!("expected a binary expression");
1853            };
1854            assert!(
1855                matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1856                "the binder use inside the body resolves to the implicit global"
1857            );
1858        }
1859    }
1860
1861    #[test]
1862    fn player_variable_range_binder_preserves_host_player_receiver() {
1863        let hir = lower_ok(
1864            "playervar I\nrule \"r\":\n    @Event global\n    for hostPlayer.I in range(3):\n        hostPlayer.I = 1\n",
1865        );
1866        let (_, actions) = rule_conditions_and_actions(&hir);
1867        let HirStmt::For { variable, .. } = &actions[0] else {
1868            panic!("expected a for statement");
1869        };
1870        let HirExpr::PlayerVar {
1871            player,
1872            name,
1873            member_span,
1874            span,
1875        } = variable.as_ref()
1876        else {
1877            panic!("expected a player-variable binder, got {variable:?}");
1878        };
1879        assert_eq!(name, "I");
1880        assert!(matches!(player.as_ref(), HirExpr::HostPlayer { .. }));
1881        assert_eq!(span.unwrap().start.line, 4);
1882        assert_eq!(span.unwrap().start.col, 9);
1883        assert_eq!(span.unwrap().end.line, 4);
1884        assert_eq!(span.unwrap().end.col, 21);
1885        let member_span = member_span.expect("player binder member span");
1886        assert_eq!(member_span.start.line, 4);
1887        assert_eq!(member_span.start.col, 20);
1888        assert_eq!(member_span.end.line, 4);
1889        assert_eq!(member_span.end.col, 21);
1890    }
1891
1892    #[test]
1893    fn default_var_names_resolve_as_implicit_globals() {
1894        // Default variable names resolve anywhere a variable may appear,
1895        // matching the pinned reference (no `globalvar` declaration needed).
1896        let hir = lower_ok("rule \"r\":\n    @Event global\n    I = 5\n    debug(I)\n");
1897        let (_, actions) = rule_conditions_and_actions(&hir);
1898        let HirStmt::Assign { target, .. } = &actions[0] else {
1899            panic!("expected an assignment");
1900        };
1901        assert!(
1902            matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1903            "the implicit global resolves, got {target:?}"
1904        );
1905        // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not.
1906        assert_eq!(default_var_index("I"), Some(8));
1907        assert_eq!(default_var_index("AA"), Some(26));
1908        assert_eq!(default_var_index("Z"), Some(25));
1909        assert_eq!(default_var_index("DX"), Some(127));
1910        assert_eq!(default_var_index("DY"), None);
1911        assert_eq!(default_var_index("i"), None);
1912    }
1913
1914    #[test]
1915    fn nested_same_name_for_binders_reuse_the_implicit_global() {
1916        // Nested loops with the same default-var binder reuse the single
1917        // implicit variable, matching the pinned reference (the inner loop
1918        // overwrites the same Workshop global — no separate binding).
1919        let hir = lower_ok(
1920            "rule \"r\":\n    @Event global\n    for I in range(3):\n        for I in range(2):\n            debug(I)\n",
1921        );
1922        let (_, actions) = rule_conditions_and_actions(&hir);
1923        let HirStmt::For {
1924            variable: outer,
1925            body,
1926            ..
1927        } = &actions[0]
1928        else {
1929            panic!("expected an outer for statement");
1930        };
1931        let HirStmt::For {
1932            variable: inner, ..
1933        } = &body[0]
1934        else {
1935            panic!("expected an inner for statement");
1936        };
1937        assert!(
1938            matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I")
1939                && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"),
1940            "both loops bind the same implicit global (spans differ per binder site)"
1941        );
1942    }
1943
1944    #[test]
1945    fn undeclared_lowercase_binder_is_still_an_unknown_identifier() {
1946        // A lowercase undeclared binder is not a default variable name; the
1947        // pinned reference rejects the program ("Unknown function name"), and
1948        // Wright reports the same reject with the structured
1949        // `unknown-identifier` diagnostic (#114).
1950        let error = compile_error(
1951            "rule \"r\":\n    @Event global\n    for i in range(3):\n        debug(i)\n",
1952            3,
1953        );
1954        assert_eq!(error.code, "unknown-identifier");
1955        let span = error.span.expect("the error is source-located");
1956        assert_eq!(span.start.line, 3);
1957    }
1958
1959    #[test]
1960    fn syntax_constructs_lower_to_provenance_preserving_hir() {
1961        let hir = lower_ok(
1962            "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",
1963        );
1964        let (_, actions) = rule_conditions_and_actions(&hir);
1965        let HirStmt::DoWhile { condition, .. } = &actions[0] else {
1966            panic!("expected do-while");
1967        };
1968        assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in"));
1969        let HirStmt::Switch { arms, .. } = &actions[1] else {
1970            panic!("expected switch");
1971        };
1972        assert_eq!(arms.len(), 2);
1973        let HirSwitchArm::Case {
1974            value: case_value,
1975            body,
1976            ..
1977        } = &arms[0]
1978        else {
1979            panic!("expected case arm");
1980        };
1981        assert!(
1982            matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64)
1983        );
1984        let HirStmt::Assign { value, .. } = &body[0] else {
1985            panic!("expected case assignment");
1986        };
1987        assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in"));
1988        let HirSwitchArm::Default { body, .. } = &arms[1] else {
1989            panic!("expected default arm");
1990        };
1991        assert!(matches!(body[0], HirStmt::Assign { .. }));
1992        let HirStmt::Assign { value, .. } = &actions[2] else {
1993            panic!("expected comprehension assignment");
1994        };
1995        assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. }));
1996        let HirStmt::Assign { value, .. } = &actions[3] else {
1997            panic!("expected sorted assignment");
1998        };
1999        assert!(
2000            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")))
2001        );
2002        let HirStmt::Assign { value, .. } = &actions[4] else {
2003            panic!("expected string assignment");
2004        };
2005        assert!(
2006            matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w")
2007        );
2008    }
2009
2010    #[test]
2011    fn syntax_rejects_reference_invalid_bare_dict_and_lambda() {
2012        let dict_error = compile_error(
2013            "globalvar x\nrule \"r\":\n    @Event global\n    x = {\"x\": 1}\n",
2014            4,
2015        );
2016        assert_eq!(dict_error.code, "dict-access");
2017        let lambda_error = compile_error(
2018            "globalvar x\nrule \"r\":\n    @Event global\n    x = lambda value: value\n",
2019            4,
2020        );
2021        assert_eq!(lambda_error.code, "lambda-context");
2022    }
2023
2024    #[test]
2025    fn do_while_requires_rule_or_definition_prefix_position() {
2026        let error = compile_error(
2027            "globalvar value\nrule \"r\":\n    @Event global\n    value = 1\n    do:\n        value += 1\n    while value < 2\n",
2028            5,
2029        );
2030        assert_eq!(error.code, "do-while-placement");
2031        assert_eq!(
2032            error.message,
2033            "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it"
2034        );
2035    }
2036}