diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 53a7e4c..876fbc8 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -425,10 +425,10 @@ "oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json" ], - "owner": "opy-rs#157", - "note": "The full real-world project now resolves included-file settings, #!mainFile, and #!defineMember directives; it remains outside the compiler baseline at the next macro declaration syntax boundary.", + "owner": "opy-rs#167", + "note": "The source frontend reaches the pinned semantic unknown-member frontier after resolving the demonstrated macro constants, settings expressions, multiline comprehensions, and catalog-backed receiver members; canonical lowering remains outside this issue.", "failureClass": "frontend", - "diagnosticCode": "parse-error" + "diagnosticCode": "unknown-member" }, { "fixture": "real-world/overpy-broken-weapons", @@ -720,12 +720,12 @@ "evidence": [ "oracle:synthetic/issue-29-invalid/oracle.json", "provenance:synthetic/issue-29-invalid/fixture.json", - "test:opy-rs::compiler::integration_tests::issue_93_frontend::frontend_diagnostics_are_public_and_source_attributed" + "test:opy-rs::compiler::integration_tests::issue_167_source::issue_29_reaches_the_duplicate_rule_name_frontier" ], - "owner": "opy-rs#93", - "note": "The public compiler reports the invalid annotation as a source-attributed parse diagnostic.", + "owner": "opy-rs#167", + "note": "An ordinary rule @Name annotation reaches the pinned duplicate-rule-name semantic frontier with source attribution.", "failureClass": "frontend", - "diagnosticCode": "parse-error" + "diagnosticCode": "duplicate-rule-name" }, { "fixture": "synthetic/issue-31-negative", diff --git a/compatibility/conformance.py b/compatibility/conformance.py index 942396d..597d623 100644 --- a/compatibility/conformance.py +++ b/compatibility/conformance.py @@ -28,6 +28,8 @@ "do-while-placement": "semantic", "invalid-range-binder": "semantic", "four-dimensional-assignment": "semantic", + "duplicate-rule-name": "semantic", + "unknown-member": "semantic", } FRONTIER_CONSTRUCTS = { "lambda-context": "parse-error", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 09d52e1..61ea55d 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -20,7 +20,7 @@ {"fixture": "synthetic/expressions-values", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/expressions-values/oracle.json", "implementation-invariant:source implementation-resolves-expressions"], "note": "Expressions, arrays, strings, vectors, calls, and format expressions resolve."}, {"fixture": "synthetic/preprocessing", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/preprocessing/oracle.json", "implementation-invariant:preprocessor-include-define-undef"], "note": "Include, object-like and function-like defines, and undef are preserved through preprocessing."}, {"fixture": "synthetic/issue-29-directives", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-directives/oracle.json", "implementation-invariant:issue-29-preprocessing-state"], "note": "Advanced directive state and source annotations resolve without executing Workshop optimizer, localization, or emission effects."}, - {"fixture": "synthetic/issue-29-invalid", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-29-invalid/oracle.json", "implementation-invariant:issue-29-structured-directive-diagnostics"], "note": "Malformed preprocessing and annotation forms remain source-located structured diagnostics."}, + {"fixture": "synthetic/issue-29-invalid", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-29-invalid/oracle.json", "implementation-invariant:issue-167-duplicate-rule-name-frontier"], "note": "The ordinary-rule @Name form reaches the pinned duplicate-rule-name semantic frontier with source attribution."}, {"fixture": "synthetic/issue-29-main-file", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-main-file/oracle.json", "implementation-invariant:issue-29-main-file-scope"], "note": "mainFile redirects the source implementation entry point and preserves child-include prefix state without copying catalog data."}, {"fixture": "synthetic/issue-129-included-main-file", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-129-included-main-file/oracle.json", "provenance:synthetic/issue-129-included-main-file/fixture.json"], "note": "The pinned oracle accepts a leading included-file #!mainFile directive, while native compilation preserves the declared root entry point and both rule outputs."}, {"fixture": "synthetic/issue-31-positive", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-31-positive/oracle.json", "implementation-invariant:issue-31-directive-surfaces"], "note": "Pinned positive probe covers global rulePrefixTemplate, include prefix restoration, AST macro/enum redeclaration, and translation normalization."}, @@ -47,7 +47,7 @@ {"fixture": "real-world/overpy-meipocalypse", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-meipocalypse/oracle.json", "provenance:real-world/overpy-meipocalypse/fixture.json"], "note": "The reference rejects the missing script hook; native rejection is retained with a different earlier diagnostic."}, {"fixture": "real-world/overpy-zencopter", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-zencopter/oracle.json", "provenance:real-world/overpy-zencopter/fixture.json"], "note": "Both sides reject the pinned upstream example; native diagnostic wording is not used as semantic evidence."}, {"fixture": "real-world/ow1-emulator", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json"], "note": "The full project now resolves included-file #!mainFile, #!defineMember directives, continued directive lines, and duplicate includes; the failure case remains at the next parser boundary."}, - {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json"], "note": "The full project now resolves included-file #!mainFile, #!defineMember directives, and included settings; the failure case remains at the next parser boundary in the included constants."}, + {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json", "implementation-invariant:issue-167-source-frontier"], "note": "The full project reaches the pinned semantic unknown-member frontier after the demonstrated source constructs are parsed and lowered."}, {"fixture": "synthetic/issue-35-integration", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-35-integration/oracle.json", "implementation-invariant:opy-rs::compiler-vertical-slice"], "note": "The OPY source implementation resolves the source fixture; the compiler test independently lowers it through canonical WIR validation and deterministic workshop-rs emission."}, {"fixture": "synthetic/issue-40-structural", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-40-structural/oracle.json", "implementation-invariant:opy-rs::compiler-structural-lowering"], "note": "The pinned oracle records subroutine source identity, deterministic explicit/implicit variable allocation, and player event filters; the compiler test independently asserts those structures in canonical WIR."}, {"fixture": "synthetic/issue-46-primitives", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-46-primitives/oracle.json", "implementation-invariant:opy-rs::compiler-primitive-lowering", "test:opy-rs::compiler-issue-46-native-wir-equivalence"], "note": "The pinned oracle records assignment and modification lowering (including **= and single-level indexed forms), value expressions, array indexing (including firstOf index-0 normalization), not-comparison negation, implicit default variables at fixed slots, and non-null variable initializers; #145 additionally covers null-default omission, float-zero initialization, and nested indexed writes. The compiler test compares native lowered WIR directly with the parsed oracle WIR and asserts structural equivalence."}, diff --git a/crates/opy-rs/src/compiler/integration_tests.rs b/crates/opy-rs/src/compiler/integration_tests.rs index d90e153..3ea992a 100644 --- a/crates/opy-rs/src/compiler/integration_tests.rs +++ b/crates/opy-rs/src/compiler/integration_tests.rs @@ -28,6 +28,8 @@ mod issue_145_lowering; mod issue_161_preprocessing; #[path = "tests/issue_162_semantic.rs"] mod issue_162_semantic; +#[path = "tests/issue_167_source.rs"] +mod issue_167_source; #[path = "tests/issue_42_oracle.rs"] mod issue_42_oracle; #[path = "tests/issue_46_oracle.rs"] diff --git a/crates/opy-rs/src/compiler/mod.rs b/crates/opy-rs/src/compiler/mod.rs index 15a0716..0979ae7 100644 --- a/crates/opy-rs/src/compiler/mod.rs +++ b/crates/opy-rs/src/compiler/mod.rs @@ -1150,6 +1150,11 @@ fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::setting value, span: span.map(convert_settings_span), }, + SourceNode::Raw { name, value, span } => TargetNode::Raw { + name, + value, + span: span.map(convert_settings_span), + }, SourceNode::List { name, elements, diff --git a/crates/opy-rs/src/compiler/tests/issue_167_source.rs b/crates/opy-rs/src/compiler/tests/issue_167_source.rs new file mode 100644 index 0000000..bfd1512 --- /dev/null +++ b/crates/opy-rs/src/compiler/tests/issue_167_source.rs @@ -0,0 +1,87 @@ +//! Pinned source failure-frontier coverage for issue #167. + +use std::path::{Path, PathBuf}; + +use crate::hir::SettingsNode; +use crate::{CompileFailureClass, CompileStatus, Compiler}; +use workshop_rs::catalog::Locale; + +fn fixture_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures") + .join(name) +} + +#[test] +fn issue_29_reaches_the_duplicate_rule_name_frontier() { + let dir = fixture_dir("synthetic/issue-29-invalid"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let report = Compiler::new().unwrap().compile_source_report_with_locale( + &source, + "source.opy", + &dir, + &Locale::new("en-US"), + ); + + assert_eq!(report.compile.status, CompileStatus::Failure); + assert_eq!( + report.compile.failure_class, + Some(CompileFailureClass::Frontend) + ); + let diagnostic = report.compile.diagnostics.first().unwrap(); + assert_eq!(diagnostic.code, "duplicate-rule-name"); + assert!( + diagnostic + .message + .contains("Rule name was already declared") + ); + let span = diagnostic.span.as_ref().unwrap(); + assert_eq!(span.path, "source.opy"); + assert_eq!(span.start.line, 5); +} + +#[test] +fn six_v_six_reaches_the_pinned_unknown_member_frontier() { + let dir = fixture_dir("real-world/6v6-adjustments"); + let source = std::fs::read_to_string(dir.join("main.opy")).unwrap(); + let report = Compiler::new().unwrap().compile_source_report_with_locale( + &source, + "main.opy", + &dir, + &Locale::new("en-US"), + ); + + assert_eq!(report.compile.status, CompileStatus::Failure); + assert_eq!( + report.compile.failure_class, + Some(CompileFailureClass::Frontend) + ); + let diagnostic = report.compile.diagnostics.first().unwrap(); + assert_eq!(diagnostic.code, "unknown-member"); + assert!(diagnostic.message.contains("unknown member")); + let span = diagnostic.span.as_ref().unwrap(); + assert_eq!(span.path, "utilities/custom_hp.opy"); + assert_eq!(span.start.line, 31); +} + +#[test] +fn expression_valued_setting_stays_raw_through_compiler() { + let source = "settings {\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\nrule \"r\":\n @Event global\n pass\n"; + let hir = crate::compile(source, "source.opy", Path::new(".")).unwrap(); + let lobby = match &hir.settings.as_ref().unwrap().children[0] { + SettingsNode::Group { children, .. } => children, + other => panic!("expected lobby group, got {other:?}"), + }; + assert!(matches!( + &lobby[0], + SettingsNode::Raw { value, .. } + if value == "GAMEMODE_NAME\" \"GAMEMODE_VERSION" + )); + + let artifact = Compiler::new().unwrap().compile_hir(&hir).unwrap(); + assert!( + artifact + .emitted + .contains("modeName: GAMEMODE_NAME\" \"GAMEMODE_VERSION") + ); +} diff --git a/crates/opy-rs/src/compiler/tests/issue_93_frontend.rs b/crates/opy-rs/src/compiler/tests/issue_93_frontend.rs index fceeb22..4d76f9d 100644 --- a/crates/opy-rs/src/compiler/tests/issue_93_frontend.rs +++ b/crates/opy-rs/src/compiler/tests/issue_93_frontend.rs @@ -14,7 +14,7 @@ fn frontend_diagnostics_are_public_and_source_attributed() { ( "issue-29-invalid.opy", "globalvar value\n\nrule \"invalid directive\":\n @Event global\n @Name \"wrong\"\n value = 1\n", - "parse-error", + "duplicate-rule-name", ), ( "issue-31-negative.opy", diff --git a/crates/opy-rs/src/cst.rs b/crates/opy-rs/src/cst.rs index 4b70256..4187a8c 100644 --- a/crates/opy-rs/src/cst.rs +++ b/crates/opy-rs/src/cst.rs @@ -58,6 +58,12 @@ pub enum SettingsNode { value: String, span: Span, }, + /// An unquoted settings expression preserved without assigning it a type. + Raw { + name: String, + value: String, + span: Span, + }, List { name: String, elements: Vec, @@ -104,6 +110,12 @@ pub enum Decl { members: Vec<(String, Span)>, span: Span, }, + /// A source-level constant declared with `macro name = expression`. + Constant { + name: String, + value: Expr, + span: Span, + }, /// A `macro` declaration with parameterized statement body. Macro { name: String, diff --git a/crates/opy-rs/src/hir/dump.rs b/crates/opy-rs/src/hir/dump.rs index e21fbfb..5324ae7 100644 --- a/crates/opy-rs/src/hir/dump.rs +++ b/crates/opy-rs/src/hir/dump.rs @@ -425,6 +425,15 @@ fn dump_settings_node(node: &SettingsNode, out: &mut String, level: usize) { span_suffix(span.as_ref()) )); } + SettingsNode::Raw { name, value, span } => { + out.push_str(&format!( + "{}raw {} {:?}{}\n", + indent(level), + name, + value, + span_suffix(span.as_ref()) + )); + } SettingsNode::List { name, elements, diff --git a/crates/opy-rs/src/hir/types.rs b/crates/opy-rs/src/hir/types.rs index 078013b..82ae65e 100644 --- a/crates/opy-rs/src/hir/types.rs +++ b/crates/opy-rs/src/hir/types.rs @@ -248,6 +248,13 @@ pub enum SettingsNode { #[serde(skip_serializing_if = "Option::is_none")] span: Option, }, + /// A settings expression preserved without assigning it a literal type. + Raw { + name: String, + value: String, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, List { name: String, #[serde(default)] @@ -265,6 +272,7 @@ impl SettingsNode { | SettingsNode::Number { span, .. } | SettingsNode::Bool { span, .. } | SettingsNode::String { span, .. } + | SettingsNode::Raw { span, .. } | SettingsNode::List { span, .. } => span.as_ref(), } } diff --git a/crates/opy-rs/src/hir/validate.rs b/crates/opy-rs/src/hir/validate.rs index 88c4269..0a3fab4 100644 --- a/crates/opy-rs/src/hir/validate.rs +++ b/crates/opy-rs/src/hir/validate.rs @@ -82,7 +82,7 @@ const EXPR_KINDS: &[&str] = &[ "format", ]; /// Settings node `kind` values understood by this consumer. -const SETTINGS_NODE_KINDS: &[&str] = &["group", "number", "bool", "string", "list"]; +const SETTINGS_NODE_KINDS: &[&str] = &["group", "number", "bool", "string", "raw", "list"]; /// Declared names, collected before reference validation. struct NameTables<'a> { @@ -338,6 +338,7 @@ fn node_name(node: &SettingsNode) -> &str { | SettingsNode::Number { name, .. } | SettingsNode::Bool { name, .. } | SettingsNode::String { name, .. } + | SettingsNode::Raw { name, .. } | SettingsNode::List { name, .. } => name, } } diff --git a/crates/opy-rs/src/lower.rs b/crates/opy-rs/src/lower.rs index 9c685c7..55e904a 100644 --- a/crates/opy-rs/src/lower.rs +++ b/crates/opy-rs/src/lower.rs @@ -57,6 +57,9 @@ enum CallPosition { ForIterable, /// An expression occupying a signature-approved lambda argument slot. LambdaArgument, + /// An expression in a macro body, whose final call position is determined + /// when the macro is expanded. + MacroBody, } /// The lowerer's symbol context, built from the CST declarations. @@ -65,6 +68,7 @@ struct Lowerer { player_declarations: HashMap, subroutine_declarations: HashMap, subroutine_definitions: Vec<(String, usize)>, + constant_declarations: HashMap, macro_declarations: HashMap, enums: HashMap>, enum_declarations: HashMap, @@ -116,6 +120,7 @@ pub fn lower_with_preprocessing( player_declarations: HashMap::new(), subroutine_declarations: HashMap::new(), subroutine_definitions: Vec::new(), + constant_declarations: HashMap::new(), macro_declarations: HashMap::new(), enums: HashMap::new(), enum_declarations: HashMap::new(), @@ -175,6 +180,13 @@ pub fn lower_with_preprocessing( // Custom enums fold to numeric constants at use sites and // produce no HIR declaration (reference behavior). } + Decl::Constant { name, value, span } => { + declarations.push(Declaration::Constant { + name: name.clone(), + span: Some(span.into()), + value: Box::new(lowerer.lower_expr(value, &[], CallPosition::Value)), + }); + } Decl::Macro { name, args, @@ -571,6 +583,11 @@ fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode { value: value.clone(), span: Some((*span).into()), }, + cst::SettingsNode::Raw { name, value, span } => HirSettingsNode::Raw { + name: name.clone(), + value: value.clone(), + span: Some((*span).into()), + }, cst::SettingsNode::List { name, elements, @@ -635,6 +652,19 @@ impl Lowerer { ); } } + Decl::Constant { name, span, .. } => { + let duplicate = self.constant_declarations.contains_key(name); + self.constant_declarations + .entry(name.clone()) + .or_insert(order); + if duplicate { + self.error_at( + "duplicate-declaration", + format!("duplicate constant '{name}'"), + *span, + ); + } + } Decl::Enum { name, members, .. } => { self.enum_declarations.entry(name.clone()).or_insert(order); self.enums.entry(name.clone()).or_insert_with(|| { @@ -693,6 +723,12 @@ impl Lowerer { .is_some_and(|order| *order <= self.current_order) } + fn constant_visible(&self, name: &str) -> bool { + self.constant_declarations + .get(name) + .is_some_and(|order| *order <= self.current_order) + } + fn enum_visible(&self, name: &str) -> bool { self.enum_declarations .get(name) @@ -717,6 +753,17 @@ impl Lowerer { files: &[SourceFile], preprocessing: &PreprocessingState, ) -> OpyResult { + if let Some(annotation) = rule + .annotations + .iter() + .find(|annotation| annotation.name == "Name") + { + self.error_at( + "duplicate-rule-name", + "Rule name was already declared".to_string(), + annotation.span, + ); + } let conditions = rule .conditions .iter() @@ -1016,7 +1063,15 @@ impl Lowerer { } fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec { - self.lower_block(body, params, false, false, false) + body.iter() + .map(|stmt| match stmt { + Stmt::Expr { expr, span } => HirStmt::Expr { + expr: Box::new(self.lower_expr(expr, params, CallPosition::MacroBody)), + span: Some(span.into()), + }, + _ => self.lower_stmt(stmt, params, false, false), + }) + .collect() } fn lower_expr( @@ -1276,6 +1331,10 @@ impl Lowerer { member_span: None, span: Some(span.into()), }, + _ if self.constant_visible(name) => HirExpr::Constant { + name: name.to_string(), + span: Some(span.into()), + }, _ if self.enum_visible(name) => { self.error_at( "enum-type-without-member", @@ -1333,6 +1392,21 @@ impl Lowerer { } }; } + if name == "Math" { + if let Some((value, text)) = match member { + "PI" => Some((std::f64::consts::PI, "3.141592653589793")), + "E" => Some((std::f64::consts::E, "2.718281828459045")), + "INFINITY" => Some((999_999_999_999.0, "999999999999")), + "EPSILON" => Some((1192093e-13, "0.0000001192093")), + _ => None, + } { + return HirExpr::Number { + value, + text: text.to_string(), + span: Some(span.into()), + }; + } + } // Builtin Workshop enum: the domain name is a declared OPY // signature identity (manifest `param.domain`); the member list // is Workshop-owned catalog content, so the member access @@ -1397,8 +1471,8 @@ impl Lowerer { ) { if !default_var_index(member).is_some() && !self.player_visible(member) { self.error_at( - "unknown-identifier", - format!("unknown player variable '{member}'"), + "unknown-member", + format!("unknown member '{member}'"), member_span, ); return HirExpr::Null { span: None }; @@ -1510,6 +1584,9 @@ impl Lowerer { CallPosition::LambdaArgument => { ("unknown-value", format!("unknown value '{name}'")) } + CallPosition::MacroBody => { + ("unknown-value", format!("unknown value '{name}'")) + } }; self.error_at(code, message, span); } @@ -2203,6 +2280,7 @@ impl Lowerer { ); } } + CallPosition::MacroBody => {} } } diff --git a/crates/opy-rs/src/manifest/data/manifest.json b/crates/opy-rs/src/manifest/data/manifest.json index 485b898..3ab3435 100644 --- a/crates/opy-rs/src/manifest/data/manifest.json +++ b/crates/opy-rs/src/manifest/data/manifest.json @@ -1438,6 +1438,20 @@ ], "receiver": "Any" }, + { + "catalogId": "indexOfArrayValue", + "evidence": [ + "builtin-surface" + ], + "id": "index", + "kind": "memberValue", + "params": [ + { + "name": "value" + } + ], + "receiver": "Any" + }, { "catalogId": "setMoveSpeed", "evidence": [ @@ -2208,6 +2222,125 @@ "params": [], "receiver": "Player" }, + { + "catalogId": "getVelocity", + "evidence": [ + "builtin-surface" + ], + "id": "getVelocity", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getSpeed", + "evidence": [ + "builtin-surface" + ], + "id": "getSpeed", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getMaxHealthOfType", + "evidence": [ + "builtin-surface" + ], + "id": "getMaxHealthOfType", + "kind": "memberValue", + "params": [ + { + "domain": "Health", + "name": "healthType" + } + ], + "receiver": "Player" + }, + { + "catalogId": "applyImpulse", + "evidence": [ + "builtin-surface" + ], + "id": "applyImpulse", + "kind": "memberAction", + "params": [ + { + "domain": "Vector", + "name": "direction" + }, + { + "name": "speed" + }, + { + "domain": "Relativity", + "name": "relativity" + }, + { + "domain": "Impulse", + "name": "motion" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setHealingDealt", + "evidence": [ + "builtin-surface" + ], + "id": "setHealingDealt", + "kind": "memberAction", + "params": [ + { + "name": "healingPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "removeAllHealthPoolsFromPlayer", + "evidence": [ + "builtin-surface" + ], + "id": "removeAllHealthPools", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "addHealthPool", + "evidence": [ + "builtin-surface" + ], + "id": "addHealthPool", + "kind": "memberAction", + "params": [ + { + "domain": "Health", + "name": "healthType" + }, + { + "name": "amount" + }, + { + "name": "isPermanent" + }, + { + "name": "receivesHealing" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getHeroOfDuplication", + "evidence": [ + "builtin-surface" + ], + "id": "getHeroOfDuplication", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, { "catalogId": "hasStatus", "evidence": [ diff --git a/crates/opy-rs/src/manifest/probes/builtin-surface.opy b/crates/opy-rs/src/manifest/probes/builtin-surface.opy index 9d9e3f8..0655c01 100644 --- a/crates/opy-rs/src/manifest/probes/builtin-surface.opy +++ b/crates/opy-rs/src/manifest/probes/builtin-surface.opy @@ -39,9 +39,17 @@ rule "builtin surface": g = cosDeg(g) g = rgb(1, 2, 3) g = eventPlayer.getSlot() + g = eventPlayer.getHeroOfDuplication() + g = eventPlayer.getVelocity() + g = eventPlayer.getSpeed() + g = eventPlayer.getMaxHealthOfType(Health.NORMAL) g = eventPlayer.getMaxHealth() g = eventPlayer.getUltCharge() eventPlayer.addToScore(1) + eventPlayer.applyImpulse(vect(0, 0, 0), 1, Relativity.TO_WORLD, Impulse.INCORPORATE_CONTRARY_MOTION) + eventPlayer.setHealingDealt(100) + eventPlayer.removeAllHealthPools() + eventPlayer.addHealthPool(Health.NORMAL, 1, true, true) g = max(1, 2) g = getLastCreatedEntity() g = worldVector(Vector.LEFT, eventPlayer, Transform.ROTATION) @@ -49,6 +57,7 @@ rule "builtin surface": g = worldVector(Vector.LEFT, eventPlayer, Transform.ROTATION).y g = worldVector(Vector.LEFT, eventPlayer, Transform.ROTATION).z g = "abc".charAt(0) + g = [1, 2].index(2) g = [1, 2].last() chaseAtRate(g, 1, 1) createDummy(Hero.ANA, Team.ALL, -1, vect(0, 0, 0), vect(0, 0, 0)) diff --git a/crates/opy-rs/src/manifest/probes/probes.json b/crates/opy-rs/src/manifest/probes/probes.json index f091850..daaef01 100644 --- a/crates/opy-rs/src/manifest/probes/probes.json +++ b/crates/opy-rs/src/manifest/probes/probes.json @@ -4,8 +4,8 @@ { "expect": "success", "id": "builtin-surface", - "outputSha256": "ffb418c68c01871487609a8c7896d432409f724f7d6906c767b9d61584ea256b", - "sha256": "9e0fe683e2299e3ca97714e0b6099fb4a8c2a99ee1c95ab820c69bb6f6bb33bf", + "outputSha256": "244fe58d2880586b673b349beacf278e7ebe7bad4a2331e8cf5ed7fe580b610d", + "sha256": "9321d1e736a9b6bd2b137170e4b05e5bc51e5971e7240cc6dafc6b18d596ece7", "source": "builtin-surface.opy" }, { diff --git a/crates/opy-rs/src/parser.rs b/crates/opy-rs/src/parser.rs index 9e6f1e2..fdab30e 100644 --- a/crates/opy-rs/src/parser.rs +++ b/crates/opy-rs/src/parser.rs @@ -381,6 +381,16 @@ impl Parser<'_> { Ok(name) => name, Err(()) => return false, }; + if self.peek_kind() == TokenKind::Assign { + self.advance(); + let value = match self.parse_expr() { + Ok(value) => value, + Err(()) => return false, + }; + let span = Span::new(start.span.file, start.span.start, value.span().end); + declarations.push(Decl::Constant { name, value, span }); + return true; + } let args = match self.parse_param_list() { Some(args) => args, None => return false, @@ -641,12 +651,6 @@ impl Parser<'_> { ); return false; } - if !subroutine { - self.error_at_current( - "@Name is only supported on subroutine definitions".to_string(), - ); - return false; - } let end = self.peek().span.start; annotations.push(Annotation { name, @@ -1821,25 +1825,32 @@ impl Parser<'_> { self.advance(); let variable_token = self.expect(TokenKind::Ident, "a comprehension variable")?; + self.skip_newlines(); let index = if self.peek_kind() == TokenKind::Comma { self.advance(); + self.skip_newlines(); let index = self.expect(TokenKind::Ident, "a comprehension index")?; Some((index.text, index.span)) } else { None }; + self.skip_newlines(); if !self.is_ident("in") { self.error_at_current("expected `in` in list comprehension".to_string()); return Err(()); } self.advance(); + self.skip_newlines(); let iterable = self.parse_or()?; + self.skip_newlines(); let condition = if self.is_ident("if") { self.advance(); + self.skip_newlines(); Some(Box::new(self.parse_or()?)) } else { None }; + self.skip_newlines(); let end = self.expect(TokenKind::RBracket, "']'")?.span.end; return Ok(Expr::Comprehension { element: Box::new(first), @@ -2445,11 +2456,12 @@ mod tests { #[test] fn def_and_macro_parse() { let program = parse_ok( - "subroutine showStatus\n\ndef showStatus():\n print(\"hi\")\n\nmacro double(value):\n value + value\n", + "subroutine showStatus\n\nmacro VERSION = \"1.4.3\"\n\ndef showStatus():\n print(\"hi\")\n\nmacro double(value):\n value + value\n", ); - assert_eq!(program.declarations.len(), 2); - assert!(matches!(program.declarations[1], Decl::Macro { .. })); - let Decl::Macro { args, body, .. } = &program.declarations[1] else { + assert_eq!(program.declarations.len(), 3); + assert!(matches!(program.declarations[1], Decl::Constant { .. })); + assert!(matches!(program.declarations[2], Decl::Macro { .. })); + let Decl::Macro { args, body, .. } = &program.declarations[2] else { panic!(); }; assert_eq!(args, &vec!["value".to_string()]); diff --git a/crates/opy-rs/src/settings.rs b/crates/opy-rs/src/settings.rs index f5c95bc..1645489 100644 --- a/crates/opy-rs/src/settings.rs +++ b/crates/opy-rs/src/settings.rs @@ -351,12 +351,41 @@ impl Jsonc<'_> { } fn skip_whitespace(&mut self) { - while let Some(ch) = self.peek() { - if ch.is_whitespace() { + loop { + while let Some(ch) = self.peek() { + if ch.is_whitespace() { + self.advance(); + } else { + break; + } + } + if self.peek() == Some('#') { + while self.peek().is_some_and(|ch| ch != '\n') { + self.advance(); + } + continue; + } + if self.text[self.pos..].starts_with("//") { self.advance(); - } else { - break; + self.advance(); + while self.peek().is_some_and(|ch| ch != '\n') { + self.advance(); + } + continue; } + if self.text[self.pos..].starts_with("/*") { + self.advance(); + self.advance(); + while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") { + self.advance(); + } + if self.text[self.pos..].starts_with("*/") { + self.advance(); + self.advance(); + } + continue; + } + break; } } @@ -519,16 +548,59 @@ impl Jsonc<'_> { } } _ => { - return Err(self.error( - "settings-invalid", - "expected a value in settings block".to_string(), - )); + let value = self.parse_expression_value(); + if value.trim().is_empty() { + return Err(self.error( + "settings-invalid", + "expected a value in settings block".to_string(), + )); + } + cst::SettingsNode::Raw { + name: String::new(), + value, + span: Span::new(self.file, start, self.here()), + } } }; let end = self.here(); Ok((node, end)) } + /// Preserve an unquoted setting expression as an opaque value. Commas and + /// closing braces delimit it only outside nested expression delimiters and + /// string literals. + fn parse_expression_value(&mut self) -> String { + let mut value = String::new(); + let mut depth = 0usize; + let mut quote = None; + while let Some(ch) = self.peek() { + if let Some(expected) = quote { + value.push(self.advance().expect("peeked character exists")); + if ch == expected { + quote = None; + } + continue; + } + match ch { + '"' | '\'' => { + quote = Some(ch); + value.push(self.advance().expect("peeked character exists")); + } + '(' | '[' | '{' => { + depth += 1; + value.push(self.advance().expect("peeked character exists")); + } + ')' | ']' => { + depth = depth.saturating_sub(1); + value.push(self.advance().expect("peeked character exists")); + } + ',' | '}' if depth == 0 => break, + _ => value.push(self.advance().expect("peeked character exists")), + } + } + value.trim().to_string() + } + fn expect_word(&mut self, word: &str) -> OpyResult<()> { let start = self.here(); for expected in word.chars() { @@ -683,6 +755,11 @@ fn build_node( value, span, }, + cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw { + name: key, + value, + span, + }, cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List { name: key, elements, @@ -827,6 +904,21 @@ mod tests { assert_eq!(value, "line\n\t\"quoted\""); } + #[test] + fn parse_block_accepts_comments_and_expression_values() { + let found = block( + "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n", + ); + let parsed = parse_block(&found).unwrap(); + let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else { + panic!("lobby group"); + }; + let cst::SettingsNode::Raw { value, .. } = &children[0] else { + panic!("expression-valued setting"); + }; + assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION"); + } + #[test] fn parse_block_types_values() { let found = block( diff --git a/crates/opy-rs/tests/differential.rs b/crates/opy-rs/tests/differential.rs index 6acd414..d41bdfb 100644 --- a/crates/opy-rs/tests/differential.rs +++ b/crates/opy-rs/tests/differential.rs @@ -364,8 +364,8 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { diagnostic( &mut cases, "synthetic/issue-29-invalid", - None, - "malformed directive and annotation forms; oracle status failure.", + Some("duplicate-rule-name"), + "ordinary-rule @Name reaches the pinned duplicate-rule-name semantic frontier; oracle status failure.", ); resolve( &mut cases, @@ -531,8 +531,8 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { diagnostic( &mut cases, "real-world/6v6-adjustments", - Some("unsupported-directive"), - "reference fails on 'Unknown member '_hp_reset''; the native frontend accepts the included-file #!mainFile directives and reaches the next unsupported #!defineMember directive with source attribution. Gap: rejection reason differs (documented).", + Some("unknown-member"), + "the native frontend reaches the pinned unknown-member semantic frontier after resolving the demonstrated source constructs.", ); cases diff --git a/crates/opy-rs/tests/issue_143_semantic_hir.rs b/crates/opy-rs/tests/issue_143_semantic_hir.rs index 5233943..5c8fc86 100644 --- a/crates/opy-rs/tests/issue_143_semantic_hir.rs +++ b/crates/opy-rs/tests/issue_143_semantic_hir.rs @@ -231,7 +231,7 @@ rule \"invalid player member\": .diagnostics .first() .expect("unknown player member diagnostic"); - assert_eq!(diagnostic.code, "unknown-identifier"); + assert_eq!(diagnostic.code, "unknown-member"); assert_eq!(diagnostic.span.as_ref().expect("member span").start.line, 4); assert_eq!(diagnostic.span.as_ref().expect("member span").start.col, 25); } diff --git a/docs/hir/opy-hir-v2.md b/docs/hir/opy-hir-v2.md index f7d36f2..88b3124 100644 --- a/docs/hir/opy-hir-v2.md +++ b/docs/hir/opy-hir-v2.md @@ -67,6 +67,13 @@ These nodes are source-semantic and preserve provenance. The bounded compiler reports an explicit integration diagnostic for them until canonical WIR owns the corresponding Workshop control-flow semantics. +## Additive raw settings node + +Settings expressions that are syntactically valid but not literal JSONC +values are carried as a `raw` settings node. This preserves the source value +without misrepresenting it as a string literal; the Workshop-owned settings +carrier may emit it or report its semantic incompleteness. + ## Consumer migration Every external `wright/opy-hir` consumer must migrate its protocol gate and diff --git a/docs/language-support/callables-and-domains.md b/docs/language-support/callables-and-domains.md index 2c76525..79aea59 100644 --- a/docs/language-support/callables-and-domains.md +++ b/docs/language-support/callables-and-domains.md @@ -50,7 +50,7 @@ behavior, and dispatch rule. | --- | --- | --- | | `Hero`, `Map`, `Gamemode`, `Team`, `Slot`, `Color`, `Button` domains | 🚧 Coming soon | Membership and spelling are domain-specific. | | `Vector.UP/DOWN/LEFT/RIGHT/FORWARD/BACKWARD` | ✅ Supported | Constants are separate from arbitrary vectors. | -| `Math.PI`, `Math.E`, `Math.INFINITY`, `Math.EPSILON` | 🚧 Coming soon | Numeric constants are distinct leaves. | +| `Math.PI`, `Math.E`, `Math.INFINITY`, `Math.EPSILON` | ✅ Supported | Numeric constants lower to source-attributed numeric leaves. | | User enum assignment and inferred increments | ✅ Supported | Separate from Workshop catalog domains. | | Contextual `None`/reevaluation enum dispatch | 🚧 Coming soon | `ChaseTimeReeval`, `ChaseRateReeval` and `Invis` differ. | | Alias resolution (`getCurrentHero`, `hasStatusEffect`, `ChaseReeval`) | ✅ Supported | Non-contextual and call-context aliases differ. | diff --git a/docs/language-support/registries.md b/docs/language-support/registries.md index e5c41d3..2834552 100644 --- a/docs/language-support/registries.md +++ b/docs/language-support/registries.md @@ -191,5 +191,5 @@ catalogue. | Upstream key | Status | | --- | --- | | `Vector` (`UP`, `DOWN`, `LEFT`, `RIGHT`, `FORWARD`, `BACKWARD`) | ✅ Supported | -| `Math` (`PI`, `E`, `INFINITY`, `EPSILON`, and documented spacing/radius constants) | 🚧 Coming soon | +| `Math` (`PI`, `E`, `INFINITY`, `EPSILON`) | ✅ Supported (documented spacing/radius constants remain deferred) | | `Texture` (complete texture constant registry) | 🚧 Coming soon |