From 485dbef40188a5f4c7a2178a7bcfcbc564dab0a2 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sun, 30 Aug 2026 02:03:34 +0800 Subject: [PATCH 1/2] fix(opy-compiler): restore residual canonical WIR equivalence Align array, vector, condition, and format lowering with the pinned canonical WIR while preserving the explicit Workshop-owned Debug carrier gap. Fixes #89 --- compatibility/compiler-expectations.json | 37 ++++---- compatibility/differential-expectations.json | 8 +- compatibility/tests/test_diff.py | 2 +- crates/opy-compiler/src/lib.rs | 92 ++++++++++++++++---- crates/opy-compiler/tests/issue_46_oracle.rs | 10 +-- crates/opy-compiler/tests/issue_47_oracle.rs | 20 +---- crates/opy-compiler/tests/issue_89_oracle.rs | 83 ++++++++++++++++++ 7 files changed, 185 insertions(+), 67 deletions(-) create mode 100644 crates/opy-compiler/tests/issue_89_oracle.rs diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 1a23182..00c6c90 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -89,27 +89,26 @@ "fixture": "synthetic/control-flow", "nativeStatus": "success", "classification": "known-gap", - "comparison": "semantic-wir", + "comparison": "compiler-contract", "evidence": [ "oracle:synthetic/control-flow/oracle.json", "provenance:synthetic/control-flow/fixture.json" ], - "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual control-flow divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "owner": "workshop-rs#123", + "note": "The native canonical Debug carrier is intentionally emitted as a simplified HUD action, while the pinned oracle expands type-aware debug formatting; direct equivalence remains a Workshop-owned carrier gap." }, { "fixture": "synthetic/issue-33-switch-break", "nativeStatus": "success", - "classification": "known-gap", + "classification": "match", "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/issue-33-switch-break/oracle.json", "provenance:synthetic/issue-33-switch-break/fixture.json" ], "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual nested-switch-break divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "note": "The native switch-break lowering is directly equivalent to the pinned canonical WIR.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-40-structural", @@ -128,54 +127,54 @@ { "fixture": "synthetic/issue-46-primitives", "nativeStatus": "success", - "classification": "known-gap", + "classification": "match", "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/issue-46-primitives/oracle.json", "provenance:synthetic/issue-46-primitives/fixture.json" ], "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual #46 primitive-lowering divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "note": "The native #46 primitive lowering is directly equivalent to the pinned canonical WIR.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-47-control-flow", "nativeStatus": "success", - "classification": "known-gap", + "classification": "match", "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/issue-47-control-flow/oracle.json", "provenance:synthetic/issue-47-control-flow/fixture.json" ], "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual #47 control-flow divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "note": "The native #47 control-flow lowering is directly equivalent to the pinned canonical WIR.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-47-switch-order", "nativeStatus": "success", - "classification": "known-gap", + "classification": "match", "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/issue-47-switch-order/oracle.json", "provenance:synthetic/issue-47-switch-order/fixture.json" ], "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual authored-switch-order divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "note": "The native authored switch order is directly equivalent to the pinned canonical WIR.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", - "classification": "known-gap", + "classification": "match", "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/issue-47-switch-structured-target/oracle.json", "provenance:synthetic/issue-47-switch-structured-target/fixture.json" ], "owner": "opy-rs#89", - "note": "Direct native-WIR comparison records a residual structured-switch-target divergence; follow-up #89 tracks the lowering gap.", - "semanticEquivalent": false + "note": "The native structured switch targets are directly equivalent to the pinned canonical WIR.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-47-do-while-shapes", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 0ddb0a1..cfca0b6 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -48,12 +48,12 @@ {"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 remains preserved as a failure corpus case with recorded provenance."}, {"fixture": "synthetic/issue-35-integration", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-35-integration/oracle.json", "implementation-invariant:opy-compiler-vertical-slice"], "note": "The OPY source implementation resolves the source fixture; the dedicated opy-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-compiler-structural-lowering"], "note": "The pinned oracle records subroutine source identity, deterministic explicit/implicit variable allocation, and player event filters; the dedicated opy-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-compiler-primitive-lowering", "test:opy-compiler-issue-46-native-wir-gap"], "note": "The pinned oracle records the evidenced assignment and modification lowering (including **= and single-level indexed forms), value expressions, array indexing (including the firstOf index-0 read normalization), not-comparison negation, implicit default variables at fixed Workshop slots, and non-null variable initializers; null-default initializer semantics and deeper indexed writes remain explicit follow-ups. The dedicated opy-compiler test compares native lowered WIR directly with the parsed oracle WIR and keeps the residual difference explicit."}, + {"fixture": "synthetic/issue-46-primitives", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-46-primitives/oracle.json", "implementation-invariant:opy-compiler-primitive-lowering", "test:opy-compiler-issue-46-native-wir-equivalence"], "note": "The pinned oracle records the evidenced assignment and modification lowering (including **= and single-level indexed forms), value expressions, array indexing (including the firstOf index-0 read normalization), not-comparison negation, implicit default variables at fixed Workshop slots, and non-null variable initializers; null-default initializer semantics and deeper indexed writes remain explicit follow-ups. The dedicated opy-compiler test compares native lowered WIR directly with the parsed oracle WIR and asserts structural equivalence."}, {"fixture": "synthetic/issue-46-unsupported", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-46-unsupported/oracle.json", "implementation-invariant:opy-compiler-primitive-lowering-limits"], "note": "Negative #46 probe: the source implementation resolves and the pinned oracle compiles the dict-indexed assignment, while the native compiler rejects the dict primitive with the stable source-attributed unsupported-integration-surface diagnostic."}, - {"fixture": "synthetic/issue-47-control-flow", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-control-flow/oracle.json", "implementation-invariant:issue-47-control-flow-hir"], "note": "The #47 control-flow slice resolves in the source implementation; compiler-level canonical-WIR parity for its residual switch shapes is tracked separately in the compiler expectation contract."}, + {"fixture": "synthetic/issue-47-control-flow", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-control-flow/oracle.json", "implementation-invariant:issue-47-control-flow-hir"], "note": "The #47 control-flow slice resolves in the source implementation and its native lowered WIR is directly equivalent to the pinned oracle."}, {"fixture": "synthetic/issue-47-unsupported", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-unsupported/oracle.json", "test:opy-compiler-issue-47-nested-negative"], "note": "Negative #47 probe: the source implementation and pinned oracle accept the nested conditional switch-break HIR, while the compiler rejects the form because canonical WIR has no equivalent structured break carrier."}, - {"fixture": "synthetic/issue-47-switch-order", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-order/oracle.json", "implementation-invariant:issue-47-authored-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the source implementation; compiler-level WIR equivalence is tracked separately because the native lowering still has an explicit residual gap."}, - {"fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-structured-target/oracle.json", "implementation-invariant:issue-47-structured-switch-source-order"], "note": "The #47 structured switch probe preserves nested if/while actions and authored case/default target order in the source implementation; compiler-level WIR parity remains an explicit lowering gap."}, + {"fixture": "synthetic/issue-47-switch-order", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-order/oracle.json", "implementation-invariant:issue-47-authored-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, + {"fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-structured-target/oracle.json", "implementation-invariant:issue-47-structured-switch-source-order"], "note": "The #47 structured switch probe preserves nested if/while actions and authored case/default target order in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, {"fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-multiple-break/oracle.json", "test:opy-compiler-issue-47-multiple-switch-break"], "note": "The source implementation preserves the multi-break source and the pinned oracle accepts it; the compiler rejects the later-reachable multi-target shape explicitly because workshop-rs v0.1.11 has one canonical else carrier."}, {"fixture": "synthetic/issue-47-do-while-shapes", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-do-while-shapes/oracle.json", "test:opy-compiler-issue-47-do-while-break-shapes"], "note": "Direct, conditional, and nested do-while break shapes resolve and match the pinned Workshop through direct native-WIR comparison with the parsed oracle."}, {"fixture": "synthetic/issue-47-do-while-invalid-placement", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-do-while-invalid-placement/oracle.json", "test:opy-compiler-issue-47-invalid-do-while-placement"], "note": "The source implementation reports the stable source-attributed do-while-placement diagnostic for a non-prefix do-while."}, diff --git a/compatibility/tests/test_diff.py b/compatibility/tests/test_diff.py index ad21f7a..6df4e89 100644 --- a/compatibility/tests/test_diff.py +++ b/compatibility/tests/test_diff.py @@ -216,7 +216,7 @@ def test_compile_result_rejects_public_semantic_wir_evidence(self): diff.require_result_shape(result, "compile result") def test_compiler_semantic_wir_consumes_direct_evidence(self): - fixture = "synthetic/issue-47-control-flow" + fixture = "synthetic/expressions-values" oracle = json.loads( ( COMPATIBILITY_DIR diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 0b73a31..4fc50c8 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -1427,7 +1427,7 @@ impl<'a> Lowering<'a> { let conditions = rule .conditions .iter() - .map(|expr| self.lower_value(expr)) + .map(|expr| self.lower_condition(expr)) .collect::, _>>()?; let mut actions = Vec::new(); actions.extend(self.lower_actions(&rule.actions, None)?); @@ -2082,10 +2082,7 @@ impl<'a> Lowering<'a> { } } - let case_values = self.wir.values.push(ValueNode::new( - Value::Array(case_values), - self.wir_span(span)?, - )); + let case_values = self.lower_array(case_values, span)?; let value_span = self.wir_span(span)?; let offset_values = std::iter::once(default_offset) .chain(case_offsets) @@ -2099,10 +2096,7 @@ impl<'a> Lowering<'a> { )) }) .collect(); - let offsets = self - .wir - .values - .push(ValueNode::new(Value::Array(offset_values), value_span)); + let offsets = self.lower_array(offset_values, span)?; let one = self.wir.values.push(ValueNode::new( Value::Number { value: 1.0, @@ -2228,6 +2222,46 @@ impl<'a> Lowering<'a> { }) } + fn lower_array( + &mut self, + elements: Vec, + span: Option, + ) -> Result { + let name = if elements.is_empty() { + "emptyArray" + } else { + "array" + }; + Ok(self.wir.values.push(ValueNode::new( + Value::Call { + name: name.to_string(), + args: elements, + }, + self.wir_span(span)?, + ))) + } + + fn lower_condition(&mut self, expr: &Expr) -> Result { + let value = self.lower_value(expr)?; + let is_comparison = |expr: &Expr| matches!(expr, Expr::Binary { op, .. } if matches!(op.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">=")); + if is_comparison(expr) + || matches!(expr, Expr::Unary { op, operand, .. } if op == "not" && is_comparison(operand)) + { + return Ok(value); + } + let true_value = self.wir.values.push(ValueNode::new( + Value::Bool(true), + self.wir_span(expr.span().copied())?, + )); + Ok(self.wir.values.push(ValueNode::new( + Value::Call { + name: "==".to_string(), + args: vec![value, true_value], + }, + self.wir_span(expr.span().copied())?, + ))) + } + fn lower_assign( &mut self, target: &Expr, @@ -2611,16 +2645,20 @@ impl<'a> Lowering<'a> { value: value.clone(), } } - Expr::Array { elements, .. } => Value::Array( - elements + Expr::Array { elements, .. } => { + let elements = elements .iter() .map(|element| self.lower_value(element)) - .collect::, _>>()?, - ), - Expr::Vector { x, y, z, .. } => Value::Vector { - x: self.lower_value(x)?, - y: self.lower_value(y)?, - z: self.lower_value(z)?, + .collect::, _>>()?; + return self.lower_array(elements, span); + } + Expr::Vector { x, y, z, .. } => Value::Call { + name: "vector".to_string(), + args: vec![ + self.lower_value(x)?, + self.lower_value(y)?, + self.lower_value(z)?, + ], }, Expr::Constant { name, .. } => { let const_expr = *self @@ -2647,7 +2685,7 @@ impl<'a> Lowering<'a> { } Expr::Format { text, args, .. } => { let text_node = self.wir.values.push(ValueNode::new( - Value::String(text.clone()), + Value::String(canonical_format_text(text)), self.wir_span(span)?, )); let mut call_args = vec![text_node]; @@ -3333,6 +3371,24 @@ fn canonical_number_text(value: f64, text: &str) -> String { } } +fn canonical_format_text(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + let mut index = 0; + while let Some(character) = chars.next() { + if character == '{' && chars.peek() == Some(&'}') { + chars.next(); + output.push('{'); + output.push_str(&index.to_string()); + output.push('}'); + index += 1; + } else { + output.push(character); + } + } + output +} + fn negated_comparison(op: &str) -> Option<&'static str> { Some(match op { "==" => "!=", diff --git a/crates/opy-compiler/tests/issue_46_oracle.rs b/crates/opy-compiler/tests/issue_46_oracle.rs index cf1a774..c651d8c 100644 --- a/crates/opy-compiler/tests/issue_46_oracle.rs +++ b/crates/opy-compiler/tests/issue_46_oracle.rs @@ -4,8 +4,7 @@ //! fixture is load-bearing for the native compiler: this suite compiles the //! fixture source through the full native pipeline (frontend → OPY HIR → //! canonical WIR → deterministic en-US emission). The native lowering is -//! compared directly with the oracle's parsed canonical WIR; the residual -//! difference is kept explicit until the follow-up owner closes it. +//! compared directly with the oracle's parsed canonical WIR. //! //! The adjacent `synthetic/issue-46-unsupported` fixture is the negative //! counterpart: the frontend resolves it and the pinned oracle compiles it, @@ -46,7 +45,7 @@ fn compile_fixture(dir: &Path) -> opy_compiler::CompilationArtifact { } #[test] -fn issue_46_native_wir_gap_is_explicit() { +fn issue_46_native_wir_matches_the_pinned_oracle() { let dir = fixture_dir("issue-46-primitives"); let artifact = compile_fixture(&dir); let catalog = Catalog::builtin().expect("catalog must load"); @@ -55,10 +54,7 @@ fn issue_46_native_wir_gap_is_explicit() { let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale) .expect("the pinned oracle Workshop text must reparse"); - assert!( - !equivalent(&artifact.wir, &oracle), - "native WIR gap disappeared; update the compiler expectation" - ); + assert!(equivalent(&artifact.wir, &oracle)); } #[test] diff --git a/crates/opy-compiler/tests/issue_47_oracle.rs b/crates/opy-compiler/tests/issue_47_oracle.rs index 24cc147..3c36c36 100644 --- a/crates/opy-compiler/tests/issue_47_oracle.rs +++ b/crates/opy-compiler/tests/issue_47_oracle.rs @@ -39,31 +39,15 @@ fn assert_native_wir_equivalent(name: &str) { ); } -fn assert_native_wir_gap(name: &str) { - let dir = fixture_dir(name); - let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); - let hir = opy_rs::compile(&source, "source.opy", &dir).expect("fixture must resolve"); - let artifact = Compiler::new().unwrap().compile_hir(&hir).unwrap(); - let catalog = Catalog::builtin().unwrap(); - let locale = Locale::new("en-US"); - let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); - - assert!( - !equivalent(&artifact.wir, &oracle), - "native WIR gap disappeared; update the compiler expectation for {name}" - ); -} - #[test] -fn issue_47_residual_native_wir_gaps_are_explicit() { +fn issue_47_switch_lowering_matches_the_pinned_oracle() { for name in [ - "control-flow", "issue-33-switch-break", "issue-47-control-flow", "issue-47-switch-order", "issue-47-switch-structured-target", ] { - assert_native_wir_gap(name); + assert_native_wir_equivalent(name); } } diff --git a/crates/opy-compiler/tests/issue_89_oracle.rs b/crates/opy-compiler/tests/issue_89_oracle.rs new file mode 100644 index 0000000..d37a16c --- /dev/null +++ b/crates/opy-compiler/tests/issue_89_oracle.rs @@ -0,0 +1,83 @@ +//! Oracle evidence for residual native-WIR lowering cases (issue #89). + +use std::path::{Path, PathBuf}; + +use opy_compiler::Compiler; +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::roundtrip::equivalent; +use workshop_rs::wir::Action; + +fn fixture_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures/synthetic") + .join(name) +} + +fn oracle_workshop(dir: &Path) -> String { + let oracle: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.join("oracle.json")).expect("oracle.json must be readable"), + ) + .expect("oracle snapshot must parse"); + oracle["compile"]["workshop"] + .as_str() + .expect("oracle snapshot must contain Workshop text") + .to_string() +} + +fn compile_fixture(name: &str) -> (opy_compiler::CompilationArtifact, workshop_rs::wir::Program) { + let dir = fixture_dir(name); + let source = std::fs::read_to_string(dir.join("source.opy")).expect("source must be readable"); + let hir = opy_rs::compile(&source, "source.opy", &dir).expect("fixture must resolve"); + let artifact = Compiler::new() + .expect("released workshop contract must load") + .compile_hir(&hir) + .expect("fixture must lower to canonical WIR"); + let catalog = Catalog::builtin().expect("catalog must load"); + let oracle = + workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &Locale::new("en-US")) + .expect("oracle output must reparse"); + (artifact, oracle) +} + +#[test] +fn issue_89_residual_lowering_cases_match_the_pinned_oracle() { + for name in [ + "issue-33-switch-break", + "issue-46-primitives", + "issue-47-control-flow", + "issue-47-switch-order", + "issue-47-switch-structured-target", + ] { + let (artifact, oracle) = compile_fixture(name); + assert!( + equivalent(&artifact.wir, &oracle), + "native WIR diverged for {name}\n{}", + artifact.emitted + ); + } +} + +#[test] +fn issue_89_debug_gap_remains_source_attributed() { + let (artifact, oracle) = compile_fixture("control-flow"); + assert!(!equivalent(&artifact.wir, &oracle)); + + let rule = artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap(); + let for_action = artifact.wir.actions.get(rule.actions[0]).unwrap(); + let Action::ForGlobalVariable { body, .. } = for_action else { + panic!("control-flow fixture must lower its for loop"); + }; + let if_action = artifact.wir.actions.get(body[0]).unwrap(); + let Action::If { branches, .. } = if_action else { + panic!("control-flow fixture must lower its conditional"); + }; + let debug_action = artifact.wir.actions.get(branches[0].body[0]).unwrap(); + let Action::Debug { span, .. } = debug_action else { + panic!("control-flow fixture must retain the canonical Debug carrier"); + }; + assert_eq!(span.unwrap().start.line, 7); +} From cf99c304aa53e26cbc70724d55f6787448374847 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sun, 30 Aug 2026 03:08:47 +0800 Subject: [PATCH 2/2] fix(opy-compiler): lower debug through native HUD WIR Expand debug helpers into the canonical Workshop HUD action/value graph and cover control-flow with direct WIR equivalence. Fixes #89 --- compatibility/compiler-expectations.json | 9 +- crates/opy-compiler/src/lib.rs | 445 +++++++++++++++++- .../tests/issue_85_preprocessing.rs | 20 +- crates/opy-compiler/tests/issue_89_oracle.rs | 12 +- 4 files changed, 468 insertions(+), 18 deletions(-) diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 00c6c90..ca94a1a 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -88,14 +88,15 @@ { "fixture": "synthetic/control-flow", "nativeStatus": "success", - "classification": "known-gap", - "comparison": "compiler-contract", + "classification": "match", + "comparison": "semantic-wir", "evidence": [ "oracle:synthetic/control-flow/oracle.json", "provenance:synthetic/control-flow/fixture.json" ], - "owner": "workshop-rs#123", - "note": "The native canonical Debug carrier is intentionally emitted as a simplified HUD action, while the pinned oracle expands type-aware debug formatting; direct equivalence remains a Workshop-owned carrier gap." + "owner": "opy-rs#89", + "note": "The native debug helper lowers to the pinned canonical HUD action and is directly equivalent to the oracle.", + "semanticEquivalent": true }, { "fixture": "synthetic/issue-33-switch-break", diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 4fc50c8..22fdc5e 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -1882,11 +1882,7 @@ impl<'a> Lowering<'a> { span: self.wir_span(*span)?, })]) } else if name == "debug" && args.len() == 1 { - let val = self.lower_value(&args[0])?; - Ok(vec![self.wir.actions.push(Action::Debug { - value: val, - span: self.wir_span(*span)?, - })]) + Ok(vec![self.lower_debug(&args[0], *span)?]) } else if name == "print" && args.len() == 1 { let msg = self.lower_value(&args[0])?; Ok(vec![self.wir.actions.push(Action::Print { @@ -2241,6 +2237,293 @@ impl<'a> Lowering<'a> { ))) } + fn lower_debug( + &mut self, + expr: &Expr, + span: Option, + ) -> Result { + macro_rules! call { + ($name:literal $(, $arg:expr)* $(,)?) => {{ + let args = vec![$($arg),*]; + self.push_call($name, args) + }}; + } + + let value = self.lower_value(expr)?; + let array_text = if self.debug_value_is_array(value) { + self.lower_debug_array_text(value) + } else { + value + }; + let debug_label = canonical_debug_text(&debug_expr_text(expr)); + let debug_prefix = format!("{debug_label}\u{2028}= {{0}}"); + let inline_padding = 128 - debug_prefix.chars().count() - "{1}".chars().count(); + let padding_text = self.push_value(Value::String(" ".repeat(170 - inline_padding))); + let padding = self.push_call("customString", vec![padding_text]); + let debug_label = self.push_value(Value::String(format!( + "{debug_prefix}{}{{1}}", + " ".repeat(inline_padding) + ))); + let text = self.push_call("customString", vec![debug_label, array_text, padding]); + let all_teams = self.push_value(Value::Enum { + value_type: "Team".to_string(), + value: "ALL".to_string(), + }); + let all_players = call!("allPlayers", all_teams); + let null_value = self.push_value(Value::Null); + let null_value_2 = self.push_value(Value::Null); + let null_value_3 = self.push_value(Value::Null); + let null_value_4 = self.push_value(Value::Null); + let hud_position = self.push_value(Value::Enum { + value_type: "HudPosition".to_string(), + value: "LEFT".to_string(), + }); + let sort_order = self.push_value(Value::Number { + value: -9999.0, + text: "-9999".to_string(), + }); + let color = self.push_value(Value::Enum { + value_type: "Color".to_string(), + value: "WHITE".to_string(), + }); + let reevaluation = self.push_value(Value::Enum { + value_type: "HudReeval".to_string(), + value: "VISIBILITY_SORT_ORDER_STRING_AND_COLOR".to_string(), + }); + let visibility = self.push_value(Value::Enum { + value_type: "SpecVisibility".to_string(), + value: "DEFAULT".to_string(), + }); + Ok(self.wir.actions.push(Action::Call { + name: "createHudText".to_string(), + args: vec![ + all_players, + null_value, + text, + null_value_2, + hud_position, + sort_order, + null_value_3, + color, + null_value_4, + reevaluation, + visibility, + ], + span: self.wir_span(span)?, + })) + } + + fn lower_debug_array_text(&mut self, value: wir::ValueId) -> wir::ValueId { + macro_rules! call { + ($name:literal $(, $arg:expr)* $(,)?) => {{ + let args = vec![$($arg),*]; + self.push_call($name, args) + }}; + } + + let current_count = call!("countOf", call!("currentArrayElement")); + let is_single = call!( + "==", + call!("countOf", call!("currentArrayElement")), + self.push_number(1.0, "1") + ); + let is_empty = call!("==", call!("currentArrayElement"), call!("emptyArray")); + let not_null = call!( + "!=", + call!("currentArrayElement"), + self.push_value(Value::Null) + ); + let has_empty_array = call!("and", is_empty, not_null); + let brackets = call!("or", is_single, has_empty_array); + let first_element = call!( + "customString", + self.push_value(Value::String("[{0}]".to_string())), + call!("currentArrayElement"), + ); + let many_elements = call!( + "customString", + self.push_value(Value::String("[{0}, …+{1}]".to_string())), + call!("currentArrayElement"), + call!( + "subtract", + call!("countOf", call!("currentArrayElement")), + self.push_number(1.0, "1"), + ), + ); + let element_text = call!( + "ifThenElse", + brackets, + first_element, + call!( + "ifThenElse", + current_count, + many_elements, + call!("currentArrayElement"), + ), + ); + let mapped_elements = call!("mappedArray", value, element_text,); + let mapped_input = call!("array", mapped_elements); + let current_array = call!("currentArrayElement"); + let actual_array = call!( + "or", + call!("countOf", current_array), + call!( + "and", + call!("==", call!("currentArrayElement"), call!("emptyArray")), + call!( + "!=", + call!("currentArrayElement"), + self.push_value(Value::Null) + ), + ), + ); + let empty_length = call!( + "ifThenElse", + call!( + "and", + call!("not", call!("countOf", call!("currentArrayElement"))), + call!("!=", call!("currentArrayElement"), call!("emptyArray"),), + ), + self.push_number(3.0, "3"), + call!( + "multiply", + call!("countOf", call!("currentArrayElement")), + self.push_number(3.0, "3"), + ), + ); + let x = call!( + "appendToArray", + call!("appendToArray", actual_array, empty_length), + current_array, + ); + let x_input = call!("mappedArray", mapped_input, x); + let x_length = |this: &mut Self| { + let current = this.push_call("currentArrayElement", Vec::new()); + let index = this.push_number(1.0, "1"); + this.push_call("valueInArray", vec![current, index]) + }; + let x_value = |this: &mut Self, index: f64| { + let current = this.push_call("currentArrayElement", Vec::new()); + let index_value = this.push_number(index, &index.to_string()); + this.push_call("valueInArray", vec![current, index_value]) + }; + let first = call!("firstOf", call!("currentArrayElement")); + let array_tail = call!( + "customString", + self.push_value(Value::String("{0}, {1}, {2}".to_string())), + x_value(self, 4.0), + x_value(self, 5.0), + call!( + "customString", + self.push_value(Value::String("{0}, {1}, …\u{0001}".to_string())), + x_value(self, 6.0), + x_value(self, 7.0), + ), + ); + let array_head = call!( + "customString", + self.push_value(Value::String("{0}, {1}, {2}".to_string())), + x_value(self, 2.0), + x_value(self, 3.0), + array_tail, + ); + let placeholder = call!( + "customString", + self.push_value(Value::String("0, 0, 0, 0, 0, 0, …\u{0001}".to_string())), + ); + let length_for_slice = x_length(self); + let end_length_for_slice = x_length(self); + let slice = call!( + "stringSlice", + placeholder, + call!("add", self.push_number(-2.0, "-2"), length_for_slice), + call!( + "subtract", + self.push_number(22.0, "22"), + end_length_for_slice, + ), + ); + let replaced = call!("stringReplace", array_head, slice, call!("emptyArray"),); + let length_for_compare = x_length(self); + let length_for_divide = x_length(self); + let plus = call!( + "ifThenElse", + call!(">", length_for_compare, self.push_number(18.0, "18")), + call!( + "customString", + self.push_value(Value::String("+{0}".to_string())), + call!( + "subtract", + call!("divide", length_for_divide, self.push_number(3.0, "3")), + self.push_number(6.0, "6"), + ), + ), + call!("emptyArray"), + ); + let formatted_array = call!( + "customString", + self.push_value(Value::String("[{0}{1}]".to_string())), + replaced, + plus, + ); + let current_for_split = call!("currentArrayElement"); + let rendered = call!( + "ifThenElse", + first, + formatted_array, + call!( + "stringSplit", + call!( + "valueInArray", + current_for_split, + self.push_number(2.0, "2") + ), + call!("emptyArray"), + ), + ); + call!("mappedArray", x_input, rendered) + } + + fn debug_value_is_array(&self, value: wir::ValueId) -> bool { + match &self + .wir + .values + .get(value) + .expect("lowered value must exist") + .value + { + Value::GlobalVariable(_) | Value::Array(_) => true, + Value::Call { name, .. } if matches!(name.as_str(), "array" | "emptyArray") => true, + Value::Call { name, .. } => self + .compiler + .catalog + .entry(Kind::Value, name) + .and_then(|entry| entry.return_type()) + .is_some_and(|return_type| { + return_type.split('|').any(|part| part.trim() == "Array") + }), + _ => false, + } + } + + fn push_value(&mut self, value: Value) -> wir::ValueId { + self.wir.values.push(ValueNode::new(value, None)) + } + + fn push_call(&mut self, name: &str, args: Vec) -> wir::ValueId { + self.push_value(Value::Call { + name: name.to_string(), + args, + }) + } + + fn push_number(&mut self, value: f64, text: &str) -> wir::ValueId { + self.push_value(Value::Number { + value, + text: text.to_string(), + }) + } + fn lower_condition(&mut self, expr: &Expr) -> Result { let value = self.lower_value(expr)?; let is_comparison = |expr: &Expr| matches!(expr, Expr::Binary { op, .. } if matches!(op.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">=")); @@ -3389,6 +3672,158 @@ fn canonical_format_text(text: &str) -> String { output } +fn debug_expr_text(expr: &Expr) -> String { + match expr { + Expr::Number { text, .. } => text.clone(), + Expr::String { value, .. } => { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } + Expr::Bool { value, .. } => value.to_string(), + Expr::Null { .. } => "null".to_string(), + Expr::Array { elements, .. } => format!( + "[{}]", + elements + .iter() + .map(debug_expr_text) + .collect::>() + .join(", ") + ), + Expr::Dict { entries, .. } => format!( + "{{{}}}", + entries + .iter() + .map(|entry| format!( + "{}: {}", + debug_expr_text(&entry.key), + debug_expr_text(&entry.value) + )) + .collect::>() + .join(", ") + ), + Expr::Comprehension { + element, + variable, + iterable, + condition, + .. + } => { + let condition = condition + .as_deref() + .map(|condition| format!(" if {}", debug_expr_text(condition))) + .unwrap_or_default(); + format!( + "[{} for {} in {}{}]", + debug_expr_text(element), + variable, + debug_expr_text(iterable), + condition + ) + } + Expr::Lambda { params, body, .. } => { + format!("lambda {}: {}", params.join(", "), debug_expr_text(body)) + } + Expr::StringModifier { + modifier, value, .. + } => format!("{}\"{}\"", modifier, value), + Expr::Local { name, .. } + | Expr::GlobalVar { name, .. } + | Expr::Constant { name, .. } + | Expr::MacroParam { name, .. } => name.clone(), + Expr::Vector { x, y, z, .. } => format!( + "vect({}, {}, {})", + debug_expr_text(x), + debug_expr_text(y), + debug_expr_text(z) + ), + Expr::Enum { + value_type, value, .. + } => format!("{}.{}", value_type, value), + Expr::PlayerVar { player, name, .. } => { + format!("{}.{}", debug_expr_text(player), name) + } + Expr::Member { + receiver, member, .. + } => format!("{}.{}", debug_expr_text(receiver), member), + Expr::EventPlayer { .. } => "eventPlayer".to_string(), + Expr::Call { name, args, .. } | Expr::MacroCall { name, args, .. } => format!( + "{}({})", + name, + args.iter() + .map(debug_expr_text) + .collect::>() + .join(", ") + ), + Expr::ReceiverCall { + receiver, + name, + args, + .. + } => format!( + "{}.{}({})", + debug_expr_text(receiver), + name, + args.iter() + .map(debug_expr_text) + .collect::>() + .join(", ") + ), + Expr::Binary { + left, op, right, .. + } => format!( + "{} {} {}", + debug_expr_text(left), + op, + debug_expr_text(right) + ), + Expr::Unary { op, operand, .. } => format!("{} {}", op, debug_expr_text(operand)), + Expr::Index { array, index, .. } => { + format!("{}[{}]", debug_expr_text(array), debug_expr_text(index)) + } + Expr::Format { text, args, .. } => format!( + "\"{}\".format({})", + text, + args.iter() + .map(debug_expr_text) + .collect::>() + .join(", ") + ), + } +} + +fn canonical_debug_text(text: &str) -> String { + text.chars() + .map(|character| match character { + 'a' => 'ạ', + 'b' => 'ḅ', + 'c' => 'ƈ', + 'd' => 'ḍ', + 'e' => 'ẹ', + 'f' => 'ƒ', + 'g' => 'ǥ', + 'h' => 'һ', + 'i' => 'і', + 'j' => 'ј', + 'k' => 'ḳ', + 'l' => 'I', + 'm' => 'ṃ', + 'n' => 'ṇ', + 'o' => 'ο', + 'p' => 'ṗ', + 'q' => 'ǫ', + 'r' => 'ṛ', + 's' => 'ѕ', + 't' => 'ṭ', + 'u' => 'υ', + 'v' => 'ν', + 'w' => 'ẉ', + 'x' => 'ҳ', + 'y' => 'ỵ', + 'z' => 'ẓ', + _ => character, + }) + .collect() +} + fn negated_comparison(op: &str) -> Option<&'static str> { Some(match op { "==" => "!=", diff --git a/crates/opy-compiler/tests/issue_85_preprocessing.rs b/crates/opy-compiler/tests/issue_85_preprocessing.rs index de822c7..00254e4 100644 --- a/crates/opy-compiler/tests/issue_85_preprocessing.rs +++ b/crates/opy-compiler/tests/issue_85_preprocessing.rs @@ -24,15 +24,24 @@ fn included_macro_call_reaches_canonical_workshop_output() { .rules .get(RuleId::from_index(0)) .expect("fixture has one rule"); - let Action::Debug { value, .. } = artifact + let Action::Call { name, args, .. } = artifact .wir .actions .get(rule.actions[0]) .expect("fixture has one debug action") else { - panic!("macro must lower to a debug action"); + panic!("macro must lower to a native HUD action"); }; - let Value::Call { name, args } = &artifact.wir.values.get(*value).unwrap().value else { + assert_eq!(name, "createHudText"); + let Value::Call { + name: text_name, + args: text_args, + } = &artifact.wir.values.get(args[2]).unwrap().value + else { + panic!("debug text must lower to a canonical value call"); + }; + assert_eq!(text_name, "customString"); + let Value::Call { name, args } = &artifact.wir.values.get(text_args[1]).unwrap().value else { panic!("macro result must lower to a canonical value call"); }; assert_eq!(name, "add"); @@ -58,7 +67,10 @@ fn statement_macro_expands_into_multiple_canonical_actions() { assert_eq!( rule.actions .iter() - .filter(|id| matches!(artifact.wir.actions.get(**id), Some(Action::Debug { .. }))) + .filter(|id| matches!( + artifact.wir.actions.get(**id), + Some(Action::Call { name, .. }) if name == "createHudText" + )) .count(), 2 ); diff --git a/crates/opy-compiler/tests/issue_89_oracle.rs b/crates/opy-compiler/tests/issue_89_oracle.rs index d37a16c..09a1109 100644 --- a/crates/opy-compiler/tests/issue_89_oracle.rs +++ b/crates/opy-compiler/tests/issue_89_oracle.rs @@ -42,6 +42,7 @@ fn compile_fixture(name: &str) -> (opy_compiler::CompilationArtifact, workshop_r #[test] fn issue_89_residual_lowering_cases_match_the_pinned_oracle() { for name in [ + "control-flow", "issue-33-switch-break", "issue-46-primitives", "issue-47-control-flow", @@ -58,9 +59,9 @@ fn issue_89_residual_lowering_cases_match_the_pinned_oracle() { } #[test] -fn issue_89_debug_gap_remains_source_attributed() { +fn issue_89_debug_lowers_to_a_native_hud_action() { let (artifact, oracle) = compile_fixture("control-flow"); - assert!(!equivalent(&artifact.wir, &oracle)); + assert!(equivalent(&artifact.wir, &oracle)); let rule = artifact .wir @@ -75,9 +76,10 @@ fn issue_89_debug_gap_remains_source_attributed() { let Action::If { branches, .. } = if_action else { panic!("control-flow fixture must lower its conditional"); }; - let debug_action = artifact.wir.actions.get(branches[0].body[0]).unwrap(); - let Action::Debug { span, .. } = debug_action else { - panic!("control-flow fixture must retain the canonical Debug carrier"); + let hud_action = artifact.wir.actions.get(branches[0].body[0]).unwrap(); + let Action::Call { name, span, .. } = hud_action else { + panic!("control-flow fixture debug must lower to a native HUD action"); }; + assert_eq!(name, "createHudText"); assert_eq!(span.unwrap().start.line, 7); }