From 279209455e80a5f2569167d4151a20b13940473f Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 24 Aug 2026 13:50:12 +0800 Subject: [PATCH 1/5] feat(opy-compiler): lower control flow into canonical WIR Fixes #47 --- compatibility/README.md | 2 +- compatibility/differential-expectations.json | 2 + compatibility/fixtures/README.md | 8 +- .../issue-47-control-flow/fixture.json | 14 + .../issue-47-control-flow/oracle.json | 27 ++ .../issue-47-control-flow/source.opy | 29 ++ .../issue-47-unsupported/fixture.json | 14 + .../issue-47-unsupported/oracle.json | 27 ++ .../synthetic/issue-47-unsupported/source.opy | 7 + compatibility/support-matrix.json | 20 +- crates/opy-compiler/src/lib.rs | 432 ++++++++++++++++-- crates/opy-compiler/tests/issue_47_oracle.rs | 74 +++ crates/opy-frontend/tests/differential.rs | 12 + docs/opy/support-matrix.md | 11 +- 14 files changed, 636 insertions(+), 43 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-47-control-flow/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-control-flow/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-control-flow/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-47-unsupported/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-unsupported/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-unsupported/source.opy create mode 100644 crates/opy-compiler/tests/issue_47_oracle.rs diff --git a/compatibility/README.md b/compatibility/README.md index 522a8cb..16a1146 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -45,7 +45,7 @@ compatibility/fixtures/// Imported fixtures should also record an immutable `sourceCommit`, a direct `sourceUrl`, a `licenseUrl`, and whether the source was modified. The corpus -contains 44 fixtures: 30 WrightKit-authored synthetic cases, one census boundary +contains 46 fixtures: 32 WrightKit-authored synthetic cases, one census boundary fixture, and 13 real-world projects (11 derived from the pinned OverPy `examples/` tree, GPL-3.0-only, provenance-recorded evidence, plus the independent BSD-2-Clause diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 4d0b090..91cf434 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -50,6 +50,8 @@ {"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-oracle-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 opy-compiler test suite reparses both the native output and the oracle Workshop text through the canonical workshop-rs parser and requires 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 frontend 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", "test:opy-compiler-issue-47-oracle-equivalence"], "note": "The #47 control-flow slice resolves in the frontend and lowers to canonical WIR with structural equivalence to the pinned oracle; direct loop/switch break and the nested switch shape are independently covered by opy-compiler tests."}, + {"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 frontend 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": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} ] } diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index 51c4229..4e5d639 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -3,7 +3,7 @@ This directory is the opy-rs compatibility corpus: OPY sources with their pinned-oracle snapshots (`oracle.json`), ported from the WrightKit project's evidence base (wright `compatibility/fixtures/`) and re-verified against the -pinned OverPy 9.7.10 oracle (all 44 snapshots match; see +pinned OverPy 9.7.10 oracle (all 46 snapshots match; see [`docs/compatibility/upstream-references.md`](../../docs/compatibility/upstream-references.md) for the dated verification record). @@ -29,7 +29,7 @@ fixtures/// ## Synthetic fixtures (WrightKit-authored) -`fixtures/synthetic/` — 30 fixtures authored for the WrightKit compatibility +`fixtures/synthetic/` — 32 fixtures authored for the WrightKit compatibility corpus (AGPL-3.0-or-later, `kind: original`): an initial set ported unchanged from the wright repository corpus, extended by fixtures added in this repository: @@ -56,6 +56,8 @@ repository: | `issue-35-integration` | minimal OPY HIR to canonical Workshop WIR validation and deterministic emission slice | | `issue-46-primitives` | #46 oracle-backed primitive lowering probe: assignments and modifications (including `**=`), expressions, indexing, format, initializers, implicit default variables at fixed slots; the snapshot constrains the native compiler through structural equivalence | | `issue-46-unsupported` | #46 negative probe: a dict-indexed assignment the compiler rejects with the stable source-attributed diagnostic while the oracle compiles it | +| `issue-47-control-flow` | #47 pinned oracle-backed control-flow lowering probe: if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break | +| `issue-47-unsupported` | #47 negative probe: a break hidden inside a conditional switch arm is accepted by the frontend/oracle but rejected by the compiler with a stable source-attributed diagnostic | | `issue-29-*` | directive/include/main-file preprocessing probes | | `issue-33-*` | switch break/fallthrough, f-string interpolation, and lambda negative probes | | `receiver-playervar` | bare variable member expression `A = B.C` with preserved receiver/member provenance | @@ -128,7 +130,7 @@ reference diagnostics, exactly like the pinned oracle behaves. ## Not ported / dropped -* **No fixture was dropped for provenance reasons**: all 44 fixtures in the +* **No fixture was dropped for provenance reasons**: all 46 fixtures in the WrightKit corpus carried complete, reviewed provenance and are ported. * Upstream `examples/` not ported (candidates for later expansion once a demonstrated need exists): `lucioball_all_heroes.opy`, `skirmish_elim.opy`, diff --git a/compatibility/fixtures/synthetic/issue-47-control-flow/fixture.json b/compatibility/fixtures/synthetic/issue-47-control-flow/fixture.json new file mode 100644 index 0000000..3fac138 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-control-flow/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-control-flow", + "category": "compilation", + "features": ["control flow", "if", "while", "for", "doWhile", "switch", "break"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned control-flow lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-control-flow/oracle.json b/compatibility/fixtures/synthetic/issue-47-control-flow/oracle.json new file mode 100644 index 0000000..e6db9b6 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-control-flow/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n 8: I\n}\n\nrule (\"issue 47 control flow\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip If(Compare(Global.value, ==, 1), 2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_2__:\n If(Compare(Global.value, ==, 2));\n Set Global Variable(value, 3);\n Else If(Compare(Global.value, ==, 4));\n Set Global Variable(value, 5);\n Else;\n Set Global Variable(value, 6);\n End;\n For Global Variable(I, 0, 2, 1);\n If(Compare(Global.I, ==, 1));\n Modify Global Variable(value, Add, 1);\n End;\n End;\n While(Compare(Global.value, <, 5));\n Modify Global Variable(value, Add, 1);\n End;\n If(True);\n Skip(Value In Array(Array(3, 0, 1), Add(1, Index Of Array Value(Array(5, 6), Global.value))));\n //__label_switch_3_0__:\n Set Global Variable(value, 6);\n //__label_switch_3_1__:\n Set Global Variable(value, 7);\n Else;\n //__label_switch_3_default__:\n Set Global Variable(value, 8);\n End;\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n 8: I\n}\n\nrule (\"issue 47 control flow\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip If(Compare(Global.value, ==, 1), 2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_2__:\n If(Compare(Global.value, ==, 2));\n Set Global Variable(value, 3);\n Else If(Compare(Global.value, ==, 4));\n Set Global Variable(value, 5);\n Else;\n Set Global Variable(value, 6);\n End;\n For Global Variable(I, 0, 2, 1);\n If(Compare(Global.I, ==, 1));\n Modify Global Variable(value, Add, 1);\n End;\n End;\n While(Compare(Global.value, <, 5));\n Modify Global Variable(value, Add, 1);\n End;\n If(True);\n Skip(Value In Array(Array(3, 0, 1), Add(1, Index Of Array Value(Array(5, 6), Global.value))));\n //__label_switch_3_0__:\n Set Global Variable(value, 6);\n //__label_switch_3_1__:\n Set Global Variable(value, 7);\n Else;\n //__label_switch_3_default__:\n Set Global Variable(value, 8);\n End;\n }\n}\n\n", + "workshopSha256": "70c21e1ee305a46f8195e9ff0f322ac22797314c1f157e07ad3e333ea6d9bc69" + }, + "fixture": "synthetic/issue-47-control-flow", + "input": { + "sha256": "8ff446e90c2d5b1827c42d4d3480951b8059dad1c7f9ecb7f0a6e18a63f1ca77", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-control-flow/source.opy b/compatibility/fixtures/synthetic/issue-47-control-flow/source.opy new file mode 100644 index 0000000..ad3d21f --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-control-flow/source.opy @@ -0,0 +1,29 @@ +globalvar value = 0 + +rule "issue 47 control flow": + @Event global + do: + value += 1 + if value == 1: + break + value += 10 + while value < 2 + if value == 2: + value = 3 + elif value == 4: + value = 5 + else: + value = 6 + for I in range(0, 2): + if I == 1: + value += 1 + while value < 5: + value += 1 + switch value: + case 5: + value = 6 + case 6: + value = 7 + break + default: + value = 8 diff --git a/compatibility/fixtures/synthetic/issue-47-unsupported/fixture.json b/compatibility/fixtures/synthetic/issue-47-unsupported/fixture.json new file mode 100644 index 0000000..78fec3e --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-unsupported/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-unsupported", + "category": "compilation", + "features": ["control flow", "switch", "break", "negative diagnostic"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 nested conditional switch-break negative probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-unsupported/oracle.json b/compatibility/fixtures/synthetic/issue-47-unsupported/oracle.json new file mode 100644 index 0000000..5264c8f --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-unsupported/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\n", + "workshopSha256": "55a66c030c98fbd8fa9136c220b86b7cbd460a93e5d74033af4e04abf0cd899c" + }, + "fixture": "synthetic/issue-47-unsupported", + "input": { + "sha256": "e20ad681f3771fc07b49fe577778b595b8f3b847266e4805f0ef7b0cff81c9a6", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-unsupported/source.opy b/compatibility/fixtures/synthetic/issue-47-unsupported/source.opy new file mode 100644 index 0000000..9613a56 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-unsupported/source.opy @@ -0,0 +1,7 @@ +globalvar value +rule "issue 47 unsupported nested switch break": + @Event global + switch value: + case 1: + if value == 1: + break diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 8b4e2cb..88c9024 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -674,6 +674,20 @@ ], "notes": "Issue #46. Literals (numbers, strings, booleans, null), arrays, vectors (vect), enum constants, global/player/eventPlayer variable references, array indexing (valueInArray; a literal zero index lowers to firstOf, matching the pinned oracle), string formatting (customString), arithmetic/comparison/logical binary operations, unary operations (not, -; not on a comparison lowers to the negated comparison, matching the pinned oracle), and power operations lower into canonical workshop-rs WIR. Value-position power uses the canonical raiseToPower Value contract; direct and indexed power modifications use the canonical Workshop Operation contract." }, + { + "id": "compilation/opy-control-flow-lowering", + "name": "OPY control-flow HIR -> canonical WIR lowering", + "category": "compilation", + "state": "end-to-end-supported", + "evidence": [ + "fixtures:synthetic/issue-47-control-flow", + "fixtures:synthetic/issue-47-unsupported", + "fixtures:synthetic/issue-33-switch-break", + "test:opy-compiler-issue-47-oracle-equivalence", + "contract:workshop-rs-v0.1.8" + ], + "notes": "Issue #47. If/elif/else, while, supported range-for, do-while expansion, switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. The nested switch shape is oracle-equivalent; a break hidden inside a conditional switch arm remains a stable source-attributed unsupported-integration-surface diagnostic because the current canonical WIR has no equivalent structured break carrier." + }, { "id": "compilation/workshop-lowering", "name": "HIR -> Workshop lowering and emission through workshop-rs", @@ -683,7 +697,7 @@ "fixtures:synthetic", "fixtures:real-world" ], - "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.8 public contract now supports the bounded #35 adapter and the #40 structural skeleton; full OPY statement/expression lowering, catalog/member/enum breadth, settings/content, locales, optimizer effects, and hooks remain lowering-dependent follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." + "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.8 public contract now supports the bounded #35 adapter, the #40 structural skeleton, and the separately evidenced #47 control-flow slice; full OPY statement/expression lowering, catalog/member/enum breadth, settings/content, locales, optimizer effects, and hooks remain lowering-dependent follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." }, { "id": "compilation/end-to-end", @@ -725,7 +739,7 @@ "frontend-supported": 23, "semantic-supported": 13, "lowering-dependent": 13, - "end-to-end-supported": 6 + "end-to-end-supported": 7 }, "byCategory": { "syntax": 14, @@ -736,7 +750,7 @@ "translations": 2, "optimization": 3, "runtime": 1, - "compilation": 9, + "compilation": 10, "decompilation": 2 } } diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 7902985..5569a5f 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -239,6 +239,13 @@ struct Lowering<'a> { defined_subroutines: HashSet, } +#[derive(Debug, Clone, Copy)] +enum BreakTarget { + Loop, + DoWhile, + Switch, +} + impl<'a> Lowering<'a> { fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result { Ok(Self { @@ -613,11 +620,7 @@ impl<'a> Lowering<'a> { .map(|expr| self.lower_value(expr)) .collect::, _>>()?; let mut actions = Vec::new(); - for stmt in &rule.actions { - if let Some(action) = self.lower_action(stmt)? { - actions.push(action); - } - } + actions.extend(self.lower_actions(&rule.actions, None)?); self.wir.rules.push(wir::Rule { name: rule.name.clone(), span: self.wir_span(rule.span)?, @@ -658,11 +661,7 @@ impl<'a> Lowering<'a> { )); } let mut actions = Vec::new(); - for stmt in body { - if let Some(action) = self.lower_action(stmt)? { - actions.push(action); - } - } + actions.extend(self.lower_actions(body, None)?); self.wir.rules.push(wir::Rule { name: self.subroutine_rule_name(name), span: self.wir_span(span)?, @@ -930,36 +929,163 @@ impl<'a> Lowering<'a> { } } - fn lower_action(&mut self, stmt: &Stmt) -> Result, IntegrationError> { + fn lower_actions( + &mut self, + statements: &[Stmt], + break_target: Option, + ) -> Result, IntegrationError> { + let mut actions = Vec::new(); + for statement in statements { + actions.extend(self.lower_action(statement, break_target)?); + } + Ok(actions) + } + + fn lower_action( + &mut self, + stmt: &Stmt, + break_target: Option, + ) -> Result, IntegrationError> { match stmt { - Stmt::Pass { .. } => Ok(None), + Stmt::Pass { .. } => Ok(Vec::new()), Stmt::Assign { target, value, span, - } => self.lower_assign(target, value, *span).map(Some), + } => self.lower_assign(target, value, *span).map(|action| vec![action]), + Stmt::If { + branches, + r#else, + span, + } => { + let branches = branches + .iter() + .map(|branch| { + Ok(wir::IfBranch { + condition: self.lower_value(&branch.condition)?, + body: self.lower_actions(&branch.body, break_target)?, + }) + }) + .collect::, IntegrationError>>()?; + let else_body = r#else + .as_ref() + .map(|body| self.lower_actions(body, break_target)) + .transpose()?; + Ok(vec![self.wir.actions.push(Action::If { + branches, + else_body, + span: self.wir_span(*span)?, + })]) + } + Stmt::For { + variable, + iterable, + body, + span, + } => { + let Expr::GlobalVar { + name, + span: target_span, + } = variable.as_ref() + else { + return Err(self.unsupported( + "range loops require a global-variable binder in canonical WIR", + variable.span().copied(), + )); + }; + let variable_id = *self.globals.get(name).ok_or_else(|| { + self.unsupported(format!("unknown global variable '{name}'"), *target_span) + })?; + let (start, stop, step) = self.lower_range(iterable)?; + let body = self.lower_actions(body, Some(BreakTarget::Loop))?; + Ok(vec![self.wir.actions.push(Action::ForGlobalVariable { + variable: variable_id, + start, + stop, + step, + body, + span: self.wir_span(*span)?, + target_span: self.wir_span(*target_span)?, + })]) + } + Stmt::While { + condition, + body, + span, + } => { + let condition = self.lower_value(condition)?; + let body = self.lower_actions(body, Some(BreakTarget::Loop))?; + Ok(vec![self.wir.actions.push(Action::While { + condition, + body, + span: self.wir_span(*span)?, + })]) + } + Stmt::DoWhile { + condition, + body, + span, + } => { + let body = self.lower_do_while_body(body)?; + let condition = self.lower_value(condition)?; + let loop_if = self.wir.actions.push(Action::Call { + name: "loopIf".to_string(), + args: vec![condition], + span: self.wir_span(*span)?, + }); + // OverPy's pinned lowering expands do/while into its body + // followed by the canonical Loop If action. + let mut actions = body; + actions.push(loop_if); + Ok(actions) + } + Stmt::Switch { + value, + cases, + r#default, + span, + } => self.lower_switch(value, cases, r#default.as_deref(), *span).map(|action| vec![action]), + Stmt::Break { span } => match break_target { + Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call { + name: "break".to_string(), + args: Vec::new(), + span: self.wir_span(*span)?, + })]), + Some(BreakTarget::DoWhile) => Err(self.unsupported( + "break inside a do-while must be a direct statement or a single conditional break", + *span, + )), + Some(BreakTarget::Switch) => Err(self.unsupported( + "break inside a nested conditional cannot be normalized into canonical switch control flow", + *span, + )), + None => Err(self.unsupported( + "break has no enclosing canonical loop or switch", + *span, + )), + }, Stmt::Expr { expr, span } => match expr.as_ref() { Expr::Call { name, args, .. } => { if name == "disableInspector" && args.is_empty() { - Ok(Some(self.wir.actions.push(Action::Call { + Ok(vec![self.wir.actions.push(Action::Call { name: "disableInspector".to_string(), args: Vec::new(), span: self.wir_span(*span)?, - }))) + })]) } else if name == "debug" && args.len() == 1 { let val = self.lower_value(&args[0])?; - Ok(Some(self.wir.actions.push(Action::Debug { + Ok(vec![self.wir.actions.push(Action::Debug { value: val, span: self.wir_span(*span)?, - }))) + })]) } else if name == "print" && args.len() == 1 { let msg = self.lower_value(&args[0])?; - Ok(Some(self.wir.actions.push(Action::Print { + Ok(vec![self.wir.actions.push(Action::Print { message: msg, span: self.wir_span(*span)?, - }))) + })]) } else { - self.lower_action_call(name, args, *span).map(Some) + self.lower_action_call(name, args, *span).map(|action| vec![action]) } } _ => Err(self.unsupported( @@ -972,19 +1098,252 @@ impl<'a> Lowering<'a> { self.unsupported(format!("unknown subroutine '{name}'"), *span) })?; let span = self.wir_span(*span)?; - Ok(Some(self.wir.actions.push(Action::CallSubroutine { + Ok(vec![self.wir.actions.push(Action::CallSubroutine { subroutine, span, callee_span: span, - }))) + })]) } - _ => Err(self.unsupported( - "the statement is not currently representable in canonical WIR", - stmt.span().copied(), + } + } + + fn lower_do_while_body( + &mut self, + statements: &[Stmt], + ) -> Result, IntegrationError> { + let mut actions = Vec::new(); + for (index, statement) in statements.iter().enumerate() { + let direct_break = matches!(statement, Stmt::Break { .. }); + let conditional_break = match statement { + Stmt::If { + branches, + r#else: None, + .. + } if branches.len() == 1 => { + matches!(branches[0].body.as_slice(), [Stmt::Break { .. }]) + } + _ => false, + }; + + if direct_break || conditional_break { + let tail = self.lower_do_while_body(&statements[index + 1..])?; + let distance = tail.len() + 1; + let (name, args, span) = if let Stmt::Break { span } = statement { + ("skip", Vec::new(), *span) + } else if let Stmt::If { branches, span, .. } = statement { + ( + "skipIf", + vec![self.lower_value(&branches[0].condition)?], + *span, + ) + } else { + unreachable!("break shape was checked above") + }; + let distance = self.wir.values.push(ValueNode::new( + Value::Number { + value: distance as f64, + text: distance.to_string(), + }, + self.wir_span(span)?, + )); + let mut args = args; + args.push(distance); + actions.push(self.wir.actions.push(Action::Call { + name: name.to_string(), + args, + span: self.wir_span(span)?, + })); + actions.extend(tail); + return Ok(actions); + } + + actions.extend(self.lower_action(statement, Some(BreakTarget::DoWhile))?); + } + Ok(actions) + } + + fn lower_range( + &mut self, + iterable: &Expr, + ) -> Result<(wir::ValueId, wir::ValueId, wir::ValueId), IntegrationError> { + let Expr::Call { name, args, .. } = iterable else { + return Err(self.unsupported( + "range loop iterable must be a range(...) call", + iterable.span().copied(), + )); + }; + if name != "range" || !(1..=3).contains(&args.len()) { + return Err(self.unsupported( + "range loop requires one to three arguments", + iterable.span().copied(), + )); + } + let span = iterable.span().copied(); + let number = |this: &mut Self, value: f64| -> Result { + Ok(this.wir.values.push(ValueNode::new( + Value::Number { + value, + text: value.to_string(), + }, + this.wir_span(span)?, + ))) + }; + match args.as_slice() { + [stop] => Ok(( + number(self, 0.0)?, + self.lower_value(stop)?, + number(self, 1.0)?, )), + [start, stop] => Ok(( + self.lower_value(start)?, + self.lower_value(stop)?, + number(self, 1.0)?, + )), + [start, stop, step] => Ok(( + self.lower_value(start)?, + self.lower_value(stop)?, + self.lower_value(step)?, + )), + _ => unreachable!("range arity checked above"), } } + fn lower_switch( + &mut self, + value: &Expr, + cases: &[hir::SwitchCase], + default: Option<&[Stmt]>, + span: Option, + ) -> Result { + let selector = self.lower_value(value)?; + let mut case_values = Vec::with_capacity(cases.len()); + let mut lowered_cases = Vec::with_capacity(cases.len()); + let mut offsets = Vec::with_capacity(cases.len() + 1); + let mut offset = 0usize; + + for case in cases { + case_values.push(self.lower_value(&case.value)?); + let (body, breaks) = self.lower_switch_body(&case.body)?; + offsets.push(offset); + offset += body.len() + usize::from(breaks); + lowered_cases.push((body, breaks)); + } + let default_offset = offset; + + let default_body = default + .map(|body| self.lower_switch_body(body).map(|(actions, _)| actions)) + .transpose()?; + + let case_values = self.wir.values.push(ValueNode::new( + Value::Array(case_values), + self.wir_span(span)?, + )); + let value_span = self.wir_span(span)?; + let offset_values = std::iter::once(default_offset) + .chain(offsets) + .map(|value| { + self.wir.values.push(ValueNode::new( + Value::Number { + value: value as f64, + text: value.to_string(), + }, + value_span, + )) + }) + .collect(); + let offsets = self + .wir + .values + .push(ValueNode::new(Value::Array(offset_values), value_span)); + let one = self.wir.values.push(ValueNode::new( + Value::Number { + value: 1.0, + text: "1".to_string(), + }, + self.wir_span(span)?, + )); + let index = self.wir.values.push(ValueNode::new( + Value::Call { + name: "indexOfArrayValue".to_string(), + args: vec![case_values, selector], + }, + self.wir_span(span)?, + )); + let case_offset = self.wir.values.push(ValueNode::new( + Value::Call { + name: "add".to_string(), + args: vec![one, index], + }, + self.wir_span(span)?, + )); + let skip_condition = self.wir.values.push(ValueNode::new( + Value::Call { + name: "valueInArray".to_string(), + args: vec![offsets, case_offset], + }, + self.wir_span(span)?, + )); + let skip = self.wir.actions.push(Action::Call { + name: "skip".to_string(), + args: vec![skip_condition], + span: self.wir_span(span)?, + }); + let true_value = self + .wir + .values + .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + + let mut branch_body = vec![skip]; + let mut else_body = None; + for (body, breaks) in lowered_cases { + if else_body.is_none() { + branch_body.extend(body); + if breaks { + else_body = Some(Vec::new()); + } + } else if let Some(tail) = &mut else_body { + tail.extend(body); + // A second direct break is a structural Else in the pinned + // oracle. The canonical WIR has one else carrier, so the + // first boundary is the representable semantic boundary and + // later unreachable boundaries contribute no action. + if breaks { + break; + } + } + } + if let Some(tail) = &mut else_body { + if let Some(default_body) = default_body { + tail.extend(default_body); + } + } else { + else_body = default_body; + } + + Ok(self.wir.actions.push(Action::If { + branches: vec![wir::IfBranch { + condition: true_value, + body: branch_body, + }], + else_body, + span: self.wir_span(span)?, + })) + } + + fn lower_switch_body( + &mut self, + statements: &[Stmt], + ) -> Result<(Vec, bool), IntegrationError> { + let mut actions = Vec::new(); + for statement in statements { + if matches!(statement, Stmt::Break { .. }) { + return Ok((actions, true)); + } + actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?); + } + Ok((actions, false)) + } + fn lower_assign( &mut self, target: &Expr, @@ -2047,20 +2406,25 @@ mod tests { } #[test] - fn unsupported_lowering_remains_source_attributed() { + fn while_lowering_is_source_attributed() { let compiler = Compiler::new().unwrap(); let hir = opy_frontend::compile( - "rule \"unsupported\":\n @Event global\n while true:\n disableInspector()\n", - "unsupported.opy", + "rule \"while\":\n @Event global\n while true:\n disableInspector()\n", + "while.opy", Path::new("."), ) .unwrap(); - let error = match compiler.compile_hir(&hir) { - Ok(_) => panic!("unsupported lowering unexpectedly succeeded"), - Err(error) => error, - }; - assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); - assert_eq!(error.diagnostic.span.unwrap().start.line, 3); + let artifact = compiler.compile_hir(&hir).unwrap(); + let rule = artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap(); + assert!(matches!( + artifact.wir.actions.get(rule.actions[0]), + Some(workshop_rs::wir::Action::While { .. }) + )); + assert!(artifact.emitted.contains("While(True);")); } #[test] diff --git a/crates/opy-compiler/tests/issue_47_oracle.rs b/crates/opy-compiler/tests/issue_47_oracle.rs new file mode 100644 index 0000000..68fcb94 --- /dev/null +++ b/crates/opy-compiler/tests/issue_47_oracle.rs @@ -0,0 +1,74 @@ +//! Oracle-backed control-flow lowering evidence for issue #47. + +use std::path::{Path, PathBuf}; + +use opy_compiler::Compiler; +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::roundtrip::equivalent; + +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() +} + +#[test] +fn issue_47_control_flow_matches_the_pinned_oracle() { + let dir = fixture_dir("issue-47-control-flow"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::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 native = workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap(); + let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); + + assert!( + equivalent(&native, &oracle), + "native lowering diverged\n{}", + artifact.emitted + ); +} + +#[test] +fn issue_47_nested_switch_break_matches_the_pinned_oracle() { + let dir = fixture_dir("issue-33-switch-break"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::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 native = workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap(); + let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); + + assert!( + equivalent(&native, &oracle), + "nested native lowering diverged\n{}", + artifact.emitted + ); +} + +#[test] +fn issue_47_nested_switch_break_is_source_attributed_when_not_representable() { + let compiler = Compiler::new().unwrap(); + let dir = fixture_dir("issue-47-unsupported"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::compile(&source, "source.opy", &dir).unwrap(); + let error = match compiler.compile_hir(&hir) { + Ok(_) => panic!("nested switch break unexpectedly lowered"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); + assert_eq!(error.diagnostic.span.unwrap().start.line, 7); +} diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 1fa228a..bec8d97 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -211,6 +211,18 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { false, "Issue #46 negative primitive-lowering probe; the frontend and the pinned oracle accept the dict-indexed assignment while the opy-compiler rejects it with the stable source-attributed unsupported-integration-surface diagnostic.", ); + resolve( + &mut cases, + "synthetic/issue-47-control-flow", + false, + "Issue #47 oracle-backed control-flow lowering probe; the frontend resolves the source while opy-compiler independently constrains canonical WIR against the pinned oracle.", + ); + resolve( + &mut cases, + "synthetic/issue-47-unsupported", + false, + "Issue #47 negative probe; the frontend preserves the nested conditional switch-break HIR while the compiler rejects it at the canonical WIR integration boundary.", + ); diagnostic( &mut cases, "synthetic/issue-33-lambda-negative", diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 39d84ad..f29e8ec 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -32,7 +32,8 @@ suite are implemented and CI-covered. The rows they evidence are flipped to `compatibility/support-matrix.json`, the mechanically checked state source; features whose completion requires the full canonical Workshop surface remain `lowering-dependent`; the bounded #35 adapter, the #40 structural HIR → -canonical WIR lowering, and the #46 non-control-flow primitive lowering are +canonical WIR lowering, the #46 non-control-flow primitive lowering, and the +#47 control-flow lowering are separately recorded as `end-to-end-supported` and do not reclassify broader Workshop-owned rows. Here, `end-to-end-supported` is scoped to the explicitly evidenced feature or @@ -60,8 +61,11 @@ Workshop-independent up to the documented integration boundary toward | `compatibility/fixtures/synthetic/issue-40-structural/` | #40 pinned OverPy oracle evidence for subroutine identity, deterministic variable allocation, and player event filters | | `compatibility/fixtures/synthetic/issue-46-primitives/` | #46 pinned OverPy oracle evidence for non-control-flow statement and value primitive lowering (assignments and modifications including `**=`, global/player implicit default variables at fixed slots, hex-number normalization, index-0 `firstOf` read normalization, negated-comparison lowering); the oracle snapshot constrains the native compiler through the canonical `workshop-rs` parser and structural equivalence | | `compatibility/fixtures/synthetic/issue-46-unsupported/` | #46 negative primitive-lowering evidence: the frontend and pinned oracle accept a dict-indexed assignment while the compiler rejects it with the stable source-attributed `unsupported-integration-surface` diagnostic | +| `compatibility/fixtures/synthetic/issue-47-control-flow/` | #47 pinned OverPy oracle evidence for if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break lowering; native output is reparsed through `workshop-rs` and compared structurally | +| `compatibility/fixtures/synthetic/issue-47-unsupported/` | #47 negative evidence: nested conditional switch-break is preserved by the frontend/oracle and rejected at the canonical WIR boundary with a source-attributed diagnostic | | `crates/opy-compiler/src/lib.rs` structural tests | #40/#46 declarations, subroutines, rules, event filters, assignments, expressions, indexing, format, pass, and source-attributed negative lowering evidence | | `crates/opy-compiler/tests/issue_46_oracle.rs` | #46 oracle-constrained differential equivalence: native output and the pinned oracle Workshop text both reparse through the canonical `workshop-rs` parser and must satisfy `roundtrip::equivalent` | +| `crates/opy-compiler/tests/issue_47_oracle.rs` | #47 oracle-constrained control-flow equivalence plus a nested switch-break negative diagnostic | | `compatibility/support-matrix.json` | Machine-readable state tracking of every declared feature (the mechanically checkable artifact) | | `crates/opy-frontend/src/manifest/` | The opy-rs-owned semantic compatibility manifest and its oracle probes (ported with the frontend, issue #3/#4) | | `crates/opy-frontend/tests/differential.rs` + `compatibility/diff.py` | Native-vs-reference differential parity (issue #7): the rust suite runs every corpus fixture through the native pipeline in `cargo test` (no Node), compares status/rule-name evidence against the recorded `oracle.json` snapshots, and writes `target/opy-differential-report.json` | @@ -86,7 +90,10 @@ implements; "reference" always means the pinned OverPy 9.7.10 - `switch`/`case`/`default` preserve source-order fallthrough; `break` is a real HIR statement valid in the innermost switch or loop. - `do ... while`, hexadecimal literals, and expression-level `in`/`not in` - are represented in the source-language HIR. + are represented in the source-language HIR. The #47 integration slice lowers + if/elif/else, while, range-for, do-while, switch fallthrough/default, and + direct loop/switch break into canonical WIR; an evidence-insufficient nested + conditional switch break remains a source-attributed diagnostic. - String modifiers, including f-string interpolation, preserve semantic format text, interpolation expressions, and source spans; dict literals, keyed access, list comprehensions, and lambda binders preserve local scope. From bff5c796d1c1492ce5111f83ad93c00272b533bd Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 24 Aug 2026 16:10:28 +0800 Subject: [PATCH 2/5] feat(opy-compiler): complete issue 47 control-flow lowering Preserve source-ordered switch arms and enforce the pinned do-while placement contract. Normalize jump distances from emitted canonical WIR widths, retain reachable switch actions, and report unsupported multi-target switch shapes explicitly. Add pinned oracle fixtures for ordering, break shapes, and diagnostics. Fixes #47 --- compatibility/README.md | 2 +- compatibility/differential-expectations.json | 4 + compatibility/fixtures/README.md | 4 + .../fixture.json | 14 + .../oracle.json | 32 +++ .../source.opy | 8 + .../issue-47-do-while-shapes/fixture.json | 14 + .../issue-47-do-while-shapes/oracle.json | 27 ++ .../issue-47-do-while-shapes/source.opy | 38 +++ .../fixture.json | 14 + .../oracle.json | 27 ++ .../issue-47-switch-multiple-break/source.opy | 15 ++ .../issue-47-switch-order/fixture.json | 14 + .../issue-47-switch-order/oracle.json | 27 ++ .../issue-47-switch-order/source.opy | 11 + compatibility/support-matrix.json | 8 +- crates/opy-compiler/src/lib.rs | 244 ++++++++++++------ crates/opy-compiler/tests/issue_47_oracle.rs | 60 +++++ crates/opy-frontend/src/cst.rs | 36 ++- crates/opy-frontend/src/hir/dump.rs | 37 +-- crates/opy-frontend/src/hir/mod.rs | 2 +- crates/opy-frontend/src/hir/types.rs | 29 ++- crates/opy-frontend/src/hir/validate.rs | 91 +++++-- crates/opy-frontend/src/lower.rs | 122 ++++++--- crates/opy-frontend/src/parser.rs | 17 +- crates/opy-frontend/src/tooling.rs | 29 +-- crates/opy-frontend/tests/differential.rs | 24 ++ docs/hir/opy-hir-v1.md | 2 +- docs/opy/support-matrix.md | 18 +- 29 files changed, 759 insertions(+), 211 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-shapes/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-shapes/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-do-while-shapes/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-multiple-break/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-multiple-break/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-order/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-order/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-order/source.opy diff --git a/compatibility/README.md b/compatibility/README.md index 16a1146..ae65281 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -45,7 +45,7 @@ compatibility/fixtures/// Imported fixtures should also record an immutable `sourceCommit`, a direct `sourceUrl`, a `licenseUrl`, and whether the source was modified. The corpus -contains 46 fixtures: 32 WrightKit-authored synthetic cases, one census boundary +contains 50 fixtures: 36 WrightKit-authored synthetic cases, one census boundary fixture, and 13 real-world projects (11 derived from the pinned OverPy `examples/` tree, GPL-3.0-only, provenance-recorded evidence, plus the independent BSD-2-Clause diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 91cf434..b5ca893 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -52,6 +52,10 @@ {"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 frontend 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", "test:opy-compiler-issue-47-oracle-equivalence"], "note": "The #47 control-flow slice resolves in the frontend and lowers to canonical WIR with structural equivalence to the pinned oracle; direct loop/switch break and the nested switch shape are independently covered by opy-compiler tests."}, {"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 frontend 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", "test:opy-compiler-issue-47-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the frontend; native and pinned Workshop both reparse to equivalent canonical WIR."}, + {"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 frontend 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.8 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 after native and oracle reparsing through workshop-rs."}, + {"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 frontend reports the stable source-attributed do-while-placement diagnostic for a non-prefix do-while."}, {"fixture": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} ] } diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index 4e5d639..7f1fb55 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -58,6 +58,10 @@ repository: | `issue-46-unsupported` | #46 negative probe: a dict-indexed assignment the compiler rejects with the stable source-attributed diagnostic while the oracle compiles it | | `issue-47-control-flow` | #47 pinned oracle-backed control-flow lowering probe: if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break | | `issue-47-unsupported` | #47 negative probe: a break hidden inside a conditional switch arm is accepted by the frontend/oracle but rejected by the compiler with a stable source-attributed diagnostic | +| `issue-47-switch-order` | #47 pinned oracle probe for a default arm before later case arms and source-order fallthrough | +| `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks; frontend preserves the source while the compiler reports the canonical multi-target WIR gap | +| `issue-47-do-while-shapes` | #47 pinned oracle probe for direct, conditional, and nested do-while break lowering | +| `issue-47-do-while-invalid-placement` | #47 pinned negative probe for the stable do-while placement diagnostic | | `issue-29-*` | directive/include/main-file preprocessing probes | | `issue-33-*` | switch break/fallthrough, f-string interpolation, and lambda negative probes | | `receiver-playervar` | bare variable member expression `A = B.C` with preserved receiver/member provenance | diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/fixture.json b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/fixture.json new file mode 100644 index 0000000..5959dc9 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-do-while-invalid-placement", + "category": "diagnostics", + "features": ["doWhile", "placement-diagnostic"], + "source": "source.opy", + "expectedStatus": "failure", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned do-while placement diagnostic probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/oracle.json b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/oracle.json new file mode 100644 index 0000000..9fb9951 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/oracle.json @@ -0,0 +1,32 @@ +{ + "compile": { + "diagnostics": [ + { + "severity": "error", + "text": "Error: Do/While loops can only be at the beginning of a rule: parent is '__rule__' and childIndex is 1\n | line 6, col 5, at source.opy" + } + ], + "exitCode": 1, + "status": "failure", + "stdout": "", + "workshop": "", + "workshopExact": "", + "workshopSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fixture": "synthetic/issue-47-do-while-invalid-placement", + "input": { + "sha256": "b7df07df9799afec1c139cd0a23764804feeaccf7aae8dc7d9be575f37c1e5c7", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/source.opy b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/source.opy new file mode 100644 index 0000000..4d09dda --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/source.opy @@ -0,0 +1,8 @@ +globalvar value + +rule "issue 47 invalid do while placement": + @Event global + value = 1 + do: + value += 1 + while value < 2 diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-shapes/fixture.json b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/fixture.json new file mode 100644 index 0000000..863397e --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-do-while-shapes", + "category": "compilation", + "features": ["doWhile", "break", "nested-doWhile"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned do-while break-shape lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-shapes/oracle.json b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/oracle.json new file mode 100644 index 0000000..57eff64 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 do while direct\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_2__:\n }\n}\n\nrule (\"issue 47 do while conditional\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip If(Compare(Global.value, ==, 1), 2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_3__:\n }\n}\n\nrule (\"issue 47 do while nested\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(1);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_4__:\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n }\n}\n\nrule (\"issue 47 do while structured tail\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(5);\n If(Compare(Global.value, ==, 1));\n Modify Global Variable(value, Add, 2);\n End;\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_5__:\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 do while direct\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_2__:\n }\n}\n\nrule (\"issue 47 do while conditional\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip If(Compare(Global.value, ==, 1), 2);\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_3__:\n }\n}\n\nrule (\"issue 47 do while nested\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(1);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_4__:\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n }\n}\n\nrule (\"issue 47 do while structured tail\") {\n event {\n Ongoing - Global;\n }\n actions {\n Modify Global Variable(value, Add, 1);\n Skip(5);\n If(Compare(Global.value, ==, 1));\n Modify Global Variable(value, Add, 2);\n End;\n Modify Global Variable(value, Add, 10);\n Loop If(Compare(Global.value, <, 2));\n //__label_break_5__:\n }\n}\n\n", + "workshopSha256": "7e211d3d3e0f2c2e386fea86cbc33ba28c060529354ae91147f0d4983fcbb613" + }, + "fixture": "synthetic/issue-47-do-while-shapes", + "input": { + "sha256": "6e0baa620c74b51b98ecb2576a353bcae83f335bd50ba904c73347f8b87b9a9c", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-do-while-shapes/source.opy b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/source.opy new file mode 100644 index 0000000..bb475cc --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-do-while-shapes/source.opy @@ -0,0 +1,38 @@ +globalvar value + +rule "issue 47 do while direct": + @Event global + do: + value += 1 + break + value += 10 + while value < 2 + +rule "issue 47 do while conditional": + @Event global + do: + value += 1 + if value == 1: + break + value += 10 + while value < 2 + +rule "issue 47 do while nested": + @Event global + do: + do: + value += 1 + break + while value < 2 + value += 10 + while value < 2 + +rule "issue 47 do while structured tail": + @Event global + do: + value += 1 + break + if value == 1: + value += 2 + value += 10 + while value < 2 diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json new file mode 100644 index 0000000..928f843 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-switch-multiple-break", + "category": "compilation", + "features": ["switch", "case", "default", "break"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned multi-break lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/oracle.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/oracle.json new file mode 100644 index 0000000..bc6407c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch multiple break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(5, 0, 2, 4), Add(1, Index Of Array Value(Array(1, 2, 3), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, 1);\n Else;\n //__label_switch_2_1__:\n Set Global Variable(value, 2);\n Else;\n //__label_switch_2_2__:\n Set Global Variable(value, 3);\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch multiple break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(5, 0, 2, 4), Add(1, Index Of Array Value(Array(1, 2, 3), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, 1);\n Else;\n //__label_switch_2_1__:\n Set Global Variable(value, 2);\n Else;\n //__label_switch_2_2__:\n Set Global Variable(value, 3);\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n }\n}\n\n", + "workshopSha256": "17fcd581475f835de2682061f86ccae110747783a6751e391301709b22130657" + }, + "fixture": "synthetic/issue-47-switch-multiple-break", + "input": { + "sha256": "e800e97e748ab8ed301e14064e88f115b74fd9953052f5b2108b510d084fedb3", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/source.opy b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/source.opy new file mode 100644 index 0000000..b7879bf --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/source.opy @@ -0,0 +1,15 @@ +globalvar value + +rule "issue 47 switch multiple break": + @Event global + switch value: + case 1: + value = 1 + break + case 2: + value = 2 + break + case 3: + value = 3 + default: + value = 4 diff --git a/compatibility/fixtures/synthetic/issue-47-switch-order/fixture.json b/compatibility/fixtures/synthetic/issue-47-switch-order/fixture.json new file mode 100644 index 0000000..d5d783b --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-order/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-switch-order", + "category": "compilation", + "features": ["switch", "case", "default", "fallthrough"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned switch-order lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-order/oracle.json b/compatibility/fixtures/synthetic/issue-47-switch-order/oracle.json new file mode 100644 index 0000000..4a030e8 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-order/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch order\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(0, 1, 2), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n //__label_switch_2_0__:\n Set Global Variable(value, 1);\n //__label_switch_2_1__:\n Set Global Variable(value, 2);\n End;\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch order\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(0, 1, 2), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n //__label_switch_2_0__:\n Set Global Variable(value, 1);\n //__label_switch_2_1__:\n Set Global Variable(value, 2);\n End;\n }\n}\n\n", + "workshopSha256": "2aaa4c6147c02a89121f2cb58f89a669fa46a04dd5b9e08c06db209b0e2c3823" + }, + "fixture": "synthetic/issue-47-switch-order", + "input": { + "sha256": "b45056da5e473dbfea1ba2de6731359872be6439d146ffbf6360781ead967f12", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-order/source.opy b/compatibility/fixtures/synthetic/issue-47-switch-order/source.opy new file mode 100644 index 0000000..586a388 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-order/source.opy @@ -0,0 +1,11 @@ +globalvar value + +rule "issue 47 switch order": + @Event global + switch value: + default: + value = 4 + case 1: + value = 1 + case 2: + value = 2 diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 88c9024..102a9b4 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -10,7 +10,7 @@ "snapshot": { "date": "2026-08-24", "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 and #40 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The differential corpus currently reports 0 unexpected divergences and 0 inconclusive results; its exact match and known-gap counts are generated evidence, not a support claim. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", - "asOfCommit": "6956eed025536a201f769402306eb28090a5135d" + "asOfCommit": "279209455e80a5f2569167d4151a20b13940473f" }, "states": { "planned": "Declared surface, not yet implemented in opy-rs; evidence/provenance recorded for the implementing issue.", @@ -682,11 +682,15 @@ "evidence": [ "fixtures:synthetic/issue-47-control-flow", "fixtures:synthetic/issue-47-unsupported", + "fixtures:synthetic/issue-47-switch-order", + "fixtures:synthetic/issue-47-switch-multiple-break", + "fixtures:synthetic/issue-47-do-while-shapes", + "fixtures:synthetic/issue-47-do-while-invalid-placement", "fixtures:synthetic/issue-33-switch-break", "test:opy-compiler-issue-47-oracle-equivalence", "contract:workshop-rs-v0.1.8" ], - "notes": "Issue #47. If/elif/else, while, supported range-for, do-while expansion, switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. The nested switch shape is oracle-equivalent; a break hidden inside a conditional switch arm remains a stable source-attributed unsupported-integration-surface diagnostic because the current canonical WIR has no equivalent structured break carrier." + "notes": "Issue #47. If/elif/else, while, global-binder range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Direct, conditional, and nested do-while break shapes are pinned and oracle-equivalent. A nested conditional switch break and a multi-break switch with later reachable actions remain stable source-attributed unsupported-integration-surface diagnostics because workshop-rs v0.1.8 has no lossless multi-target switch carrier." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 5569a5f..a32b668 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use opy_frontend::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, default_var_index}; +use opy_frontend::hir::{ + self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, default_var_index, +}; use opy_frontend::manifest::{FunctionKind, Manifest}; use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale}; use workshop_rs::source::{Position as WorkshopPosition, SourceFile, Span as WorkshopSpan}; @@ -246,6 +248,9 @@ enum BreakTarget { Switch, } +type SwitchBreak = (usize, HirSpan); +type LoweredSwitchBody = (Vec, Option); + impl<'a> Lowering<'a> { fn new(compiler: &'a Compiler, hir: &'a hir::Program) -> Result { Ok(Self { @@ -1041,10 +1046,9 @@ impl<'a> Lowering<'a> { } Stmt::Switch { value, - cases, - r#default, + arms, span, - } => self.lower_switch(value, cases, r#default.as_deref(), *span).map(|action| vec![action]), + } => self.lower_switch(value, arms, *span).map(|action| vec![action]), Stmt::Break { span } => match break_target { Some(BreakTarget::Loop) => Ok(vec![self.wir.actions.push(Action::Call { name: "break".to_string(), @@ -1127,7 +1131,7 @@ impl<'a> Lowering<'a> { if direct_break || conditional_break { let tail = self.lower_do_while_body(&statements[index + 1..])?; - let distance = tail.len() + 1; + let distance = self.normalized_action_width(&tail)? + 1; let (name, args, span) = if let Stmt::Break { span } = statement { ("skip", Vec::new(), *span) } else if let Stmt::If { branches, span, .. } = statement { @@ -1211,28 +1215,59 @@ impl<'a> Lowering<'a> { fn lower_switch( &mut self, value: &Expr, - cases: &[hir::SwitchCase], - default: Option<&[Stmt]>, + arms: &[SwitchArm], span: Option, ) -> Result { let selector = self.lower_value(value)?; - let mut case_values = Vec::with_capacity(cases.len()); - let mut lowered_cases = Vec::with_capacity(cases.len()); - let mut offsets = Vec::with_capacity(cases.len() + 1); + let mut case_values = Vec::new(); + let mut lowered_arms = Vec::with_capacity(arms.len()); + let mut case_offsets = Vec::new(); let mut offset = 0usize; + let mut default_offset = None; - for case in cases { - case_values.push(self.lower_value(&case.value)?); - let (body, breaks) = self.lower_switch_body(&case.body)?; - offsets.push(offset); - offset += body.len() + usize::from(breaks); - lowered_cases.push((body, breaks)); + for arm in arms { + let (value, (body, break_at)) = match arm { + SwitchArm::Case { value, body, .. } => { + case_values.push(self.lower_value(value)?); + (Some(value), self.lower_switch_body(body)?) + } + SwitchArm::Default { body, span } => { + if default_offset.is_some() { + return Err( + self.unsupported("a switch may contain at most one default arm", *span) + ); + } + default_offset = Some(offset); + (None, self.lower_switch_body(body)?) + } + }; + if value.is_some() { + case_offsets.push(offset); + } + offset += self.normalized_action_width(&body)? + usize::from(break_at.is_some()); + lowered_arms.push((value, body, break_at)); } - let default_offset = offset; + let default_offset = default_offset.unwrap_or(offset); - let default_body = default - .map(|body| self.lower_switch_body(body).map(|(actions, _)| actions)) - .transpose()?; + let break_arms: Vec<_> = lowered_arms + .iter() + .enumerate() + .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at))) + .collect(); + if break_arms.len() > 1 { + let (first_index, first_break) = break_arms[0]; + let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0 + || lowered_arms + .iter() + .skip(first_index + 1) + .any(|(_, body, _)| !body.is_empty()); + if has_actions_after_first { + return Err(self.unsupported( + "multiple switch breaks with later reachable actions require canonical switch targets", + Some(break_arms[1].1.1), + )); + } + } let case_values = self.wir.values.push(ValueNode::new( Value::Array(case_values), @@ -1240,7 +1275,7 @@ impl<'a> Lowering<'a> { )); let value_span = self.wir_span(span)?; let offset_values = std::iter::once(default_offset) - .chain(offsets) + .chain(case_offsets) .map(|value| { self.wir.values.push(ValueNode::new( Value::Number { @@ -1293,32 +1328,28 @@ impl<'a> Lowering<'a> { .values .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + let first_break = break_arms.first().copied(); let mut branch_body = vec![skip]; - let mut else_body = None; - for (body, breaks) in lowered_cases { - if else_body.is_none() { - branch_body.extend(body); - if breaks { - else_body = Some(Vec::new()); - } - } else if let Some(tail) = &mut else_body { - tail.extend(body); - // A second direct break is a structural Else in the pinned - // oracle. The canonical WIR has one else carrier, so the - // first boundary is the representable semantic boundary and - // later unreachable boundaries contribute no action. - if breaks { - break; + let else_body = if let Some((break_index, (break_at, _))) = first_break { + for (index, (_, body, _)) in lowered_arms.iter().enumerate() { + if index < break_index { + branch_body.extend(body.iter().copied()); + } else if index == break_index { + branch_body.extend(body[..break_at].iter().copied()); } } - } - if let Some(tail) = &mut else_body { - if let Some(default_body) = default_body { - tail.extend(default_body); + let mut tail = Vec::new(); + tail.extend(lowered_arms[break_index].1[break_at..].iter().copied()); + for (_, body, _) in lowered_arms.iter().skip(break_index + 1) { + tail.extend(body.iter().copied()); } + Some(tail) } else { - else_body = default_body; - } + for (_, body, _) in &lowered_arms { + branch_body.extend(body.iter().copied()); + } + None + }; Ok(self.wir.actions.push(Action::If { branches: vec![wir::IfBranch { @@ -1333,15 +1364,77 @@ impl<'a> Lowering<'a> { fn lower_switch_body( &mut self, statements: &[Stmt], - ) -> Result<(Vec, bool), IntegrationError> { + ) -> Result { let mut actions = Vec::new(); + let mut break_at = None; for statement in statements { - if matches!(statement, Stmt::Break { .. }) { - return Ok((actions, true)); + if let Stmt::Break { span } = statement { + if break_at.is_some() { + return Err(self.unsupported( + "multiple switch breaks in one arm require canonical switch targets", + *span, + )); + } + break_at = Some(( + actions.len(), + span.ok_or_else(|| { + self.unsupported("switch break is missing source provenance", None) + })?, + )); + continue; } actions.extend(self.lower_action(statement, Some(BreakTarget::Switch))?); } - Ok((actions, false)) + Ok((actions, break_at)) + } + + /// Count the Workshop action lines emitted by canonical WIR, including + /// structural headers and terminators. This is the normalization used by + /// jump-producing source constructs; arena-node counts are not offsets. + fn normalized_action_width( + &self, + actions: &[wir::ActionId], + ) -> Result { + actions.iter().try_fold(0usize, |total, id| { + let action = self + .wir + .actions + .get(*id) + .ok_or_else(|| self.unsupported("canonical WIR action id is invalid", None))?; + let width = match action { + Action::If { + branches, + else_body, + .. + } => { + let branches_width = branches.iter().try_fold(0usize, |width, branch| { + Ok::<_, IntegrationError>( + width + 1 + self.normalized_action_width(&branch.body)?, + ) + })?; + branches_width + + else_body + .as_ref() + .map(|body| self.normalized_action_width(body).map(|width| width + 1)) + .transpose()? + .unwrap_or(0) + + 1 + } + Action::While { body, .. } + | Action::ForGlobalVariable { body, .. } + | Action::ForPlayerVariable { body, .. } => self.normalized_action_width(body)? + 2, + Action::SetGlobalVariable { .. } + | Action::ModifyGlobalVariable { .. } + | Action::SetPlayerVariable { .. } + | Action::ModifyPlayerVariable { .. } + | Action::AssignMember { .. } + | Action::CallSubroutine { .. } + | Action::Debug { .. } + | Action::Print { .. } + | Action::Call { .. } => 1, + }; + Ok(total + width) + }) } fn lower_assign( @@ -2036,37 +2129,36 @@ fn collect_implicit_stmts( ); collect_implicit_stmts(body, declared_globals, declared_players, globals, players); } - Stmt::Switch { - value, - cases, - r#default, - .. - } => { + Stmt::Switch { value, arms, .. } => { collect_implicit_expr(value, declared_globals, declared_players, globals, players); - for case in cases { - collect_implicit_expr( - &case.value, - declared_globals, - declared_players, - globals, - players, - ); - collect_implicit_stmts( - &case.body, - declared_globals, - declared_players, - globals, - players, - ); - } - if let Some(default) = r#default { - collect_implicit_stmts( - default, - declared_globals, - declared_players, - globals, - players, - ); + for arm in arms { + match arm { + SwitchArm::Case { value, body, .. } => { + collect_implicit_expr( + value, + declared_globals, + declared_players, + globals, + players, + ); + collect_implicit_stmts( + body, + declared_globals, + declared_players, + globals, + players, + ); + } + SwitchArm::Default { body, .. } => { + collect_implicit_stmts( + body, + declared_globals, + declared_players, + globals, + players, + ); + } + } } } Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} diff --git a/crates/opy-compiler/tests/issue_47_oracle.rs b/crates/opy-compiler/tests/issue_47_oracle.rs index 68fcb94..15d7aa8 100644 --- a/crates/opy-compiler/tests/issue_47_oracle.rs +++ b/crates/opy-compiler/tests/issue_47_oracle.rs @@ -59,6 +59,66 @@ fn issue_47_nested_switch_break_matches_the_pinned_oracle() { ); } +#[test] +fn issue_47_switch_order_matches_the_pinned_oracle() { + let dir = fixture_dir("issue-47-switch-order"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::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 native = workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap(); + let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); + + assert!( + equivalent(&native, &oracle), + "switch order diverged\n{}", + artifact.emitted + ); +} + +#[test] +fn issue_47_do_while_break_shapes_match_the_pinned_oracle() { + let dir = fixture_dir("issue-47-do-while-shapes"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::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 native = workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap(); + let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); + + assert!( + equivalent(&native, &oracle), + "do-while break shapes diverged\n{}", + artifact.emitted + ); +} + +#[test] +fn issue_47_multiple_switch_breaks_are_not_silently_dropped() { + let compiler = Compiler::new().unwrap(); + let dir = fixture_dir("issue-47-switch-multiple-break"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::compile(&source, "source.opy", &dir).unwrap(); + let error = match compiler.compile_hir(&hir) { + Ok(_) => panic!("multi-break switch must not be silently truncated"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); + assert_eq!(error.diagnostic.span.unwrap().start.line, 11); +} + +#[test] +fn issue_47_invalid_do_while_placement_is_source_attributed() { + let dir = fixture_dir("issue-47-do-while-invalid-placement"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let error = opy_frontend::compile(&source, "source.opy", &dir) + .expect_err("invalid do-while placement must be rejected"); + assert_eq!(error.code, "do-while-placement"); + assert_eq!(error.span.unwrap().start.line, 6); +} + #[test] fn issue_47_nested_switch_break_is_source_attributed_when_not_representable() { let compiler = Compiler::new().unwrap(); diff --git a/crates/opy-frontend/src/cst.rs b/crates/opy-frontend/src/cst.rs index 6b18313..4c1ed4c 100644 --- a/crates/opy-frontend/src/cst.rs +++ b/crates/opy-frontend/src/cst.rs @@ -194,8 +194,7 @@ pub enum Stmt { }, Switch { value: Expr, - cases: Vec, - r#default: Option>, + arms: Vec, span: Span, }, Break { @@ -206,12 +205,18 @@ pub enum Stmt { }, } -/// One `case value:` arm in a switch statement. +/// One source-ordered arm in a switch statement. #[derive(Debug, Clone)] -pub struct SwitchCase { - pub value: Expr, - pub body: Vec, - pub span: Span, +pub enum SwitchArm { + Case { + value: Expr, + body: Vec, + span: Span, + }, + Default { + body: Vec, + span: Span, + }, } /// One condition/body pair of an `if`. @@ -362,6 +367,23 @@ impl Expr { } } +impl Stmt { + /// The source span of this statement. + pub fn span(&self) -> Span { + match self { + Stmt::Expr { span, .. } + | Stmt::Assign { span, .. } + | Stmt::If { span, .. } + | Stmt::For { span, .. } + | Stmt::While { span, .. } + | Stmt::DoWhile { span, .. } + | Stmt::Switch { span, .. } + | Stmt::Break { span } + | Stmt::Pass { span } => *span, + } + } +} + impl CallArg { /// The source span of this argument: the keyword name when keyword, the /// value expression otherwise. diff --git a/crates/opy-frontend/src/hir/dump.rs b/crates/opy-frontend/src/hir/dump.rs index d71c679..0c1274a 100644 --- a/crates/opy-frontend/src/hir/dump.rs +++ b/crates/opy-frontend/src/hir/dump.rs @@ -4,7 +4,9 @@ //! issue reports. It is not part of the wire contract: the same validated //! payload always produces the same dump, in payload order. -use super::types::{Declaration, Event, Expr, Program, Rule, RuleEntry, SettingsNode, Span, Stmt}; +use super::types::{ + Declaration, Event, Expr, Program, Rule, RuleEntry, SettingsNode, Span, Stmt, SwitchArm, +}; /// Render a validated program as a deterministic text dump. pub fn dump(program: &Program) -> String { @@ -286,24 +288,27 @@ fn dump_stmt(statement: &Stmt, out: &mut String, level: usize) { render_expr(condition, out); out.push('\n'); } - Stmt::Switch { - value, - cases, - r#default, - span, - } => { + Stmt::Switch { value, arms, span } => { out.push_str(&format!("{}switch ", indent(level))); render_expr(value, out); out.push_str(&format!("{}\n", span_suffix(span.as_ref()))); - for case in cases { - out.push_str(&format!("{}case ", indent(level + 1))); - render_expr(&case.value, out); - out.push_str(&format!("{}\n", span_suffix(case.span.as_ref()))); - dump_stmts(&case.body, out, level + 2); - } - if let Some(default_body) = r#default { - out.push_str(&format!("{}default\n", indent(level + 1))); - dump_stmts(default_body, out, level + 2); + for arm in arms { + match arm { + SwitchArm::Case { value, body, span } => { + out.push_str(&format!("{}case ", indent(level + 1))); + render_expr(value, out); + out.push_str(&format!("{}\n", span_suffix(span.as_ref()))); + dump_stmts(body, out, level + 2); + } + SwitchArm::Default { body, span } => { + out.push_str(&format!( + "{}default{}\n", + indent(level + 1), + span_suffix(span.as_ref()) + )); + dump_stmts(body, out, level + 2); + } + } } } Stmt::Break { span } => { diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index 3d94cfa..f6e7ce7 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -21,7 +21,7 @@ pub use types::{ Annotation, AnnotationArg, Declaration, DictEntry, DirectiveRecord, DirectiveValue, Event, Expr, Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState, Program, Protocol, Rule, RuleEntry, Settings, SettingsListElement, SettingsNode, SourceFile, - Span, Stmt, SwitchCase, TranslationState, default_var_index, + Span, Stmt, SwitchArm, TranslationState, default_var_index, }; use serde_json::Value; diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index 10f0718..ad2a76f 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -452,9 +452,7 @@ pub enum Stmt { Switch { value: Box, #[serde(default)] - cases: Vec, - #[serde(default, rename = "default")] - r#default: Option>, + arms: Vec, #[serde(skip_serializing_if = "Option::is_none")] span: Option, }, @@ -499,15 +497,24 @@ impl Stmt { } } -/// One switch case in the OPY HIR. Cases execute in source order and fall -/// through to subsequent cases until a `break` statement is encountered. +/// One source-ordered arm in the OPY HIR. Arms execute in source order and +/// fall through to subsequent arms until a `break` statement is encountered. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SwitchCase { - pub value: Box, - #[serde(default)] - pub body: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub span: Option, +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SwitchArm { + Case { + value: Box, + #[serde(default)] + body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + Default { + #[serde(default)] + body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, } /// An expression. diff --git a/crates/opy-frontend/src/hir/validate.rs b/crates/opy-frontend/src/hir/validate.rs index 3f4e040..a502e07 100644 --- a/crates/opy-frontend/src/hir/validate.rs +++ b/crates/opy-frontend/src/hir/validate.rs @@ -21,7 +21,7 @@ use serde_json::Value; use super::error::{HirError, invalid}; use super::types::{ Declaration, Expr, PROTOCOL_MAJOR, PROTOCOL_NAME, Position, Program, Rule, RuleEntry, Settings, - SettingsNode, Span, Stmt, default_var_index, + SettingsNode, Span, Stmt, SwitchArm, default_var_index, }; /// Declaration `kind` values understood by this consumer. @@ -425,6 +425,37 @@ fn validate_stmts( *span, )), }, + Stmt::Switch { arms, .. } => { + if arms.is_empty() { + errors.push(invalid( + "invalid-structure", + "a switch must contain at least one arm", + None, + )); + } + let mut defaults = 0; + for arm in arms { + let arm_span = match arm { + SwitchArm::Case { span, body, .. } | SwitchArm::Default { span, body } => { + let _ = body; + span + } + }; + if let Err(error) = check_span(*arm_span, program.files.len()) { + errors.push(error); + } + if matches!(arm, SwitchArm::Default { .. }) { + defaults += 1; + if defaults > 1 { + errors.push(invalid( + "invalid-structure", + "a switch may contain at most one default arm", + *arm_span, + )); + } + } + } + } _ => {} } }); @@ -532,19 +563,18 @@ fn statement_exprs(statements: &[Stmt]) -> Vec<&Expr> { exprs.push(condition.as_ref()); exprs.extend(statement_exprs(body)); } - Stmt::Switch { - value, - cases, - r#default, - .. - } => { + Stmt::Switch { value, arms, .. } => { exprs.push(value.as_ref()); - for case in cases { - exprs.push(case.value.as_ref()); - exprs.extend(statement_exprs(&case.body)); - } - if let Some(default_body) = r#default { - exprs.extend(statement_exprs(default_body)); + for arm in arms { + match arm { + SwitchArm::Case { value, body, .. } => { + exprs.push(value.as_ref()); + exprs.extend(statement_exprs(body)); + } + SwitchArm::Default { body, .. } => { + exprs.extend(statement_exprs(body)); + } + } } } Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} @@ -632,14 +662,13 @@ fn for_each_stmt<'a>(statements: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { Stmt::For { body, .. } | Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { for_each_stmt(body, f) } - Stmt::Switch { - cases, r#default, .. - } => { - for case in cases { - for_each_stmt(&case.body, f); - } - if let Some(default_body) = r#default { - for_each_stmt(default_body, f); + Stmt::Switch { arms, .. } => { + for arm in arms { + match arm { + SwitchArm::Case { body, .. } | SwitchArm::Default { body, .. } => { + for_each_stmt(body, f); + } + } } } Stmt::Expr { .. } @@ -811,12 +840,22 @@ fn check_stmt(value: &Value) -> Result<(), HirError> { } } } - if let Some(cases) = object.get("cases").and_then(Value::as_array) { - for case in cases { - if let Some(value) = case.get("value") { - check_expr(value)?; + if let Some(arms) = object.get("arms").and_then(Value::as_array) { + for arm in arms { + let Some(arm_object) = arm.as_object() else { + return Err(unsupported_node("", object)); + }; + let arm_kind = arm_object.get("kind").and_then(Value::as_str).unwrap_or(""); + match arm_kind { + "case" => { + if let Some(value) = arm_object.get("value") { + check_expr(value)?; + } + } + "default" => {} + _ => return Err(unsupported_node(arm_kind, arm_object)), } - if let Some(body) = case.get("body").and_then(Value::as_array) { + if let Some(body) = arm_object.get("body").and_then(Value::as_array) { for statement in body { check_stmt(statement)?; } diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 40d23a5..ad99c9a 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -32,7 +32,7 @@ use crate::hir::types::{ DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry, Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan, Stmt as HirStmt, - SwitchCase as HirSwitchCase, default_var_index, + SwitchArm as HirSwitchArm, default_var_index, }; use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt}; @@ -211,7 +211,7 @@ pub fn lower_with_preprocessing( source_name: name.clone(), span: Some(span.into()), name_span: Some(name_span.into()), - body: lowerer.lower_block(body, &[], false), + body: lowerer.lower_block(body, &[], false, true), annotations: lower_annotations(annotations), }); } @@ -619,7 +619,7 @@ impl Lowerer { .iter() .map(|condition| self.lower_expr(condition, &[], CallPosition::Value)) .collect(); - let actions = self.lower_block(&rule.actions, &[], false); + let actions = self.lower_block(&rule.actions, &[], false, true); Ok(Rule { name: render_rule_name( &rule.name, @@ -656,10 +656,26 @@ impl Lowerer { stmts: &[Stmt], macro_params: &[String], breakable: bool, + allow_do_while: bool, ) -> Vec { stmts .iter() - .map(|stmt| self.lower_stmt(stmt, macro_params, breakable)) + .enumerate() + .map(|(index, stmt)| { + if matches!(stmt, Stmt::DoWhile { .. }) + && (!allow_do_while + || stmts[..index] + .iter() + .any(|previous| !matches!(previous, Stmt::Pass { .. }))) + { + self.error_at( + "do-while-placement", + "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it".to_string(), + stmt.span(), + ); + } + self.lower_stmt(stmt, macro_params, breakable) + }) .collect() } @@ -705,12 +721,12 @@ impl Lowerer { macro_params, CallPosition::Value, )), - body: self.lower_block(&branch.body, macro_params, breakable), + body: self.lower_block(&branch.body, macro_params, breakable, false), }) .collect(), r#else: r#else .as_ref() - .map(|body| self.lower_block(body, macro_params, breakable)), + .map(|body| self.lower_block(body, macro_params, breakable, false)), span: Some(span.into()), }, Stmt::For { @@ -740,7 +756,7 @@ impl Lowerer { CallPosition::Value, )), iterable: Box::new(self.lower_expr(iterable, macro_params, iterable_position)), - body: self.lower_block(body, macro_params, true), + body: self.lower_block(body, macro_params, true, false), span: Some(span.into()), } } @@ -750,7 +766,7 @@ impl Lowerer { span, } => HirStmt::While { condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), - body: self.lower_block(body, macro_params, true), + body: self.lower_block(body, macro_params, true, false), span: Some(span.into()), }, Stmt::DoWhile { @@ -759,31 +775,29 @@ impl Lowerer { span, } => HirStmt::DoWhile { condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), - body: self.lower_block(body, macro_params, true), + body: self.lower_block(body, macro_params, true, true), span: Some(span.into()), }, - Stmt::Switch { - value, - cases, - r#default, - span, - } => HirStmt::Switch { + Stmt::Switch { value, arms, span } => HirStmt::Switch { value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)), - cases: cases + arms: arms .iter() - .map(|case| HirSwitchCase { - value: Box::new(self.lower_expr( - &case.value, - macro_params, - CallPosition::Value, - )), - body: self.lower_block(&case.body, macro_params, true), - span: Some(case.span.into()), + .map(|arm| match arm { + cst::SwitchArm::Case { value, body, span } => HirSwitchArm::Case { + value: Box::new(self.lower_expr( + value, + macro_params, + CallPosition::Value, + )), + body: self.lower_block(body, macro_params, true, false), + span: Some((*span).into()), + }, + cst::SwitchArm::Default { body, span } => HirSwitchArm::Default { + body: self.lower_block(body, macro_params, true, false), + span: Some((*span).into()), + }, }) .collect(), - r#default: r#default - .as_ref() - .map(|body| self.lower_block(body, macro_params, true)), span: Some(span.into()), }, Stmt::Break { span } => { @@ -805,7 +819,7 @@ impl Lowerer { } fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec { - self.lower_block(body, params, false) + self.lower_block(body, params, false, false) } fn lower_expr( @@ -2866,36 +2880,47 @@ mod tests { #[test] fn issue_28_constructs_lower_to_provenance_preserving_hir() { let hir = lower_ok( - "globalvar x\nrule \"r\":\n @Event global\n switch x:\n case 0x10:\n x = 1 in [1, 2]\n default:\n do:\n x = {\"x\": 1}[\"x\"]\n while x not in [2, 3]\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", + "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", ); let (_, actions) = rule_conditions_and_actions(&hir); - let HirStmt::Switch { - cases, r#default, .. - } = &actions[0] - else { + let HirStmt::DoWhile { condition, .. } = &actions[0] else { + panic!("expected do-while"); + }; + assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in")); + let HirStmt::Switch { arms, .. } = &actions[1] else { panic!("expected switch"); }; - assert_eq!(cases.len(), 1); - assert!(r#default.is_some()); - let HirStmt::Assign { value, .. } = &cases[0].body[0] else { + assert_eq!(arms.len(), 2); + let HirSwitchArm::Case { + value: case_value, + body, + .. + } = &arms[0] + else { + panic!("expected case arm"); + }; + assert!( + matches!(case_value.as_ref(), HirExpr::Number { value, .. } if *value == 0x10 as f64) + ); + let HirStmt::Assign { value, .. } = &body[0] else { panic!("expected case assignment"); }; assert!(matches!(value.as_ref(), HirExpr::Binary { op, .. } if op == "in")); - let HirStmt::DoWhile { condition, .. } = &r#default.as_ref().unwrap()[0] else { - panic!("expected do-while"); + let HirSwitchArm::Default { body, .. } = &arms[1] else { + panic!("expected default arm"); }; - assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in")); - let HirStmt::Assign { value, .. } = &actions[1] else { + assert!(matches!(body[0], HirStmt::Assign { .. })); + let HirStmt::Assign { value, .. } = &actions[2] else { panic!("expected comprehension assignment"); }; assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. })); - let HirStmt::Assign { value, .. } = &actions[2] else { + let HirStmt::Assign { value, .. } = &actions[3] else { panic!("expected sorted assignment"); }; assert!( 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"))) ); - let HirStmt::Assign { value, .. } = &actions[3] else { + let HirStmt::Assign { value, .. } = &actions[4] else { panic!("expected string assignment"); }; assert!( @@ -2916,4 +2941,17 @@ mod tests { ); assert_eq!(lambda_error.code, "lambda-context"); } + + #[test] + fn do_while_requires_rule_or_definition_prefix_position() { + let error = compile_error( + "globalvar value\nrule \"r\":\n @Event global\n value = 1\n do:\n value += 1\n while value < 2\n", + 5, + ); + assert_eq!(error.code, "do-while-placement"); + assert_eq!( + error.message, + "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it" + ); + } } diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index 5c88baa..abec6f4 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -9,7 +9,7 @@ use crate::cst::{ Annotation, AnnotationArg, CallArg, Decl, DictEntry, Event, Expr, IfBranch, Program, Rule, - RuleEntry, Stmt, SwitchCase, + RuleEntry, Stmt, SwitchArm, }; use crate::diag::{FrontendError, Position, Span}; use crate::lexer::{Token, TokenKind}; @@ -1096,8 +1096,7 @@ impl Parser<'_> { return Err(()); } let body_indent = self.block_indent(start.span.start.col).ok_or(())?; - let mut cases = Vec::new(); - let mut r#default = None; + let mut arms = Vec::new(); loop { self.skip_newlines(); if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent { @@ -1119,7 +1118,7 @@ impl Parser<'_> { } let case_body_indent = self.block_indent(body_indent).ok_or(())?; let body = self.parse_block(case_body_indent); - cases.push(SwitchCase { + arms.push(SwitchArm::Case { value: case_value, body, span: case_start.span, @@ -1133,7 +1132,10 @@ impl Parser<'_> { return Err(()); } let default_body_indent = self.block_indent(body_indent).ok_or(())?; - r#default = Some(self.parse_block(default_body_indent)); + arms.push(SwitchArm::Default { + body: self.parse_block(default_body_indent), + span: default_start.span, + }); if default_start.span.start.col != body_indent { self.error_at_current("invalid default indentation".to_string()); return Err(()); @@ -1143,7 +1145,7 @@ impl Parser<'_> { self.recover_line(); } } - if cases.is_empty() && r#default.is_none() { + if arms.is_empty() { self.errors.push(FrontendError::at( "parse-error", "switch must contain at least one case or default arm".to_string(), @@ -1153,8 +1155,7 @@ impl Parser<'_> { } Ok(Stmt::Switch { value, - cases, - r#default, + arms, span: start.span, }) } diff --git a/crates/opy-frontend/src/tooling.rs b/crates/opy-frontend/src/tooling.rs index 85f822b..6565f64 100644 --- a/crates/opy-frontend/src/tooling.rs +++ b/crates/opy-frontend/src/tooling.rs @@ -655,22 +655,21 @@ impl SemanticModel { Self::collect_stmt(stmt, sites); } } - HirStmt::Switch { - value, - cases, - r#default, - .. - } => { + HirStmt::Switch { value, arms, .. } => { Self::collect_expr(value, sites); - for case in cases { - Self::collect_expr(&case.value, sites); - for stmt in &case.body { - Self::collect_stmt(stmt, sites); - } - } - if let Some(default_body) = r#default { - for stmt in default_body { - Self::collect_stmt(stmt, sites); + for arm in arms { + match arm { + hir::SwitchArm::Case { value, body, .. } => { + Self::collect_expr(value, sites); + for stmt in body { + Self::collect_stmt(stmt, sites); + } + } + hir::SwitchArm::Default { body, .. } => { + for stmt in body { + Self::collect_stmt(stmt, sites); + } + } } } } diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index bec8d97..344aa97 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -223,6 +223,30 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { false, "Issue #47 negative probe; the frontend preserves the nested conditional switch-break HIR while the compiler rejects it at the canonical WIR integration boundary.", ); + resolve( + &mut cases, + "synthetic/issue-47-switch-order", + false, + "Issue #47 source-order switch probe; default-before-case fallthrough remains represented in ordered HIR arms.", + ); + resolve( + &mut cases, + "synthetic/issue-47-switch-multiple-break", + false, + "Issue #47 multi-break probe; the frontend preserves all authored arms and breaks while the compiler reports the canonical WIR capability gap.", + ); + resolve( + &mut cases, + "synthetic/issue-47-do-while-shapes", + false, + "Issue #47 do-while probe; direct, conditional, and nested break shapes resolve in the frontend and are constrained by compiler oracle tests.", + ); + diagnostic( + &mut cases, + "synthetic/issue-47-do-while-invalid-placement", + Some("do-while-placement"), + "Issue #47 invalid do-while placement remains a stable source-attributed frontend diagnostic.", + ); diagnostic( &mut cases, "synthetic/issue-33-lambda-negative", diff --git a/docs/hir/opy-hir-v1.md b/docs/hir/opy-hir-v1.md index 81bb769..084e15c 100644 --- a/docs/hir/opy-hir-v1.md +++ b/docs/hir/opy-hir-v1.md @@ -315,7 +315,7 @@ whose `kind` the consumer does not recognize is an *unsupported node* (§7.3). | `for` | `variable`, `iterable`, `body`, `span` | Iteration. `variable` is an expression naming the loop variable (a `globalVar` reference). | | `while` | `condition`, `body`, `span` | Loop. | | `doWhile` | `body`, `condition`, `span` | Loop whose body executes before its condition. | -| `switch` | `value`, `cases`, `default`, `span` | Source-order arms; execution falls through until a `break` or the end of the switch. | +| `switch` | `value`, `arms`, `span` | Source-order arms; execution falls through until a `break` or the end of the switch. Each arm is tagged `case` or `default`; a case has `value` and `body`, while a default has `body`, and each arm may carry `span`. At most one default arm is valid. | | `break` | `span` | Exit the innermost switch or loop; invalid contexts are rejected by the frontend. | | `callSubroutine` | `name`, `span` | Call a subroutine by name. | | `pass` | `span` | A no-op emitted by the frontend. | diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index f29e8ec..9a0aa53 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -63,9 +63,13 @@ Workshop-independent up to the documented integration boundary toward | `compatibility/fixtures/synthetic/issue-46-unsupported/` | #46 negative primitive-lowering evidence: the frontend and pinned oracle accept a dict-indexed assignment while the compiler rejects it with the stable source-attributed `unsupported-integration-surface` diagnostic | | `compatibility/fixtures/synthetic/issue-47-control-flow/` | #47 pinned OverPy oracle evidence for if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break lowering; native output is reparsed through `workshop-rs` and compared structurally | | `compatibility/fixtures/synthetic/issue-47-unsupported/` | #47 negative evidence: nested conditional switch-break is preserved by the frontend/oracle and rejected at the canonical WIR boundary with a source-attributed diagnostic | -| `crates/opy-compiler/src/lib.rs` structural tests | #40/#46 declarations, subroutines, rules, event filters, assignments, expressions, indexing, format, pass, and source-attributed negative lowering evidence | +| `compatibility/fixtures/synthetic/issue-47-switch-order/` | #47 pinned oracle evidence for default-before-case source order and fallthrough; native output is reparsed through `workshop-rs` and compared structurally | +| `compatibility/fixtures/synthetic/issue-47-switch-multiple-break/` | #47 multi-break evidence: frontend/oracle preserve the source, while later reachable actions are rejected explicitly at the canonical WIR boundary because v0.1.8 has no lossless multi-target switch carrier | +| `compatibility/fixtures/synthetic/issue-47-do-while-shapes/` | #47 pinned oracle evidence for direct, conditional, nested, and structured-tail do-while breaks; native output is reparsed through `workshop-rs` and compared structurally | +| `compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/` | #47 negative evidence for the stable source-attributed do-while placement diagnostic | +| `crates/opy-compiler/src/lib.rs` structural tests | #40/#46/#47 declarations, subroutines, rules, event filters, assignments, expressions, indexing, format, pass, control-flow lowering, and source-attributed negative lowering evidence | | `crates/opy-compiler/tests/issue_46_oracle.rs` | #46 oracle-constrained differential equivalence: native output and the pinned oracle Workshop text both reparse through the canonical `workshop-rs` parser and must satisfy `roundtrip::equivalent` | -| `crates/opy-compiler/tests/issue_47_oracle.rs` | #47 oracle-constrained control-flow equivalence plus a nested switch-break negative diagnostic | +| `crates/opy-compiler/tests/issue_47_oracle.rs` | #47 oracle-constrained control-flow equivalence for ordered switches and do-while break shapes, plus source-attributed negative diagnostics for unsupported switch targets and invalid do-while placement | | `compatibility/support-matrix.json` | Machine-readable state tracking of every declared feature (the mechanically checkable artifact) | | `crates/opy-frontend/src/manifest/` | The opy-rs-owned semantic compatibility manifest and its oracle probes (ported with the frontend, issue #3/#4) | | `crates/opy-frontend/tests/differential.rs` + `compatibility/diff.py` | Native-vs-reference differential parity (issue #7): the rust suite runs every corpus fixture through the native pipeline in `cargo test` (no Node), compares status/rule-name evidence against the recorded `oracle.json` snapshots, and writes `target/opy-differential-report.json` | @@ -91,9 +95,13 @@ implements; "reference" always means the pinned OverPy 9.7.10 real HIR statement valid in the innermost switch or loop. - `do ... while`, hexadecimal literals, and expression-level `in`/`not in` are represented in the source-language HIR. The #47 integration slice lowers - if/elif/else, while, range-for, do-while, switch fallthrough/default, and - direct loop/switch break into canonical WIR; an evidence-insufficient nested - conditional switch break remains a source-attributed diagnostic. + if/elif/else, while, global-binder range-for, do-while, switch + fallthrough/default, and direct loop/switch break into canonical WIR. Do-while + is accepted only at the beginning of a rule/definition or nested do-while + body (preceded only by `pass`). Multiple switch breaks with later reachable + actions, and an evidence-insufficient nested conditional switch break, remain + source-attributed diagnostics because the pinned canonical WIR has no + lossless multi-target switch carrier. - String modifiers, including f-string interpolation, preserve semantic format text, interpolation expressions, and source spans; dict literals, keyed access, list comprehensions, and lambda binders preserve local scope. From 826cf6051ce6d0662606ef55c0796150e345c297 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 24 Aug 2026 16:10:54 +0800 Subject: [PATCH 3/5] docs(opy): refresh issue 47 support provenance Record the issue 47 support-matrix scope and the exact commit that materialized its evidence. --- compatibility/support-matrix.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 102a9b4..e93c09e 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -9,8 +9,8 @@ }, "snapshot": { "date": "2026-08-24", - "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 and #40 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The differential corpus currently reports 0 unexpected divergences and 0 inconclusive results; its exact match and known-gap counts are generated evidence, not a support claim. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", - "asOfCommit": "279209455e80a5f2569167d4151a20b13940473f" + "note": "Readiness baseline for #7 through the #28/#33 Draft PR series plus the bounded #35, #40, #46, and #47 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The differential corpus currently reports 0 unexpected divergences and 0 inconclusive results; its exact match and known-gap counts are generated evidence, not a support claim. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", + "asOfCommit": "bff5c796d1c1492ce5111f83ad93c00272b533bd" }, "states": { "planned": "Declared surface, not yet implemented in opy-rs; evidence/provenance recorded for the implementing issue.", From c5714a4028464f19f236f54d390e7b7237e434bd Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 25 Aug 2026 02:14:50 +0800 Subject: [PATCH 4/5] fix(opy): consume canonical control-flow layout Rebaseline workshop-rs to v0.1.11 and route switch and do-while distances through its action_width API. Add structured switch oracle evidence and migrate the ordered switch-arm HIR grammar to protocol v2 with explicit v1 rejection. Fixes #47 --- Cargo.lock | 4 +- Cargo.toml | 2 +- compatibility/README.md | 2 +- compatibility/differential-expectations.json | 3 +- compatibility/fixtures/README.md | 1 + .../fixture.json | 14 ++++ .../oracle.json | 27 +++++++ .../source.opy | 14 ++++ compatibility/support-matrix.json | 23 +++--- crates/opy-compiler/src/lib.rs | 70 +++++++------------ crates/opy-compiler/tests/issue_47_oracle.rs | 18 +++++ crates/opy-frontend/src/hir/dump.rs | 2 +- crates/opy-frontend/src/hir/error.rs | 4 +- crates/opy-frontend/src/hir/mod.rs | 34 +++++++-- crates/opy-frontend/src/hir/types.rs | 10 +-- crates/opy-frontend/src/hir/validate.rs | 6 +- crates/opy-frontend/src/lib.rs | 6 +- crates/opy-frontend/src/lower.rs | 23 ++++-- crates/opy-frontend/tests/differential.rs | 8 ++- docs/README.md | 3 +- docs/compatibility/upstream-references.md | 4 +- docs/hir/opy-hir-v2.md | 37 ++++++++++ docs/opy/architecture.md | 2 +- docs/opy/support-matrix.md | 12 ++-- 24 files changed, 230 insertions(+), 99 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-structured-target/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-structured-target/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-47-switch-structured-target/source.opy create mode 100644 docs/hir/opy-hir-v2.md diff --git a/Cargo.lock b/Cargo.lock index 22b7087..cde06dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,9 +633,9 @@ dependencies = [ [[package]] name = "workshop-rs" -version = "0.1.8" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8b6c25a292fbb06742c158966a628de20aecc543e218ddfef03219c431aad4" +checksum = "699bb027b5882d50d182cf84f982a2ef0ca2b3a9f3f600b740f23afed1432d98" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 35b4c69..b303716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/wrightkit/opy-rs" opy-frontend = { path = "crates/opy-frontend" } serde = "1" serde_json = "1" -workshop-rs = "=0.1.8" +workshop-rs = "=0.1.11" [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/compatibility/README.md b/compatibility/README.md index ae65281..b32308b 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -111,7 +111,7 @@ cargo test -p opy-frontend --test differential ``` It compiles every fixture through the native pipeline (preprocess → parse → -lower), verifies the Opy HIR v1 structure (validation, wire round-trip, +lower), verifies the Opy HIR v2 structure (validation, wire round-trip, deterministic dump), and compares the outcome against the recorded `oracle.json` evidence: status parity (resolve vs expected diagnostic), ordered authored rule names (normalized to drop reference-synthesized diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index b5ca893..258672f 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -53,7 +53,8 @@ {"fixture": "synthetic/issue-47-control-flow", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-control-flow/oracle.json", "test:opy-compiler-issue-47-oracle-equivalence"], "note": "The #47 control-flow slice resolves in the frontend and lowers to canonical WIR with structural equivalence to the pinned oracle; direct loop/switch break and the nested switch shape are independently covered by opy-compiler tests."}, {"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 frontend 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", "test:opy-compiler-issue-47-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the frontend; native and pinned Workshop both reparse to equivalent canonical WIR."}, - {"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 frontend 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.8 has one canonical else carrier."}, + {"fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-structured-target/oracle.json", "test:opy-compiler-issue-47-structured-switch-target"], "note": "The #47 structured switch probe places nested if/while actions in an earlier arm and verifies later case/default targets against canonical emitted action widths through native/oracle WIR equivalence."}, + {"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 frontend 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 after native and oracle reparsing through workshop-rs."}, {"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 frontend reports the stable source-attributed do-while-placement diagnostic for a non-prefix do-while."}, {"fixture": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index 7f1fb55..ccc34df 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -59,6 +59,7 @@ repository: | `issue-47-control-flow` | #47 pinned oracle-backed control-flow lowering probe: if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break | | `issue-47-unsupported` | #47 negative probe: a break hidden inside a conditional switch arm is accepted by the frontend/oracle but rejected by the compiler with a stable source-attributed diagnostic | | `issue-47-switch-order` | #47 pinned oracle probe for a default arm before later case arms and source-order fallthrough | +| `issue-47-switch-structured-target` | #47 pinned oracle probe for nested if/while structure in an earlier arm and later case/default targets | | `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks; frontend preserves the source while the compiler reports the canonical multi-target WIR gap | | `issue-47-do-while-shapes` | #47 pinned oracle probe for direct, conditional, and nested do-while break lowering | | `issue-47-do-while-invalid-placement` | #47 pinned negative probe for the stable do-while placement diagnostic | diff --git a/compatibility/fixtures/synthetic/issue-47-switch-structured-target/fixture.json b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/fixture.json new file mode 100644 index 0000000..c2e5658 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-47-switch-structured-target", + "category": "compilation", + "features": ["switch", "case", "default", "nested-structured-control-flow"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #47 pinned structured switch-target lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-structured-target/oracle.json b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/oracle.json new file mode 100644 index 0000000..2766680 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch structured target\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(7, 0, 6), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_0__:\n If(Compare(Global.value, ==, 1));\n Set Global Variable(value, 2);\n End;\n While(Compare(Global.value, <, 0));\n Modify Global Variable(value, Add, 1);\n End;\n //__label_switch_2_1__:\n Set Global Variable(value, 3);\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch structured target\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(7, 0, 6), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_0__:\n If(Compare(Global.value, ==, 1));\n Set Global Variable(value, 2);\n End;\n While(Compare(Global.value, <, 0));\n Modify Global Variable(value, Add, 1);\n End;\n //__label_switch_2_1__:\n Set Global Variable(value, 3);\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n }\n}\n\n", + "workshopSha256": "71ce885c694a2a546a54b36682e613486a37ddc28d6505c89db5f7a92f5b08bc" + }, + "fixture": "synthetic/issue-47-switch-structured-target", + "input": { + "sha256": "80a1020848684a5a9703f47f9da0085050ceef9a856ad01bfae8018fc88cfb2b", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-47-switch-structured-target/source.opy b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/source.opy new file mode 100644 index 0000000..c7c8bed --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-structured-target/source.opy @@ -0,0 +1,14 @@ +globalvar value + +rule "issue 47 switch structured target": + @Event global + switch value: + case 1: + if value == 1: + value = 2 + while value < 0: + value += 1 + case 2: + value = 3 + default: + value = 4 diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index e93c09e..cc05d02 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -590,13 +590,13 @@ }, { "id": "compilation/frontend-pipeline", - "name": "Frontend pipeline: lexer -> preprocess -> CST/parser -> semantic resolution -> Opy HIR v1", + "name": "Frontend pipeline: lexer -> preprocess -> CST/parser -> semantic resolution -> Opy HIR v2", "category": "compilation", "state": "frontend-supported", "evidence": [ "fixtures:synthetic", "fixtures:real-world", - "docs:docs/hir/opy-hir-v1.md", + "docs:docs/hir/opy-hir-v2.md", "test:opy-frontend-differential" ], "notes": "Issues #3-#7, #25, and the #28/#29/#30/#33 readiness tracks. Fully Workshop-independent; the pinned differential corpus is the acceptance corpus. Differential harness runs every fixture through the native pipeline in cargo test with structural self-checks (HIR validation, wire round-trip, deterministic dump), status/rule-name parity against recorded oracle.json snapshots, explicit native evidence expectations, and a machine-readable report (target/opy-differential-report.json). The generated report records per-fixture matches and known gaps; no unexpected divergence or inconclusive result is a support claim." @@ -609,9 +609,9 @@ "evidence": [ "fixtures:synthetic/issue-35-integration", "test:opy-compiler-vertical-slice", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], - "notes": "Issue #35. The dedicated opy-compiler crate consumes resolved OPY HIR, preserves source files/spans in workshop-rs WIR, cross-checks manifest catalogId/domain links against Catalog, validates canonical WIR, and emits deterministic en-US Workshop through the published workshop-rs v0.1.8 contract. This is a bounded first slice; broader OPY lowering remains lowering-dependent and is not reclassified by this fixture." + "notes": "Issue #35. The dedicated opy-compiler crate consumes resolved OPY HIR, preserves source files/spans in workshop-rs WIR, cross-checks manifest catalogId/domain links against Catalog, validates canonical WIR, and emits deterministic en-US Workshop through the published workshop-rs v0.1.11 contract. This is a bounded first slice; broader OPY lowering remains lowering-dependent and is not reclassified by this fixture." }, { "id": "compilation/opy-structural-lowering", @@ -622,7 +622,7 @@ "fixtures:synthetic/issue-35-integration", "fixtures:synthetic/issue-40-structural", "test:opy-compiler-structural-lowering", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], "notes": "Issue #40. Global/player variables and subroutines use deterministic canonical indices with source/name spans; subroutine definitions and calls, rule disabled state, conditions, supported event/filter identities, and source-file provenance lower directly into workshop-rs WIR. Unsupported settings, declarations, annotations, events, filters, and statement forms remain explicit source-attributed integration diagnostics. This does not claim full statement/expression, catalog/member/enum, settings, locale, optimizer, hook, or decompilation coverage." }, @@ -635,7 +635,7 @@ "fixtures:synthetic/issue-46-primitives", "test:opy-compiler-primitive-lowering", "test:opy-compiler-issue-46-oracle-equivalence", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], "notes": "Issue #46. Global/player assignments and augmented assignments (+=, -=, *=, /=, %=, **=), single-level indexed variable assignments and modifications (setGlobalVariableAtIndex, modifyGlobalVariableAtIndex, setPlayerVariableAtIndex, modifyPlayerVariableAtIndex), and pass no-op statements lower into canonical workshop-rs WIR. Declaration initializer evidence covers non-null initializers; OverPy null-default initializer semantics remain tracked by #58. Deeper indexed writes remain tracked by #60. Direct global/player assignments and modifications carry full statement and target-variable span provenance; the single-level indexed forms lower to canonical Call actions, which carry only the statement-level source span (the canonical WIR Call shape has no separate target-span field)." }, @@ -647,7 +647,7 @@ "evidence": [ "fixtures:synthetic/issue-46-primitives", "test:opy-compiler-implicit-default-variables", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], "notes": "Issue #46. Undeclared A-Z, AA-AZ, ..., DA-DX global references and eventPlayer. player references resolve through the frontend default_var_index contract and lower to separate Workshop variable namespaces at their fixed reference slots (A=0 ... DX=127); used implicit slots are reserved independently for declared global/player allocation, an explicit declared index colliding with a used implicit slot fails with the stable source-attributed index-collision diagnostic, and a declared name wins in its own namespace. The pinned oracle snapshot for the issue-46 fixture constrains global and player slots through structural equivalence." }, @@ -670,7 +670,7 @@ "evidence": [ "fixtures:synthetic/issue-46-primitives", "test:opy-compiler-primitive-lowering", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], "notes": "Issue #46. Literals (numbers, strings, booleans, null), arrays, vectors (vect), enum constants, global/player/eventPlayer variable references, array indexing (valueInArray; a literal zero index lowers to firstOf, matching the pinned oracle), string formatting (customString), arithmetic/comparison/logical binary operations, unary operations (not, -; not on a comparison lowers to the negated comparison, matching the pinned oracle), and power operations lower into canonical workshop-rs WIR. Value-position power uses the canonical raiseToPower Value contract; direct and indexed power modifications use the canonical Workshop Operation contract." }, @@ -683,14 +683,15 @@ "fixtures:synthetic/issue-47-control-flow", "fixtures:synthetic/issue-47-unsupported", "fixtures:synthetic/issue-47-switch-order", + "fixtures:synthetic/issue-47-switch-structured-target", "fixtures:synthetic/issue-47-switch-multiple-break", "fixtures:synthetic/issue-47-do-while-shapes", "fixtures:synthetic/issue-47-do-while-invalid-placement", "fixtures:synthetic/issue-33-switch-break", "test:opy-compiler-issue-47-oracle-equivalence", - "contract:workshop-rs-v0.1.8" + "contract:workshop-rs-v0.1.11" ], - "notes": "Issue #47. If/elif/else, while, global-binder range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Direct, conditional, and nested do-while break shapes are pinned and oracle-equivalent. A nested conditional switch break and a multi-break switch with later reachable actions remain stable source-attributed unsupported-integration-surface diagnostics because workshop-rs v0.1.8 has no lossless multi-target switch carrier." + "notes": "Issue #47. If/elif/else, while, global-binder range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Switch offsets and do-while distances use workshop-rs v0.1.11 canonical emitted action widths; direct, conditional, nested, and structured-tail do-while shapes plus structured switch targets are pinned and oracle-equivalent. A nested conditional switch break and a multi-break switch with later reachable actions remain stable source-attributed unsupported-integration-surface diagnostics because workshop-rs v0.1.11 has no lossless multi-target switch carrier." }, { "id": "compilation/workshop-lowering", @@ -701,7 +702,7 @@ "fixtures:synthetic", "fixtures:real-world" ], - "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.8 public contract now supports the bounded #35 adapter, the #40 structural skeleton, and the separately evidenced #47 control-flow slice; full OPY statement/expression lowering, catalog/member/enum breadth, settings/content, locales, optimizer effects, and hooks remain lowering-dependent follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." + "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.11 public contract now supports the bounded #35 adapter, the #40 structural skeleton, and the separately evidenced #47 control-flow slice; full OPY statement/expression lowering, catalog/member/enum breadth, settings/content, locales, optimizer effects, and hooks remain lowering-dependent follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." }, { "id": "compilation/end-to-end", diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index a32b668..65e7252 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -1,7 +1,7 @@ //! The first OPY-to-Workshop integration boundary. //! //! `opy-frontend` remains a standalone OPY/HIR producer. This crate is the -//! consumer-owned compiler layer: it pins the released `workshop-rs` v0.1.8 +//! consumer-owned compiler layer: it pins the released `workshop-rs` v0.1.11 //! contract, checks the OPY manifest links against the canonical catalog, and //! lowers the supported OPY program structure into canonical WIR before //! validation and deterministic Workshop emission. @@ -17,7 +17,7 @@ use workshop_rs::source::{Position as WorkshopPosition, SourceFile, Span as Work use workshop_rs::wir::{self, Action, Event, PlayerEventKind, Program, Value, ValueNode}; /// The exact released dependency contract consumed by this crate. -pub const WORKSHOP_RS_VERSION: &str = "0.1.8"; +pub const WORKSHOP_RS_VERSION: &str = "0.1.11"; /// A source-attributed integration diagnostic. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1131,7 +1131,7 @@ impl<'a> Lowering<'a> { if direct_break || conditional_break { let tail = self.lower_do_while_body(&statements[index + 1..])?; - let distance = self.normalized_action_width(&tail)? + 1; + let distance = self.canonical_action_width(&tail, statement.span().copied())? + 1; let (name, args, span) = if let Stmt::Break { span } = statement { ("skip", Vec::new(), *span) } else if let Stmt::If { branches, span, .. } = statement { @@ -1244,7 +1244,7 @@ impl<'a> Lowering<'a> { if value.is_some() { case_offsets.push(offset); } - offset += self.normalized_action_width(&body)? + usize::from(break_at.is_some()); + offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some()); lowered_arms.push((value, body, break_at)); } let default_offset = default_offset.unwrap_or(offset); @@ -1388,52 +1388,30 @@ impl<'a> Lowering<'a> { Ok((actions, break_at)) } - /// Count the Workshop action lines emitted by canonical WIR, including - /// structural headers and terminators. This is the normalization used by - /// jump-producing source constructs; arena-node counts are not offsets. - fn normalized_action_width( + /// Query the Workshop-owned native action layout for a relative jump. + fn canonical_action_width( &self, actions: &[wir::ActionId], + fallback_span: Option, ) -> Result { - actions.iter().try_fold(0usize, |total, id| { - let action = self - .wir - .actions - .get(*id) - .ok_or_else(|| self.unsupported("canonical WIR action id is invalid", None))?; - let width = match action { - Action::If { - branches, - else_body, - .. - } => { - let branches_width = branches.iter().try_fold(0usize, |width, branch| { - Ok::<_, IntegrationError>( - width + 1 + self.normalized_action_width(&branch.body)?, - ) - })?; - branches_width - + else_body - .as_ref() - .map(|body| self.normalized_action_width(body).map(|width| width + 1)) - .transpose()? - .unwrap_or(0) - + 1 + workshop_rs::emitter::action_width( + &self.wir, + &self.compiler.catalog, + &Locale::new("en-US"), + actions, + ) + .map(|layout| layout.width) + .map_err(|error| { + let workshop_span = match &error { + workshop_rs::emitter::ActionLayoutError::InvalidWIR(error) => error.span(), + workshop_rs::emitter::ActionLayoutError::Emission(error) => { + workshop_error_span(error) } - Action::While { body, .. } - | Action::ForGlobalVariable { body, .. } - | Action::ForPlayerVariable { body, .. } => self.normalized_action_width(body)? + 2, - Action::SetGlobalVariable { .. } - | Action::ModifyGlobalVariable { .. } - | Action::SetPlayerVariable { .. } - | Action::ModifyPlayerVariable { .. } - | Action::AssignMember { .. } - | Action::CallSubroutine { .. } - | Action::Debug { .. } - | Action::Print { .. } - | Action::Call { .. } => 1, }; - Ok(total + width) + let span = workshop_span + .and_then(|span| self.hir_span_from_workshop(span)) + .or(fallback_span); + IntegrationError::new("workshop-action-layout", error.to_string(), span) }) } @@ -2485,7 +2463,7 @@ mod tests { assert_eq!(rule.span.unwrap().file.index(), 0); assert_eq!(rule.name_span.unwrap().start.line, 2); assert!(artifact.emitted.contains("Disable Inspector Recording;")); - assert_eq!(artifact.catalog_identity.implementation_version, "0.1.8"); + assert_eq!(artifact.catalog_identity.implementation_version, "0.1.11"); } #[test] diff --git a/crates/opy-compiler/tests/issue_47_oracle.rs b/crates/opy-compiler/tests/issue_47_oracle.rs index 15d7aa8..2eb84cd 100644 --- a/crates/opy-compiler/tests/issue_47_oracle.rs +++ b/crates/opy-compiler/tests/issue_47_oracle.rs @@ -77,6 +77,24 @@ fn issue_47_switch_order_matches_the_pinned_oracle() { ); } +#[test] +fn issue_47_structured_switch_target_matches_the_pinned_oracle() { + let dir = fixture_dir("issue-47-switch-structured-target"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = opy_frontend::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 native = workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap(); + let oracle = workshop_rs::parser::parse(&oracle_workshop(&dir), &catalog, &locale).unwrap(); + + assert!( + equivalent(&native, &oracle), + "structured switch target diverged\n{}", + artifact.emitted + ); +} + #[test] fn issue_47_do_while_break_shapes_match_the_pinned_oracle() { let dir = fixture_dir("issue-47-do-while-shapes"); diff --git a/crates/opy-frontend/src/hir/dump.rs b/crates/opy-frontend/src/hir/dump.rs index 0c1274a..54c60fc 100644 --- a/crates/opy-frontend/src/hir/dump.rs +++ b/crates/opy-frontend/src/hir/dump.rs @@ -1,4 +1,4 @@ -//! Deterministic debug/pretty dump for Opy HIR v1 programs. +//! Deterministic debug/pretty dump for Opy HIR v2 programs. //! //! The dump is a stable, human-readable rendering intended for tests and //! issue reports. It is not part of the wire contract: the same validated diff --git a/crates/opy-frontend/src/hir/error.rs b/crates/opy-frontend/src/hir/error.rs index 54e69bc..837e27b 100644 --- a/crates/opy-frontend/src/hir/error.rs +++ b/crates/opy-frontend/src/hir/error.rs @@ -1,4 +1,4 @@ -//! Structured errors for Opy HIR v1 ingestion. +//! Structured errors for Opy HIR v2 ingestion. //! //! Every failure carries a stable code, a message, and — when the offending //! source position is known — a span. Human-readable wording is not part of @@ -6,7 +6,7 @@ use crate::hir::types::Span; -/// A structured Opy HIR v1 ingestion error. +/// A structured Opy HIR v2 ingestion error. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HirError { /// The payload is not valid JSON or does not fit the protocol envelope. diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index f6e7ce7..d6fab65 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -1,9 +1,7 @@ -//! Opy HIR v1 — the OPY semantic model owned by `opy-rs`. +//! Opy HIR v2 — the OPY semantic model owned by `opy-rs`. //! -//! The wire contract is the `wright/opy-hir` protocol, major version 1 -//! (produced as `1.1.0`), specified in the WrightKit HIR protocol document -//! (`docs/hir/opy-hir-v1.md` in the wright repository; the opy-rs docs -//! workstream re-homes the specification). This module provides the serde +//! The wire contract is the `wright/opy-hir` protocol, major version 2 +//! (produced as `2.0.0`), specified in `docs/hir/opy-hir-v2.md`. This module provides the serde //! protocol types, envelope and structural validation, and a deterministic //! debug dump. //! @@ -26,7 +24,7 @@ pub use types::{ use serde_json::Value; -/// Parse and validate an Opy HIR v1 payload from a JSON string. +/// Parse and validate an Opy HIR v2 payload from a JSON string. /// /// Returns a structured [`HirError`] for malformed JSON, an unsupported /// protocol identity or major version, unknown node kinds, or invariant @@ -36,7 +34,7 @@ pub fn parse_str(input: &str) -> Result { parse_value(value) } -/// Parse and validate an Opy HIR v1 payload from a JSON value. +/// Parse and validate an Opy HIR v2 payload from a JSON value. pub fn parse_value(value: Value) -> Result { validate::check_envelope(&value)?; validate::check_unknown_kinds(&value)?; @@ -59,3 +57,25 @@ impl Program { dump::dump(self) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::validate::check_envelope; + + #[test] + fn v2_envelope_is_accepted_and_v1_is_rejected_before_body_inspection() { + let v2 = json!({ + "protocol": { "name": "wright/opy-hir", "version": "2.0.0" } + }); + assert!(check_envelope(&v2).is_ok()); + + let v1 = json!({ + "protocol": { "name": "wright/opy-hir", "version": "1.1.0" }, + "rules": [{ "kind": "malformed-v1-body" }] + }); + let error = check_envelope(&v1).expect_err("v1 payload must not enter the v2 parser"); + assert_eq!(error.code(), "incompatible-protocol"); + } +} diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index ad2a76f..f9921b9 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -1,7 +1,7 @@ -//! Serde protocol types for the `wright/opy-hir` protocol, major version 1. +//! Serde protocol types for the `wright/opy-hir` protocol, major version 2. //! -//! These types mirror the Opy HIR v1 specification (`docs/hir/opy-hir-v1.md`, -//! `wright/opy-hir` v1.1.0 wire payloads). Unknown +//! These types mirror the Opy HIR v2 specification (`docs/hir/opy-hir-v2.md`, +//! `wright/opy-hir` v2.0.0 wire payloads). Unknown //! fields on known nodes are tolerated so an additive producer change inside //! the same major version does not break the consumer; unknown node *kinds* //! are rejected during validation (see [`super::validate`]). @@ -11,7 +11,9 @@ use serde::{Deserialize, Serialize}; /// The `wright/opy-hir` protocol name. pub const PROTOCOL_NAME: &str = "wright/opy-hir"; /// The protocol major version this consumer understands. -pub const PROTOCOL_MAJOR: u32 = 1; +pub const PROTOCOL_MAJOR: u32 = 2; +/// The protocol version emitted by this producer. +pub const PROTOCOL_VERSION: &str = "2.0.0"; /// The number of Workshop variable slots per variable set. /// diff --git a/crates/opy-frontend/src/hir/validate.rs b/crates/opy-frontend/src/hir/validate.rs index a502e07..5ff2de1 100644 --- a/crates/opy-frontend/src/hir/validate.rs +++ b/crates/opy-frontend/src/hir/validate.rs @@ -1,7 +1,7 @@ -//! Opy HIR v1 validation. +//! Opy HIR v2 validation. //! -//! Validation follows the order of the Opy HIR v1 specification (`docs/hir/ -//! opy-hir-v1.md` in the wright repository) §8: the protocol envelope is +//! Validation follows the order of the Opy HIR v2 specification (`docs/hir/ +//! opy-hir-v2.md`) §8: the protocol envelope is //! checked first (in [`super::parse_value`]), then unknown node kinds are //! rejected with the offending kind name and span, then the payload is //! deserialized, then structural invariants (spans, identifiers, references) diff --git a/crates/opy-frontend/src/lib.rs b/crates/opy-frontend/src/lib.rs index a3595e6..22c9512 100644 --- a/crates/opy-frontend/src/lib.rs +++ b/crates/opy-frontend/src/lib.rs @@ -84,10 +84,8 @@ mod tests { /// The frontend's supported protocol identity for generated HIR. /// /// The producer identity and the Opy HIR protocol envelope (`wright/opy-hir` -/// v1) are preserved from the Wright frontend so existing consumers keep -/// accepting this producer's payloads during the migration; renaming the -/// producer identity is a protocol decision for the opy-rs docs/architecture -/// workstream. +/// v2) is emitted for the ordered switch-arm wire grammar; v1 consumers must +/// reject it until they migrate to the v2 contract. pub const FRONTEND_NAME: &str = "wright/opy-native"; pub const FRONTEND_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index ad99c9a..abc9c59 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -29,10 +29,10 @@ use std::collections::{HashMap, HashSet}; use crate::hir::types::{ Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define, - DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, Position, - PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry, Settings as HirSettings, - SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan, Stmt as HirStmt, - SwitchArm as HirSwitchArm, default_var_index, + DictEntry as HirDictEntry, Event, Expr as HirExpr, Generator, IfBranch, PROTOCOL_VERSION, + Position, PreprocessingState, Program as HirProgram, Protocol, Rule, RuleEntry, + Settings as HirSettings, SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan, + Stmt as HirStmt, SwitchArm as HirSwitchArm, default_var_index, }; use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt}; @@ -43,7 +43,6 @@ use crate::manifest::{ /// The protocol envelope this frontend produces. const PROTOCOL_NAME: &str = "wright/opy-hir"; -const PROTOCOL_VERSION: &str = "1.1.0"; /// The call-position context of an expression being lowered; builtin /// resolution checks action/value identity against this context. @@ -1916,6 +1915,20 @@ mod tests { (&rule.conditions, &rule.actions) } + #[test] + fn producer_emits_the_v2_ordered_switch_contract() { + let hir = lower_ok( + "globalvar value\nrule \"r\":\n @Event global\n switch value:\n default:\n value = 1\n case 2:\n value = 2\n", + ); + assert_eq!(hir.protocol.name, "wright/opy-hir"); + assert_eq!(hir.protocol.version, "2.0.0"); + let value = serde_json::to_value(&hir).expect("HIR must serialize"); + let switch = &value["rules"][0]["actions"][0]; + assert!(switch.get("arms").is_some()); + assert!(switch.get("cases").is_none()); + assert!(switch.get("default").is_none()); + } + #[test] fn receiver_calls_lower_to_receiver_call_hir() { // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 344aa97..ee037e9 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -22,7 +22,7 @@ //! silent failure; behavior that leaves the table is a `divergence` and //! fails the suite (regressions break CI, mirroring the wright contract). //! * **Structural self-check** (always runs) — a resolved program must pass -//! Opy HIR v1 validation, must round-trip through the wire payload +//! Opy HIR v2 validation, must round-trip through the wire payload //! (`parse_value(serde_json::to_value(program))`), and its debug dump must //! be deterministic. //! * **Rule-name parity** (informational) — the ordered authored rule names @@ -229,6 +229,12 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { false, "Issue #47 source-order switch probe; default-before-case fallthrough remains represented in ordered HIR arms.", ); + resolve( + &mut cases, + "synthetic/issue-47-switch-structured-target", + false, + "Issue #47 structured switch-target probe; nested canonical control-flow widths preserve later case/default targets.", + ); resolve( &mut cases, "synthetic/issue-47-switch-multiple-break", diff --git a/docs/README.md b/docs/README.md index 6c118c4..80efea6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,8 @@ architecture, compatibility evidence, APIs, and internal contracts live here. ## Internals -- [Opy HIR v1](hir/opy-hir-v1.md): semantic representation and wire contract. +- [Opy HIR v2](hir/opy-hir-v2.md): current semantic representation and wire contract. +- [Opy HIR v1](hir/opy-hir-v1.md): prior wire contract and migration baseline. - [Semantic compatibility manifest](opy/compat-manifest-spec.md): builtin, signature, alias, and catalog-link metadata owned by the OPY implementation. - [Tooling notes](opy/tooling-notes.md): focused implementation notes that do diff --git a/docs/compatibility/upstream-references.md b/docs/compatibility/upstream-references.md index e55e2d7..547a32e 100644 --- a/docs/compatibility/upstream-references.md +++ b/docs/compatibility/upstream-references.md @@ -70,8 +70,8 @@ it serves as: * the source of systematic probe validation for the proactive compatibility baseline (see [`docs/opy/compatibility-baseline.md`](../opy/compatibility-baseline.md) and [`docs/opy/compat-manifest-spec.md`](../opy/compat-manifest-spec.md)); -* the reference for differential parity at the Opy HIR v1 boundary - ([`docs/hir/opy-hir-v1.md`](../hir/opy-hir-v1.md)): the native differential +* the reference for differential parity at the Opy HIR v2 boundary + ([`docs/hir/opy-hir-v2.md`](../hir/opy-hir-v2.md)): the native differential suite (`crates/opy-frontend/tests/differential.rs`, merged in PR #13) runs every corpus fixture through the native pipeline in `cargo test` and compares status, rule-name, and diagnostic evidence against the recorded diff --git a/docs/hir/opy-hir-v2.md b/docs/hir/opy-hir-v2.md new file mode 100644 index 0000000..1ad8524 --- /dev/null +++ b/docs/hir/opy-hir-v2.md @@ -0,0 +1,37 @@ +# Opy HIR v2: ordered switch-arm protocol + +Status: accepted opy-rs-owned major migration for issue #47. + +The current wire contract is `wright/opy-hir@2.0.0`. It retains the Opy HIR +v1 payload shape and validation rules unless this document says otherwise. +The complete v1 baseline remains in [`opy-hir-v1.md`](opy-hir-v1.md). + +## Breaking change + +`Stmt::Switch` is serialized as: + +```json +{ + "kind": "switch", + "value": { "kind": "globalVar", "name": "selector" }, + "arms": [ + { "kind": "default", "body": [] }, + { "kind": "case", "value": { "kind": "number", "value": 1 }, "body": [] } + ] +} +``` + +`arms` is an ordered array. It replaces the v1 `cases` plus `default` +fields so default-before-case fallthrough is lossless. A v1 consumer must +reject a v2 payload at the protocol envelope before inspecting its body, and +the v2 consumer rejects protocol major 1 for the same reason. + +The producer emits `2.0.0`, and the opy-rs HIR parser validates major `2`. +The v1 and v2 grammars are not silently accepted under one version. + +## Consumer migration + +Every external `wright/opy-hir` consumer must migrate its protocol gate and +switch representation together. This opy-rs change does not modify another +repository's consumer; that coordination belongs to the owning consumer +repository. diff --git a/docs/opy/architecture.md b/docs/opy/architecture.md index b8665a6..4c30bc1 100644 --- a/docs/opy/architecture.md +++ b/docs/opy/architecture.md @@ -205,7 +205,7 @@ addition to focused tests. - [`../../compatibility/support-matrix.json`](../../compatibility/support-matrix.json) — machine-readable support state. - [`compat-manifest-spec.md`](compat-manifest-spec.md) — OPY semantic manifest. -- [`../hir/opy-hir-v1.md`](../hir/opy-hir-v1.md) — Opy HIR contract. +- [`../hir/opy-hir-v2.md`](../hir/opy-hir-v2.md) — current Opy HIR contract. - [`tooling-api.md`](tooling-api.md) — standalone semantic/tooling API. - [`../compatibility/upstream-references.md`](../compatibility/upstream-references.md) — pinned reference and provenance. diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 9a0aa53..4780245 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -25,7 +25,7 @@ machine-readable semantic contract for builtins is specified in The standalone frontend foundation and #7 readiness work are merged on `main` (issues #2–#7, #28–#30, and #33): the native pipeline (lexer → -preprocess → CST/parser → semantic resolution → Opy HIR v1), the bounded +preprocess → CST/parser → semantic resolution → Opy HIR v2), the bounded JavaScript macro runtime, the tooling API/CLI, and the native differential suite are implemented and CI-covered. The rows they evidence are flipped to `frontend-supported`/`semantic-supported` in @@ -44,8 +44,8 @@ expected-diagnostic / divergence) is recorded in --test differential`. The declared pipeline is `lexer → preprocess → CST/parser → semantic -resolution → OPY semantic model (Opy HIR v1, see -[`docs/hir/opy-hir-v1.md`](../hir/opy-hir-v1.md))`, fully +resolution → OPY semantic model (Opy HIR v2, see +[`docs/hir/opy-hir-v2.md`](../hir/opy-hir-v2.md))`, fully Workshop-independent up to the documented integration boundary toward `workshop-rs` (see [`architecture.md`](architecture.md)). @@ -64,7 +64,7 @@ Workshop-independent up to the documented integration boundary toward | `compatibility/fixtures/synthetic/issue-47-control-flow/` | #47 pinned OverPy oracle evidence for if/elif/else, while, range-for, do-while expansion, switch fallthrough/default, and direct break lowering; native output is reparsed through `workshop-rs` and compared structurally | | `compatibility/fixtures/synthetic/issue-47-unsupported/` | #47 negative evidence: nested conditional switch-break is preserved by the frontend/oracle and rejected at the canonical WIR boundary with a source-attributed diagnostic | | `compatibility/fixtures/synthetic/issue-47-switch-order/` | #47 pinned oracle evidence for default-before-case source order and fallthrough; native output is reparsed through `workshop-rs` and compared structurally | -| `compatibility/fixtures/synthetic/issue-47-switch-multiple-break/` | #47 multi-break evidence: frontend/oracle preserve the source, while later reachable actions are rejected explicitly at the canonical WIR boundary because v0.1.8 has no lossless multi-target switch carrier | +| `compatibility/fixtures/synthetic/issue-47-switch-multiple-break/` | #47 multi-break evidence: frontend/oracle preserve the source, while later reachable actions are rejected explicitly at the canonical WIR boundary because v0.1.11 has no lossless multi-target switch carrier | | `compatibility/fixtures/synthetic/issue-47-do-while-shapes/` | #47 pinned oracle evidence for direct, conditional, nested, and structured-tail do-while breaks; native output is reparsed through `workshop-rs` and compared structurally | | `compatibility/fixtures/synthetic/issue-47-do-while-invalid-placement/` | #47 negative evidence for the stable source-attributed do-while placement diagnostic | | `crates/opy-compiler/src/lib.rs` structural tests | #40/#46/#47 declarations, subroutines, rules, event filters, assignments, expressions, indexing, format, pass, control-flow lowering, and source-attributed negative lowering evidence | @@ -398,8 +398,8 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, ## Boundary contract -The frontend produces the Opy HIR v1 program model -([`docs/hir/opy-hir-v1.md`](../hir/opy-hir-v1.md)) with the protocol envelope, +The frontend produces the Opy HIR v2 program model +([`docs/hir/opy-hir-v2.md`](../hir/opy-hir-v2.md)) with the protocol envelope, file registry, declarations, and rules as specified there. It never requires Node or OverPy at build/runtime; the oracle remains available as an explicit `pnpm install --dir compatibility/oracle` step for the compatibility harness From 9faafff958882552930bea8face2baec34eb534d Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 25 Aug 2026 02:15:02 +0800 Subject: [PATCH 5/5] docs(opy): record issue 47 evidence head Point the support matrix at the verified canonical-layout and HIR-v2 implementation commit. Refs #47 --- compatibility/support-matrix.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index cc05d02..25e9376 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -10,7 +10,7 @@ "snapshot": { "date": "2026-08-24", "note": "Readiness baseline for #7 through the #28/#33 Draft PR series plus the bounded #35, #40, #46, and #47 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The differential corpus currently reports 0 unexpected divergences and 0 inconclusive results; its exact match and known-gap counts are generated evidence, not a support claim. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", - "asOfCommit": "bff5c796d1c1492ce5111f83ad93c00272b533bd" + "asOfCommit": "c5714a4028464f19f236f54d390e7b7237e434bd" }, "states": { "planned": "Declared surface, not yet implemented in opy-rs; evidence/provenance recorded for the implementing issue.",