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