Skip to main content

opy_rs/
lower.rs

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