From 4f2d36996a3986ab4015e562327f397e27c53b64 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:28:57 +0800 Subject: [PATCH 1/9] feat(opy): complete pure syntax surface (Fixes #28) --- compatibility/differential-expectations.json | 3 + .../issue-28-invalid-syntax/fixture.json | 14 + .../issue-28-invalid-syntax/oracle.json | 32 +++ .../issue-28-invalid-syntax/source.opy | 9 + .../issue-28-string-modifiers/fixture.json | 14 + .../issue-28-string-modifiers/oracle.json | 27 ++ .../issue-28-string-modifiers/source.opy | 8 + .../synthetic/issue-28-syntax/fixture.json | 23 ++ .../synthetic/issue-28-syntax/oracle.json | 27 ++ .../synthetic/issue-28-syntax/source.opy | 17 ++ compatibility/support-matrix.json | 86 +++++- crates/opy-frontend/src/cst.rs | 54 ++++ crates/opy-frontend/src/hir/dump.rs | 89 ++++++ crates/opy-frontend/src/hir/mod.rs | 7 +- crates/opy-frontend/src/hir/types.rs | 88 ++++++ crates/opy-frontend/src/hir/validate.rs | 93 ++++++- crates/opy-frontend/src/lexer.rs | 29 ++ crates/opy-frontend/src/lower.rs | 244 ++++++++++++++++- crates/opy-frontend/src/parser.rs | 256 +++++++++++++++++- crates/opy-frontend/src/preprocess.rs | 22 +- crates/opy-frontend/src/support.rs | 2 +- crates/opy-frontend/src/tooling.rs | 50 +++- crates/opy-frontend/tests/differential.rs | 18 ++ crates/opy-frontend/tests/tooling.rs | 6 +- docs/opy/support-matrix.md | 33 ++- 25 files changed, 1198 insertions(+), 53 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-28-invalid-syntax/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-28-invalid-syntax/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-28-invalid-syntax/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-28-string-modifiers/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-28-string-modifiers/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-28-string-modifiers/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-28-syntax/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-28-syntax/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-28-syntax/source.opy diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index f01a8d6..dc12ba4 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -9,6 +9,9 @@ "cases": [ {"fixture": "synthetic/basic-rule", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/basic-rule/oracle.json", "implementation-invariant:frontend-resolves-basic-rule"], "note": "Minimal rule is part of the Workshop-independent frontend contract."}, {"fixture": "synthetic/control-flow", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/control-flow/oracle.json", "implementation-invariant:frontend-resolves-control-flow"], "note": "If/elif/else, for-in-range, while, and pass resolve in the OPY semantic model."}, + {"fixture": "synthetic/issue-28-syntax", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-28-syntax/oracle.json", "implementation-invariant:issue-28-pure-syntax-hir"], "note": "Switch, do-while, hexadecimal literals, membership, dict indexing, comprehensions, lambda arguments, and f/w/b/c string modifiers resolve through the OPY HIR."}, + {"fixture": "synthetic/issue-28-string-modifiers", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-28-string-modifiers/oracle.json", "implementation-invariant:issue-28-string-modifier-hir"], "note": "The pinned oracle accepts the inventory-backed f/w/b/c modifier forms; l/t remain syntax-carried because translation content is outside this frontend."}, + {"fixture": "synthetic/issue-28-invalid-syntax", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-28-invalid-syntax/oracle.json", "implementation-invariant:issue-28-structured-negative-diagnostics"], "note": "Malformed do-while and dictionary syntax remain structured failures rather than silent acceptance."}, {"fixture": "synthetic/declarations-numbers", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/declarations-numbers/oracle.json", "implementation-invariant:frontend-resolves-declarations"], "note": "Numeric literals and variable-index declarations resolve in the OPY semantic model."}, {"fixture": "synthetic/declarations-rules", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/declarations-rules/oracle.json", "implementation-invariant:frontend-resolves-rule-declarations"], "note": "globalvar, playervar, subroutine, def, enum, and rule declarations resolve."}, {"fixture": "synthetic/expressions-values", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/expressions-values/oracle.json", "implementation-invariant:frontend-resolves-expressions"], "note": "Expressions, arrays, strings, vectors, calls, and format expressions resolve."}, diff --git a/compatibility/fixtures/synthetic/issue-28-invalid-syntax/fixture.json b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/fixture.json new file mode 100644 index 0000000..3eacdc2 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-28-invalid-syntax", + "category": "diagnostics", + "features": ["syntax/do-while", "syntax/string-modifiers", "syntax/dicts"], + "source": "source.opy", + "expectedStatus": "failure", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #28 minimized reference-rejected syntax probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-28-invalid-syntax/oracle.json b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/oracle.json new file mode 100644 index 0000000..defc09c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/oracle.json @@ -0,0 +1,32 @@ +{ + "compile": { + "diagnostics": [ + { + "severity": "error", + "text": "Error: Content is empty (missing operand or argument?)\n | line 7, col 5, at source.opy" + } + ], + "exitCode": 1, + "status": "failure", + "stdout": "", + "workshop": "", + "workshopExact": "", + "workshopSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fixture": "synthetic/issue-28-invalid-syntax", + "input": { + "sha256": "4c37a67406cb59376baabd986880ff71999f664456c3d1662cab47e47a371765", + "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-28-invalid-syntax/source.opy b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/source.opy new file mode 100644 index 0000000..6146f2d --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-invalid-syntax/source.opy @@ -0,0 +1,9 @@ +globalvar value + +rule "issue 28 invalid syntax": + @Event global + do: + value = 1 + while + value = {"key"} + value = u"invalid modifier" diff --git a/compatibility/fixtures/synthetic/issue-28-string-modifiers/fixture.json b/compatibility/fixtures/synthetic/issue-28-string-modifiers/fixture.json new file mode 100644 index 0000000..c2e0384 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-string-modifiers/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-28-string-modifiers", + "category": "syntax", + "features": ["syntax/string-modifiers"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #28 minimized pinned-oracle string modifier probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-28-string-modifiers/oracle.json b/compatibility/fixtures/synthetic/issue-28-string-modifiers/oracle.json new file mode 100644 index 0000000..9e3e424 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-string-modifiers/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 28 string modifiers\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, Custom String(\"formatted 0\"));\n Set Global Variable(value, Custom String(\"\uff57\uff49\uff44\uff45\"));\n Set Global Variable(value, Custom String(\"\u0392ig\"));\n Set Global Variable(value, Custom String(\"\u0188\u1ea1\u0455\u1eb9-\u0455\u1eb9\u1e47\u0455\u0456\u1e6d\u0456\u03bd\u1eb9\"));\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 28 string modifiers\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, Custom String(\"formatted 0\"));\n Set Global Variable(value, Custom String(\"\uff57\uff49\uff44\uff45\"));\n Set Global Variable(value, Custom String(\"\u0392ig\"));\n Set Global Variable(value, Custom String(\"\u0188\u1ea1\u0455\u1eb9-\u0455\u1eb9\u1e47\u0455\u0456\u1e6d\u0456\u03bd\u1eb9\"));\n }\n}\n\n", + "workshopSha256": "40d3c8c89ed4f8ea181d24a2ef9f639d1e0a394c058b5b8413b9bce32e000005" + }, + "fixture": "synthetic/issue-28-string-modifiers", + "input": { + "sha256": "a1067d0f682d3baf415c99b980d9b6a253acad47fff340a7b6e06e7af0843c68", + "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-28-string-modifiers/source.opy b/compatibility/fixtures/synthetic/issue-28-string-modifiers/source.opy new file mode 100644 index 0000000..ac8a5d4 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-string-modifiers/source.opy @@ -0,0 +1,8 @@ +globalvar value + +rule "issue 28 string modifiers": + @Event global + value = f"formatted {0}" + value = w"wide" + value = b"big" + value = c"case-sensitive" diff --git a/compatibility/fixtures/synthetic/issue-28-syntax/fixture.json b/compatibility/fixtures/synthetic/issue-28-syntax/fixture.json new file mode 100644 index 0000000..d3fc833 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-syntax/fixture.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-28-syntax", + "category": "syntax", + "features": [ + "syntax/switch", + "syntax/do-while", + "syntax/hex-literals", + "syntax/membership", + "syntax/string-modifiers", + "syntax/dicts", + "syntax/comprehensions", + "syntax/lambda" + ], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #28 minimized pure-OPY syntax probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-28-syntax/oracle.json b/compatibility/fixtures/synthetic/issue-28-syntax/oracle.json new file mode 100644 index 0000000..1f86ec3 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-syntax/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 28 syntax\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 1);\n Loop If(Not(Array Contains(Array(2, 3), Global.value)));\n If(True);\n Skip(Value In Array(Array(2, 0, 1), Add(1, Index Of Array Value(Array(16, 0X20), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, True);\n //__label_switch_2_1__:\n Set Global Variable(value, Mapped Array(Array(1, 2), Multiply(Current Array Element, 2)));\n //__label_switch_2_default__:\n Set Global Variable(value, 1);\n End;\n Set Global Variable(value, Sorted Array(Array(1, 2), Current Array Element));\n Set Global Variable(value, Custom String(\"formatted 0\"));\n Set Global Variable(value, Custom String(\"\uff57\uff49\uff44\uff45\"));\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 28 syntax\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 1);\n Loop If(Not(Array Contains(Array(2, 3), Global.value)));\n If(True);\n Skip(Value In Array(Array(2, 0, 1), Add(1, Index Of Array Value(Array(16, 0X20), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, True);\n //__label_switch_2_1__:\n Set Global Variable(value, Mapped Array(Array(1, 2), Multiply(Current Array Element, 2)));\n //__label_switch_2_default__:\n Set Global Variable(value, 1);\n End;\n Set Global Variable(value, Sorted Array(Array(1, 2), Current Array Element));\n Set Global Variable(value, Custom String(\"formatted 0\"));\n Set Global Variable(value, Custom String(\"\uff57\uff49\uff44\uff45\"));\n }\n}\n\n", + "workshopSha256": "2fc2a9a5ed812c443dd81962ab6e413e806e343d291a60e9e1d3bfc06362b178" + }, + "fixture": "synthetic/issue-28-syntax", + "input": { + "sha256": "edf9128e1aa5b6e2ad8d672e00dad15e58517daec7b1a5ae763d0030a57e5573", + "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-28-syntax/source.opy b/compatibility/fixtures/synthetic/issue-28-syntax/source.opy new file mode 100644 index 0000000..064690c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-28-syntax/source.opy @@ -0,0 +1,17 @@ +globalvar value + +rule "issue 28 syntax": + @Event global + do: + value = 1 + while value not in [2, 3] + switch value: + case 0x10: + value = 1 in [1, 2] + case 0X20: + value = [item * 2 for item in [1, 2]] + default: + value = {"key": 1}["key"] + value = sorted([1, 2], key=lambda item: item) + value = f"formatted {0}" + value = w"wide" diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 370de27..1f4076d 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -97,25 +97,93 @@ }, { "id": "syntax/switch", - "name": "switch/case/default, do…while, hex 0x literals, expression-level not in", + "name": "switch/case/default", "category": "syntax", - "state": "planned", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "upstream:src/tests/switches.opy" + ], + "notes": "Issue #28: switch/case/default preserve arm expressions, bodies, and source spans in the OPY HIR; Workshop lowering remains out of scope." + }, + { + "id": "syntax/do-while", + "name": "do … while", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "fixtures:real-world/overpy-santa/regressions/do-while.opy", + "upstream:src/tests/loops.opy" + ], + "notes": "Issue #28: the body executes before the condition and the condition is retained as an OPY HIR expression; malformed/truncated conditions remain structured parse errors." + }, + { + "id": "syntax/hex-literals", + "name": "Hexadecimal numeric literals (0x/0X)", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "upstream:src/tests/operators.opy" + ], + "notes": "Issue #28: hexadecimal source spelling is preserved in the numeric HIR text while the numeric value is decoded independently." + }, + { + "id": "syntax/membership", + "name": "Expression-level in/not in membership", + "category": "syntax", + "state": "frontend-supported", "evidence": [ - "upstream:src/tests/switches.opy", + "fixtures:synthetic/issue-28-syntax", "upstream:src/tests/operators.opy" ], - "notes": "Baseline category 1a: evidence-prioritized. Explicitly rejected (documented diagnostics) until implemented." + "notes": "Issue #28: in/not in are represented as source-semantic binary operators; no Workshop Array Contains catalog or lowering is copied here." }, { "id": "syntax/string-modifiers", - "name": "String modifiers (f/w/l/b/c/t), dict literals, list comprehensions, lambda", + "name": "String modifiers (f/w/l/b/c/t)", "category": "syntax", - "state": "planned", + "state": "frontend-supported", "evidence": [ - "upstream:src/tests/dicts.opy", + "fixtures:synthetic/issue-28-string-modifiers", + "fixtures:synthetic/issue-28-syntax", "upstream:src/tests/strings.opy" ], - "notes": "Baseline category 1b: legacy-quirk/demand-driven for dicts and modifiers, evidence-prioritized for comprehensions; explicit rejection until demand is demonstrated." + "notes": "Issue #28: modifiers are preserved as OPY HIR string nodes. f/w/b/c are oracle-corpus evidenced; l/t remain syntax-carried without duplicating translation data." + }, + { + "id": "syntax/dicts", + "name": "Dictionary literals and keyed access", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "upstream:src/tests/dicts.opy" + ], + "notes": "Issue #28: dictionary entries and keyed access are represented in OPY HIR; bare dictionaries and malformed entries produce structured diagnostics." + }, + { + "id": "syntax/comprehensions", + "name": "List comprehensions (mapping/filtering, element and index binders)", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "upstream:src/tests/dicts.opy" + ], + "notes": "Issue #28: one-for-clause comprehensions with an optional filter and optional element/index binders preserve local scope in HIR." + }, + { + "id": "syntax/lambda", + "name": "Lambda expressions for array operations", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-28-syntax", + "upstream:src/tests/dicts.opy" + ], + "notes": "Issue #28: lambda x: expr forms are carried through sorted/map/filter/any/all argument positions; standalone lambdas remain an explicit diagnostic like the pinned reference." }, { "id": "syntax/settings-blocks", @@ -605,7 +673,7 @@ "end-to-end-supported": 0 }, "byCategory": { - "syntax": 7, + "syntax": 13, "semantics": 14, "preprocessing": 4, "macros": 3, diff --git a/crates/opy-frontend/src/cst.rs b/crates/opy-frontend/src/cst.rs index 774a1f2..c1eb62d 100644 --- a/crates/opy-frontend/src/cst.rs +++ b/crates/opy-frontend/src/cst.rs @@ -187,11 +187,30 @@ pub enum Stmt { body: Vec, span: Span, }, + DoWhile { + condition: Expr, + body: Vec, + span: Span, + }, + Switch { + value: Expr, + cases: Vec, + r#default: Option>, + span: Span, + }, Pass { span: Span, }, } +/// One `case value:` arm in a switch statement. +#[derive(Debug, Clone)] +pub struct SwitchCase { + pub value: Expr, + pub body: Vec, + pub span: Span, +} + /// One condition/body pair of an `if`. #[derive(Debug, Clone)] pub struct IfBranch { @@ -235,6 +254,29 @@ pub enum Expr { elements: Vec, span: Span, }, + Dict { + entries: Vec, + span: Span, + }, + Comprehension { + element: Box, + variable: String, + variable_span: Span, + index: Option<(String, Span)>, + iterable: Box, + condition: Option>, + span: Span, + }, + Lambda { + params: Vec<(String, Span)>, + body: Box, + span: Span, + }, + StringModifier { + modifier: char, + value: String, + span: Span, + }, /// A plain function call. Call { name: String, @@ -279,6 +321,14 @@ pub enum Expr { }, } +/// One key/value pair in an OPY dictionary literal. +#[derive(Debug, Clone)] +pub struct DictEntry { + pub key: Expr, + pub value: Expr, + pub span: Span, +} + impl Expr { /// The source span of this expression. pub fn span(&self) -> Span { @@ -288,6 +338,10 @@ impl Expr { | Expr::Bool { span, .. } | Expr::Null { span } | Expr::Array { span, .. } + | Expr::Dict { span, .. } + | Expr::Comprehension { span, .. } + | Expr::Lambda { span, .. } + | Expr::StringModifier { span, .. } | Expr::Call { span, .. } | Expr::ReceiverCall { span, .. } | Expr::Name { span, .. } diff --git a/crates/opy-frontend/src/hir/dump.rs b/crates/opy-frontend/src/hir/dump.rs index 3505573..57e4807 100644 --- a/crates/opy-frontend/src/hir/dump.rs +++ b/crates/opy-frontend/src/hir/dump.rs @@ -271,6 +271,41 @@ fn dump_stmt(statement: &Stmt, out: &mut String, level: usize) { out.push_str(&format!("{}\n", span_suffix(span.as_ref()))); dump_stmts(body, out, level + 1); } + Stmt::DoWhile { + condition, + body, + span, + } => { + out.push_str(&format!( + "{}doWhile{}\n", + indent(level), + span_suffix(span.as_ref()) + )); + dump_stmts(body, out, level + 1); + out.push_str(&format!("{}while ", indent(level))); + render_expr(condition, out); + out.push('\n'); + } + Stmt::Switch { + value, + cases, + r#default, + 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); + } + } Stmt::CallSubroutine { name, span } => { out.push_str(&format!( "{}callSubroutine {}{}\n", @@ -373,6 +408,60 @@ fn render_expr(expr: &Expr, out: &mut String) { } out.push(']'); } + Expr::Dict { entries, .. } => { + out.push('{'); + for (index, entry) in entries.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + render_expr(&entry.key, out); + out.push_str(": "); + render_expr(&entry.value, out); + } + out.push('}'); + } + Expr::Comprehension { + element, + variable, + index, + iterable, + condition, + .. + } => { + out.push('['); + render_expr(element, out); + out.push_str(" for "); + out.push_str(variable); + if let Some(index) = index { + out.push_str(", "); + out.push_str(index); + } + out.push_str(" in "); + render_expr(iterable, out); + if let Some(condition) = condition { + out.push_str(" if "); + render_expr(condition, out); + } + out.push(']'); + } + Expr::Lambda { params, body, .. } => { + out.push_str("lambda "); + for (index, param) in params.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + out.push_str(param); + } + out.push_str(": "); + render_expr(body, out); + } + Expr::StringModifier { + modifier, value, .. + } => { + out.push(modifier.chars().next().unwrap_or_default()); + out.push_str(&format!("{:?}", value)); + } + Expr::Local { name, .. } => out.push_str(name), Expr::Vector { x, y, z, .. } => { out.push_str("vect("); render_expr(x, out); diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index 3c4bfe0..4ad86cf 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -19,9 +19,10 @@ mod validate; pub use error::HirError; pub use types::{ Annotation, AnnotationArg, Declaration, DirectiveRecord, DirectiveValue, Event, Expr, - Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState, Program, - Protocol, Rule, RuleEntry, Settings, SettingsListElement, SettingsNode, SourceFile, Span, Stmt, - TranslationState, default_var_index, + DictEntry, Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState, + Program, Protocol, Rule, RuleEntry, Settings, SettingsListElement, SettingsNode, SourceFile, + Span, Stmt, SwitchCase, TranslationState, default_var_index, + 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 117197e..e36920b 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -441,6 +441,22 @@ pub enum Stmt { #[serde(skip_serializing_if = "Option::is_none")] span: Option, }, + DoWhile { + condition: Box, + #[serde(default)] + body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + Switch { + value: Box, + #[serde(default)] + cases: Vec, + #[serde(default, rename = "default")] + r#default: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, CallSubroutine { name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -469,12 +485,24 @@ impl Stmt { | Stmt::If { span, .. } | Stmt::For { span, .. } | Stmt::While { span, .. } + | Stmt::DoWhile { span, .. } + | Stmt::Switch { span, .. } | Stmt::CallSubroutine { span, .. } | Stmt::Pass { span } => span.as_ref(), } } } +/// One switch case in the OPY HIR. +#[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, +} + /// An expression. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] @@ -505,6 +533,47 @@ pub enum Expr { #[serde(skip_serializing_if = "Option::is_none")] span: Option, }, + Dict { + #[serde(default)] + entries: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + Comprehension { + element: Box, + variable: String, + #[serde(skip_serializing_if = "Option::is_none")] + variable_span: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + index_span: Option, + iterable: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + condition: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + Lambda { + #[serde(default)] + params: Vec, + #[serde(default)] + param_spans: Vec>, + body: Box, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + StringModifier { + modifier: String, + value: String, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, + Local { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, Vector { x: Box, y: Box, @@ -605,6 +674,15 @@ pub enum Expr { }, } +/// One key/value pair in an OPY dictionary. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DictEntry { + pub key: Box, + pub value: Box, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, +} + impl Expr { /// The source span of this expression, if any. pub fn span(&self) -> Option<&Span> { @@ -614,6 +692,11 @@ impl Expr { | Expr::Bool { span, .. } | Expr::Null { span } | Expr::Array { span, .. } + | Expr::Dict { span, .. } + | Expr::Comprehension { span, .. } + | Expr::Lambda { span, .. } + | Expr::StringModifier { span, .. } + | Expr::Local { span, .. } | Expr::Vector { span, .. } | Expr::Enum { span, .. } | Expr::GlobalVar { span, .. } @@ -640,6 +723,11 @@ impl Expr { Expr::Bool { .. } => "bool", Expr::Null { .. } => "null", Expr::Array { .. } => "array", + Expr::Dict { .. } => "dict", + Expr::Comprehension { .. } => "comprehension", + Expr::Lambda { .. } => "lambda", + Expr::StringModifier { .. } => "stringModifier", + Expr::Local { .. } => "local", Expr::Vector { .. } => "vector", Expr::Enum { .. } => "enum", Expr::GlobalVar { .. } => "globalVar", diff --git a/crates/opy-frontend/src/hir/validate.rs b/crates/opy-frontend/src/hir/validate.rs index 6352d4f..a8c9c19 100644 --- a/crates/opy-frontend/src/hir/validate.rs +++ b/crates/opy-frontend/src/hir/validate.rs @@ -39,6 +39,8 @@ const STMT_KINDS: &[&str] = &[ "if", "for", "while", + "doWhile", + "switch", "callSubroutine", "pass", ]; @@ -49,6 +51,11 @@ const EXPR_KINDS: &[&str] = &[ "bool", "null", "array", + "dict", + "comprehension", + "lambda", + "stringModifier", + "local", "vector", "enum", "globalVar", @@ -511,6 +518,27 @@ fn statement_exprs(statements: &[Stmt]) -> Vec<&Expr> { exprs.push(condition.as_ref()); exprs.extend(statement_exprs(body)); } + Stmt::DoWhile { + condition, body, .. + } => { + exprs.push(condition.as_ref()); + exprs.extend(statement_exprs(body)); + } + Stmt::Switch { + value, + cases, + r#default, + .. + } => { + 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)); + } + } Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} } } @@ -593,7 +621,19 @@ fn for_each_stmt<'a>(statements: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { for_each_stmt(else_body, f); } } - Stmt::For { body, .. } | Stmt::While { body, .. } => for_each_stmt(body, f), + 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::Expr { .. } | Stmt::Assign { .. } | Stmt::CallSubroutine { .. } @@ -611,6 +651,25 @@ fn for_each_expr<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) { for_each_expr(element, f); } } + Expr::Dict { entries, .. } => { + for entry in entries { + for_each_expr(&entry.key, f); + for_each_expr(&entry.value, f); + } + } + Expr::Comprehension { + element, + iterable, + condition, + .. + } => { + for_each_expr(iterable, f); + for_each_expr(element, f); + if let Some(condition) = condition { + for_each_expr(condition, f); + } + } + Expr::Lambda { body, .. } => for_each_expr(body, f), Expr::Vector { x, y, z, .. } => { for_each_expr(x, f); for_each_expr(y, f); @@ -646,7 +705,9 @@ fn for_each_expr<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) { | Expr::GlobalVar { .. } | Expr::EventPlayer { .. } | Expr::Constant { .. } - | Expr::MacroParam { .. } => {} + | Expr::MacroParam { .. } + | Expr::StringModifier { .. } + | Expr::Local { .. } => {} } } @@ -741,6 +802,18 @@ 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(body) = case.get("body").and_then(Value::as_array) { + for statement in body { + check_stmt(statement)?; + } + } + } + } for field in ["body", "else"] { if let Some(statements) = object.get(field).and_then(Value::as_array) { for statement in statements { @@ -773,6 +846,22 @@ fn check_expr(value: &Value) -> Result<(), HirError> { } } } + if let Some(entries) = object.get("entries").and_then(Value::as_array) { + for entry in entries { + if let Some(key) = entry.get("key") { + check_expr(key)?; + } + if let Some(entry_value) = entry.get("value") { + check_expr(entry_value)?; + } + } + } + if let Some(condition) = object.get("condition") { + check_expr(condition)?; + } + if let Some(body) = object.get("body") { + check_expr(body)?; + } Ok(()) } diff --git a/crates/opy-frontend/src/lexer.rs b/crates/opy-frontend/src/lexer.rs index 5772d98..63db9c9 100644 --- a/crates/opy-frontend/src/lexer.rs +++ b/crates/opy-frontend/src/lexer.rs @@ -33,6 +33,8 @@ pub enum TokenKind { RParen, LBracket, RBracket, + LBrace, + RBrace, Comma, Colon, Dot, @@ -135,6 +137,8 @@ impl Lexer { ')' => self.single(TokenKind::RParen), '[' => self.single(TokenKind::LBracket), ']' => self.single(TokenKind::RBracket), + '{' => self.single(TokenKind::LBrace), + '}' => self.single(TokenKind::RBrace), ',' => self.single(TokenKind::Comma), ':' => self.single(TokenKind::Colon), '.' => self.single(TokenKind::Dot), @@ -280,6 +284,31 @@ impl Lexer { fn lex_number(&mut self) -> FrontendResult<()> { let start = self.here(1); let mut text = String::new(); + if self.chars[self.pos] == '0' && matches!(self.peek(1), Some('x' | 'X')) { + text.push('0'); + self.advance(); + text.push(self.chars[self.pos]); + self.advance(); + let digits_start = self.pos; + while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_hexdigit() { + text.push(self.chars[self.pos]); + self.advance(); + } + if self.pos == digits_start { + return Err(FrontendError::at( + "lex-error", + "hexadecimal literal requires at least one hexadecimal digit", + Span::new(self.file_id, start.start, self.here(0).start), + )); + } + let end = self.here(0); + self.tokens.push(Token::new( + TokenKind::Number, + text, + Span::new(self.file_id, start.start, end.start), + )); + return Ok(()); + } while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { text.push(self.chars[self.pos]); self.advance(); diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 7af8e35..85c5198 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -29,9 +29,10 @@ use std::collections::{HashMap, HashSet}; use crate::hir::types::{ Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define, 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, default_var_index, + DictEntry as HirDictEntry, 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, }; use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt}; @@ -63,6 +64,9 @@ struct Lowerer { subroutines: HashSet, macros: HashSet, enums: HashMap>, + locals: Vec, + allow_lambda: bool, + allow_dict_literal: bool, /// The authoritative builtin semantic table (issue #109). manifest: &'static Manifest, errors: Vec, @@ -98,6 +102,9 @@ pub fn lower_with_preprocessing( subroutines: HashSet::new(), macros: HashSet::new(), enums: HashMap::new(), + locals: Vec::new(), + allow_lambda: false, + allow_dict_literal: false, manifest, errors: Vec::new(), }; @@ -741,6 +748,39 @@ impl Lowerer { body: self.lower_block(body, macro_params), span: Some(span.into()), }, + Stmt::DoWhile { + condition, + body, + span, + } => HirStmt::DoWhile { + condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), + body: self.lower_block(body, macro_params), + span: Some(span.into()), + }, + Stmt::Switch { + value, + cases, + r#default, + span, + } => HirStmt::Switch { + value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)), + cases: cases + .iter() + .map(|case| HirSwitchCase { + value: Box::new(self.lower_expr( + &case.value, + macro_params, + CallPosition::Value, + )), + body: self.lower_block(&case.body, macro_params), + span: Some(case.span.into()), + }) + .collect(), + r#default: r#default + .as_ref() + .map(|body| self.lower_block(body, macro_params)), + span: Some(span.into()), + }, Stmt::Pass { span } => HirStmt::Pass { span: Some(span.into()), }, @@ -781,6 +821,99 @@ impl Lowerer { .collect(), span: Some(span.into()), }, + Expr::Dict { entries, span } => { + if !self.allow_dict_literal { + self.error_at( + "dict-access", + "dictionary literals must be accessed by a key".to_string(), + *span, + ); + return HirExpr::Null { span: None }; + } + HirExpr::Dict { + entries: entries + .iter() + .map(|entry| HirDictEntry { + key: Box::new(self.lower_expr( + &entry.key, + macro_params, + CallPosition::Value, + )), + value: Box::new(self.lower_expr( + &entry.value, + macro_params, + CallPosition::Value, + )), + span: Some(entry.span.into()), + }) + .collect(), + span: Some(span.into()), + } + } + Expr::Comprehension { + element, + variable, + variable_span, + index, + iterable, + condition, + span, + } => { + let iterable = self.lower_expr(iterable, macro_params, CallPosition::Value); + let previous = std::mem::take(&mut self.locals); + self.locals.push(variable.clone()); + if let Some((index, _)) = index { + self.locals.push(index.clone()); + } + let element = self.lower_expr(element, macro_params, CallPosition::Value); + let condition = condition.as_ref().map(|condition| { + Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)) + }); + self.locals = previous; + HirExpr::Comprehension { + element: Box::new(element), + variable: variable.clone(), + variable_span: Some(variable_span.into()), + index: index.as_ref().map(|(name, _)| name.clone()), + index_span: index.as_ref().map(|(_, span)| (*span).into()), + iterable: Box::new(iterable), + condition, + span: Some(span.into()), + } + } + Expr::Lambda { params, body, span } => { + if !self.allow_lambda { + self.error_at( + "lambda-context", + "lambda expressions are only valid as array operation arguments" + .to_string(), + *span, + ); + return HirExpr::Null { span: None }; + } + let previous = std::mem::take(&mut self.locals); + self.locals = params.iter().map(|(name, _)| name.clone()).collect(); + let body = self.lower_expr(body, macro_params, CallPosition::Value); + self.locals = previous; + HirExpr::Lambda { + params: params.iter().map(|(name, _)| name.clone()).collect(), + param_spans: params + .iter() + .map(|(_, span)| Some((*span).into())) + .collect(), + body: Box::new(body), + span: Some(span.into()), + } + } + Expr::StringModifier { + modifier, + value, + span, + } => HirExpr::StringModifier { + modifier: modifier.to_string(), + value: value.clone(), + span: Some(span.into()), + }, Expr::Name { name, span } => self.lower_name(name, *span, macro_params), Expr::Member { receiver, @@ -788,11 +921,17 @@ impl Lowerer { member_span, span, } => self.lower_member(receiver, member, *member_span, *span, macro_params), - Expr::Index { array, index, span } => HirExpr::Index { - array: Box::new(self.lower_expr(array, macro_params, CallPosition::Value)), - index: Box::new(self.lower_expr(index, macro_params, CallPosition::Value)), - span: Some(span.into()), - }, + Expr::Index { array, index, span } => { + let previous = self.allow_dict_literal; + self.allow_dict_literal = true; + let array = self.lower_expr(array, macro_params, CallPosition::Value); + self.allow_dict_literal = previous; + HirExpr::Index { + array: Box::new(array), + index: Box::new(self.lower_expr(index, macro_params, CallPosition::Value)), + span: Some(span.into()), + } + } Expr::Call { name, args, span } => { self.lower_call(name, args, *span, macro_params, position) } @@ -828,6 +967,12 @@ impl Lowerer { span: Some(span.into()), }; } + if self.locals.iter().any(|local| local == name) { + return HirExpr::Local { + name: name.to_string(), + span: Some(span.into()), + }; + } match name { "eventPlayer" => HirExpr::EventPlayer { span: Some(span.into()), @@ -960,7 +1105,7 @@ impl Lowerer { ) -> HirExpr { // Builtin identity and position checks run before the special forms // so that a misplaced `wait`/`vect` still diagnoses its position. - if !self.macros.contains(name) && !self.subroutines.contains(name) { + if !self.macros.contains(name) && !self.subroutines.contains(name) && name != "sorted" { match self.manifest.resolve_function(name) { Some(entry) => self.check_call_position(name, entry, position, span), None => { @@ -979,6 +1124,17 @@ impl Lowerer { } } match name { + "sorted" => { + let previous = self.allow_lambda; + self.allow_lambda = true; + let lowered = HirExpr::Call { + name: name.to_string(), + args: self.lower_arg_values(args, macro_params), + span: Some(span.into()), + }; + self.allow_lambda = previous; + lowered + } "vect" => { // `vect` goes through the generic argument binder so its // keyword forms (`vect(x=1, y=2, z=3)`) bind like any other @@ -1394,6 +1550,22 @@ impl Lowerer { macro_params: &[String], position: CallPosition, ) -> HirExpr { + if matches!(name, "map" | "filter" | "all" | "any") + && args + .iter() + .any(|arg| matches!(&arg.value, Expr::Lambda { .. })) + { + let previous = self.allow_lambda; + self.allow_lambda = true; + let lowered = HirExpr::ReceiverCall { + receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)), + name: name.to_string(), + args: self.lower_arg_values(args, macro_params), + span: Some(span.into()), + }; + self.allow_lambda = previous; + return lowered; + } // `random.uniform(...)` etc. are dotted generic calls. if let Expr::Name { name: root, .. } = receiver { if root == "random" { @@ -2625,4 +2797,58 @@ mod tests { let span = error.span.expect("the error is source-located"); assert_eq!(span.start.line, 3); } + + #[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", + ); + let (_, actions) = rule_conditions_and_actions(&hir); + let HirStmt::Switch { + cases, r#default, .. + } = &actions[0] + else { + panic!("expected switch"); + }; + assert_eq!(cases.len(), 1); + assert!(r#default.is_some()); + let HirStmt::Assign { value, .. } = &cases[0].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"); + }; + assert!(matches!(condition.as_ref(), HirExpr::Binary { op, .. } if op == "not in")); + let HirStmt::Assign { value, .. } = &actions[1] else { + panic!("expected comprehension assignment"); + }; + assert!(matches!(value.as_ref(), HirExpr::Comprehension { .. })); + let HirStmt::Assign { value, .. } = &actions[2] 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 { + panic!("expected string assignment"); + }; + assert!( + matches!(value.as_ref(), HirExpr::StringModifier { modifier, .. } if modifier == "w") + ); + } + + #[test] + fn issue_28_rejects_reference_invalid_bare_dict_and_lambda() { + let dict_error = compile_error( + "globalvar x\nrule \"r\":\n @Event global\n x = {\"x\": 1}\n", + 4, + ); + assert_eq!(dict_error.code, "dict-access"); + let lambda_error = compile_error( + "globalvar x\nrule \"r\":\n @Event global\n x = lambda value: value\n", + 4, + ); + assert_eq!(lambda_error.code, "lambda-context"); + } } diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index fc0838f..81176de 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -8,7 +8,8 @@ //! or the collected errors (never both). use crate::cst::{ - Annotation, AnnotationArg, CallArg, Decl, Event, Expr, IfBranch, Program, Rule, RuleEntry, Stmt, + Annotation, AnnotationArg, CallArg, Decl, DictEntry, Event, Expr, IfBranch, Program, Rule, + RuleEntry, Stmt, SwitchCase, }; use crate::diag::{FrontendError, Position, Span}; use crate::lexer::{Token, TokenKind}; @@ -83,6 +84,10 @@ impl Parser<'_> { self.peek().kind } + fn peek_at(&self, offset: usize) -> &Token { + &self.tokens[(self.pos + offset).min(self.tokens.len() - 1)] + } + fn advance(&mut self) -> Token { let token = self.tokens[self.pos.min(self.tokens.len() - 1)].clone(); if self.pos < self.tokens.len() - 1 { @@ -884,6 +889,8 @@ impl Parser<'_> { "if" => return self.parse_if(), "for" => return self.parse_for(), "while" => return self.parse_while(), + "do" => return self.parse_do_while(), + "switch" => return self.parse_switch(), "pass" => { let start = self.advance(); return Ok(Stmt::Pass { span: start.span }); @@ -1008,7 +1015,7 @@ impl Parser<'_> { fn parse_for(&mut self) -> Result { let start = self.advance(); - let variable = self.parse_expr()?; + let variable = self.parse_primary()?; if !self.is_ident("in") { self.error_at_current("expected `in` in the for statement".to_string()); return Err(()); @@ -1051,6 +1058,103 @@ impl Parser<'_> { }) } + fn parse_do_while(&mut self) -> Result { + let start = self.advance(); + if self.expect(TokenKind::Colon, "':' after `do`").is_err() { + return Err(()); + } + let body_indent = self.block_indent(start.span.start.col).ok_or(())?; + let body = self.parse_block(body_indent); + if !self.is_ident("while") { + self.error_at_current("expected `while` after the do block".to_string()); + return Err(()); + } + self.advance(); + let condition = self.parse_expr()?; + if self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { + self.error_at_current("expected the end of the do-while condition".to_string()); + return Err(()); + } + Ok(Stmt::DoWhile { + condition, + body, + span: start.span, + }) + } + + fn parse_switch(&mut self) -> Result { + let start = self.advance(); + let value = self.parse_expr()?; + if self + .expect(TokenKind::Colon, "':' after the switch value") + .is_err() + { + return Err(()); + } + let body_indent = self.block_indent(start.span.start.col).ok_or(())?; + let mut cases = Vec::new(); + let mut r#default = None; + loop { + self.skip_newlines(); + if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent { + break; + } + if self.peek().span.start.col != body_indent { + self.error_at_current("unexpected indentation in switch".to_string()); + self.recover_line(); + continue; + } + if self.is_ident("case") { + let case_start = self.advance(); + let case_value = self.parse_expr()?; + if self + .expect(TokenKind::Colon, "':' after the case value") + .is_err() + { + return Err(()); + } + let case_body_indent = self.block_indent(body_indent).ok_or(())?; + let body = self.parse_block(case_body_indent); + cases.push(SwitchCase { + value: case_value, + body, + span: case_start.span, + }); + } else if self.is_ident("default") { + let default_start = self.advance(); + if self + .expect(TokenKind::Colon, "':' after `default`") + .is_err() + { + return Err(()); + } + let default_body_indent = self.block_indent(body_indent).ok_or(())?; + r#default = Some(self.parse_block(default_body_indent)); + if default_start.span.start.col != body_indent { + self.error_at_current("invalid default indentation".to_string()); + return Err(()); + } + } else { + self.error_at_current("expected `case` or `default` in switch".to_string()); + self.recover_line(); + } + } + if cases.is_empty() && r#default.is_none() { + self.errors.push(FrontendError::at( + "parse-error", + "switch must contain at least one case or default arm".to_string(), + start.span, + )); + return Err(()); + } + Ok(Stmt::Switch { + value, + cases, + r#default, + span: start.span, + }) + } + // ---- expressions ---- fn parse_expr(&mut self) -> Result { @@ -1113,9 +1217,14 @@ impl Parser<'_> { TokenKind::Le => "<=", TokenKind::Gt => ">", TokenKind::Ge => ">=", + _ if self.is_ident("in") => "in", + _ if self.is_ident("not") && self.peek_at(1).text == "in" => "not in", _ => break, }; self.advance(); + if op == "not in" { + self.advance(); + } let right = self.parse_additive()?; let span = Span::new(left.span().file, left.span().start, right.span().end); left = Expr::Binary { @@ -1365,7 +1474,15 @@ impl Parser<'_> { match token.kind { TokenKind::Number => { let token = self.advance(); - let value: f64 = token.text.parse().unwrap_or(f64::NAN); + let value = if let Some(hex) = token + .text + .strip_prefix("0x") + .or_else(|| token.text.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).map_or(f64::NAN, |value| value as f64) + } else { + token.text.parse().unwrap_or(f64::NAN) + }; Ok(Expr::Number { value, text: token.text.clone(), @@ -1381,6 +1498,17 @@ impl Parser<'_> { } TokenKind::Ident => { let token = self.advance(); + if token.text == "lambda" { + return self.parse_lambda(token.span); + } + if is_string_modifier(&token.text) && self.peek_kind() == TokenKind::String { + let string = self.advance(); + return Ok(Expr::StringModifier { + modifier: token.text.chars().next().unwrap_or_default(), + value: string.text, + span: Span::new(token.span.file, token.span.start, string.span.end), + }); + } match token.text.as_str() { "true" => Ok(Expr::Bool { value: true, @@ -1414,11 +1542,43 @@ impl Parser<'_> { span: Span::new(open.span.file, open.span.start, end), }); } - loop { - match self.parse_expr() { - Ok(expr) => elements.push(expr), - Err(()) => return Err(()), + let first = self.parse_expr()?; + if self.is_ident("for") { + self.advance(); + let variable_token = + self.expect(TokenKind::Ident, "a comprehension variable")?; + let index = if self.peek_kind() == TokenKind::Comma { + self.advance(); + let index = self.expect(TokenKind::Ident, "a comprehension index")?; + Some((index.text, index.span)) + } else { + None + }; + if !self.is_ident("in") { + self.error_at_current("expected `in` in list comprehension".to_string()); + return Err(()); } + self.advance(); + let iterable = self.parse_expr()?; + let condition = if self.is_ident("if") { + self.advance(); + Some(Box::new(self.parse_expr()?)) + } else { + None + }; + let end = self.expect(TokenKind::RBracket, "']'")?.span.end; + return Ok(Expr::Comprehension { + element: Box::new(first), + variable: variable_token.text, + variable_span: variable_token.span, + index, + iterable: Box::new(iterable), + condition, + span: Span::new(open.span.file, open.span.start, end), + }); + } + elements.push(first); + loop { self.skip_newlines(); if self.peek_kind() == TokenKind::Comma { self.advance(); @@ -1426,6 +1586,7 @@ impl Parser<'_> { if self.peek_kind() == TokenKind::RBracket { break; } + elements.push(self.parse_expr()?); } else { break; } @@ -1439,12 +1600,72 @@ impl Parser<'_> { span: Span::new(open.span.file, open.span.start, end), }) } + TokenKind::LBrace => self.parse_dict(), _ => { self.error_at_current(format!("expected an expression but found '{}'", token.text)); Err(()) } } } + + fn parse_dict(&mut self) -> Result { + let open = self.advance(); + let mut entries = Vec::new(); + self.skip_newlines(); + if self.peek_kind() == TokenKind::RBrace { + let end = self.advance().span.end; + return Ok(Expr::Dict { + entries, + span: Span::new(open.span.file, open.span.start, end), + }); + } + loop { + let key = self.parse_expr()?; + self.expect(TokenKind::Colon, "':' in a dictionary entry")?; + let value = self.parse_expr()?; + let span = Span::new(key.span().file, key.span().start, value.span().end); + entries.push(DictEntry { key, value, span }); + self.skip_newlines(); + if self.peek_kind() == TokenKind::Comma { + self.advance(); + self.skip_newlines(); + if self.peek_kind() == TokenKind::RBrace { + break; + } + } else { + break; + } + } + let end = self.expect(TokenKind::RBrace, "'}'")?.span.end; + Ok(Expr::Dict { + entries, + span: Span::new(open.span.file, open.span.start, end), + }) + } + + fn parse_lambda(&mut self, start: Span) -> Result { + let mut params = Vec::new(); + loop { + let param = self.expect(TokenKind::Ident, "a lambda parameter")?; + params.push((param.text, param.span)); + if self.peek_kind() == TokenKind::Comma { + self.advance(); + } else { + break; + } + } + self.expect(TokenKind::Colon, "':' after lambda parameters")?; + let body = self.parse_expr()?; + Ok(Expr::Lambda { + params, + body: Box::new(body.clone()), + span: Span::new(start.file, start.start, body.span().end), + }) + } +} + +fn is_string_modifier(text: &str) -> bool { + matches!(text, "f" | "w" | "l" | "b" | "c" | "t") } #[cfg(test)] @@ -1506,6 +1727,27 @@ mod tests { assert_eq!(body.len(), 2); } + #[test] + fn parses_issue_28_constructs() { + let program = parse_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", + ); + let RuleEntry::Rule(rule) = &program.rules[0] else { + panic!("expected rule"); + }; + assert!(matches!(rule.actions[0], Stmt::Switch { .. })); + assert!(matches!(rule.actions[1], Stmt::Assign { .. })); + } + + #[test] + fn rejects_incomplete_do_while_and_dictionary_entries() { + let errors = parse_err( + "rule \"r\":\n @Event global\n do:\n pass\n while\n x = {\"x\"}\n", + ); + assert!(!errors.is_empty()); + assert!(errors.iter().all(|error| error.code == "parse-error")); + } + #[test] fn parses_multi_line_array() { let program = parse_ok( diff --git a/crates/opy-frontend/src/preprocess.rs b/crates/opy-frontend/src/preprocess.rs index 6983697..677d0a9 100644 --- a/crates/opy-frontend/src/preprocess.rs +++ b/crates/opy-frontend/src/preprocess.rs @@ -1414,18 +1414,24 @@ mod tests { } #[test] - fn dict_literal_braces_still_lex_error() { - // Scoped settings lexing must not mask expression-level braces: - // meipocalypse-style dict literals keep failing as a lex-error. - let error = preprocess( + fn dict_literal_braces_reach_the_parser() { + // Scoped settings lexing must not consume expression-level braces. + let (pre, _) = preprocess( "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n", "main.opy", Path::new("."), ) - .unwrap_err(); - assert_eq!(error.code, "lex-error"); - assert!(error.message.contains("unexpected character '{'")); - assert_eq!(error.span.unwrap().start.line, 2); + .unwrap(); + assert!( + pre.tokens + .iter() + .any(|token| token.kind == TokenKind::LBrace) + ); + assert!( + pre.tokens + .iter() + .any(|token| token.kind == TokenKind::RBrace) + ); } #[test] diff --git a/crates/opy-frontend/src/support.rs b/crates/opy-frontend/src/support.rs index 9e0d051..b0f6933 100644 --- a/crates/opy-frontend/src/support.rs +++ b/crates/opy-frontend/src/support.rs @@ -215,7 +215,7 @@ mod tests { fn category_and_state_filters_match_the_summary() { let matrix = SupportMatrix::builtin().unwrap(); let syntax = matrix.features_by_category("syntax"); - assert_eq!(syntax.len(), 7); + assert_eq!(syntax.len(), 13); assert!(syntax.iter().all(|feature| feature.category == "syntax")); let lowering = matrix.features_by_state("lowering-dependent"); assert_eq!(lowering.len(), 13); diff --git a/crates/opy-frontend/src/tooling.rs b/crates/opy-frontend/src/tooling.rs index 7274f79..e1aeca8 100644 --- a/crates/opy-frontend/src/tooling.rs +++ b/crates/opy-frontend/src/tooling.rs @@ -507,7 +507,9 @@ impl SemanticModel { | HirExpr::Null { .. } | HirExpr::Enum { .. } | HirExpr::EventPlayer { .. } - | HirExpr::MacroParam { .. } => {} + | HirExpr::MacroParam { .. } + | HirExpr::StringModifier { .. } + | HirExpr::Local { .. } => {} HirExpr::GlobalVar { name, span } | HirExpr::Constant { name, span } => { let kind = if matches!(expr, HirExpr::GlobalVar { .. }) { SymbolKind::Global @@ -529,6 +531,25 @@ impl SemanticModel { Self::collect_expr(element, sites); } } + HirExpr::Dict { entries, .. } => { + for entry in entries { + Self::collect_expr(&entry.key, sites); + Self::collect_expr(&entry.value, sites); + } + } + HirExpr::Comprehension { + element, + iterable, + condition, + .. + } => { + Self::collect_expr(iterable, sites); + Self::collect_expr(element, sites); + if let Some(condition) = condition { + Self::collect_expr(condition, sites); + } + } + HirExpr::Lambda { body, .. } => Self::collect_expr(body, sites), HirExpr::Vector { x, y, z, .. } => { Self::collect_expr(x, sites); Self::collect_expr(y, sites); @@ -626,6 +647,33 @@ impl SemanticModel { Self::collect_stmt(stmt, sites); } } + HirStmt::DoWhile { + condition, body, .. + } => { + Self::collect_expr(condition, sites); + for stmt in body { + Self::collect_stmt(stmt, sites); + } + } + HirStmt::Switch { + value, + cases, + r#default, + .. + } => { + 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); + } + } + } HirStmt::CallSubroutine { name, span } => { if let Some(span) = span { sites.push(( diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index bcf3be6..873978b 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -163,6 +163,24 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { true, "if/elif/else, for-in-range, while, pass; oracle status success (parity).", ); + resolve( + &mut cases, + "synthetic/issue-28-syntax", + true, + "Issue #28 pure OPY syntax: switch, do-while, hex, membership, dict indexing, comprehensions, lambda arguments, and string modifiers.", + ); + resolve( + &mut cases, + "synthetic/issue-28-string-modifiers", + true, + "Issue #28 inventory-backed string modifiers; translation-dependent l/t are syntax-carried outside the fixture.", + ); + diagnostic( + &mut cases, + "synthetic/issue-28-invalid-syntax", + Some("parse-error"), + "Issue #28 malformed do-while and dictionary syntax remains a structured parse failure.", + ); resolve( &mut cases, "synthetic/declarations-numbers", diff --git a/crates/opy-frontend/tests/tooling.rs b/crates/opy-frontend/tests/tooling.rs index 0217965..92817a9 100644 --- a/crates/opy-frontend/tests/tooling.rs +++ b/crates/opy-frontend/tests/tooling.rs @@ -153,9 +153,9 @@ fn multi_file_project_checks_and_resolves_end_to_end() { /// machine contract: codes and source locations, not wording). #[rustfmt::skip] const STABLE_DIAGNOSTICS: &[(&str, &str)] = &[ - // Expression-level dict braces stay a lex error (scoped settings lexing - // must not mask them). - ("rule \"r\":\n @Event global\n money += {\n Mei.GENERIC: 10,\n }\n", "lex-error"), + // A bare dictionary is now lexed and rejected at the OPY semantic + // boundary rather than being mistaken for settings content. + ("globalvar money\nrule \"r\":\n @Event global\n money += {\n Mei.GENERIC: 10,\n }\n", "dict-access"), // Missing colon after the rule name. ("rule \"x\"\n @Event global\n", "parse-error"), // Unknown builtin in statement position. diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 2d127dc..b272488 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -66,13 +66,12 @@ implements; "reference" always means the pinned OverPy 9.7.10 (`889d9749d1def17f146548cbddb94ea1ab015847`). ### Lexing -- Identifiers, integer and decimal number literals (source text preserved), +- Identifiers, integer, decimal, and `0x`/`0X` hexadecimal number literals + (source text preserved), double-quoted strings with `\n`/`\t`/`\\` escapes, `true`/`false`/`None`. - Line comments (`#`), block comments (`/* */`), `#!` directives. -- Operators: `+ - * / // % ** == != < <= > >= = += -= *= /= //= %= and or not`, - plus `.`/`,`/`:`/`(`/`)`/`[`/`]`/`@`. (`in` is only the `for ... in` - header keyword; expression-level `in`/`not in` membership operators are not - supported; see the deferred list.) +- Operators: `+ - * / // % ** == != < <= > >= = += -= *= /= //= %= and or not + in not in`, plus `.`/`,`/`:`/`(`/`)`/`[`/`]`/`{`/`}`/`@`. ### Declarations - `globalvar name` / `globalvar name = expr` / `globalvar name ` @@ -163,7 +162,8 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, lowering-dependent; malformed or misplaced annotations fail with structured source-located diagnostics. - Statements: expression statements, `=` and augmented assignment, - `if`/`elif`/`else`, `for x in range(...)`, `while`, `pass`. + `if`/`elif`/`else`, `for x in range(...)`, `while`, `do: … while`, + `switch`/`case`/`default`, and `pass`. - `for`-loop binder resolution: the loop variable must resolve to a global variable, either a declared `globalvar`, or an OverPy **default variable name** (`A`–`Z`, `AA`–`AZ`, …, `DA`–`DX`), which the pinned reference @@ -176,7 +176,16 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, `range(start, stop, step)` are all supported. ### Expressions and resolution -- Literals, arrays `[...]`, parenthesized expressions. +- Literals, arrays `[...]`, dictionaries used through indexed access, + parenthesized expressions, list comprehensions with one `for` clause and + optional `if`, and the reference's context-bound lambda forms. +- String modifiers `f`/`w`/`l`/`b`/`c`/`t` are preserved as OPY source + semantics. `l`/`t` translation/content behavior remains reference-limited; + the committed positive fixture covers the inventory-backed `f`/`w`/`b`/`c` + forms. +- `lambda` is accepted in the reference-supported array-operation contexts + (`sorted(..., key=...)` and array `map`/`filter`/`all`/`any`); standalone + lambda expressions are rejected with a structured frontend diagnostic. - Calls (`range`, `len`, `abs`, `sqrt`, `debug`, `print`, `wait`, `createBeam`, `playEffect`, `getAllPlayers`, `disableInspector`, …). - `vect(x, y, z)` → HIR `Vector` (3 arguments required; other arities are an @@ -337,12 +346,16 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, (alias targets `stopChasing`/`getHero`/`hasStatus`, and enum members without a catalogged spelling); these fail at emission with catalog diagnostics once integration lands, never silently. -- Expression-level `in`/`not in` membership operators: rejected at parsing - (`for ... in` headers are supported). + - Expression-level `in`/`not in` membership operators: rejected at parsing + (`for ... in` headers are supported). +- Rule `disabled` markers (no corpus evidence for the source annotation). + - Rule `disabled` markers (no corpus evidence for the source annotation). - Backslash line continuation (`\` at end of line inside string concatenations / macro bodies): rejected at lexing. - Postfix increment/decrement (`++`/`--`): rejected at parsing. -- Dict literals (`{...}`): rejected at lexing. +- Dict literals outside an indexed-access context are rejected with a + structured `dict-access` diagnostic; dict lowering/emission remains + `lowering-dependent`. - Triple-quoted strings / docstrings (`"""`): rejected at lexing. - Subroutine parameters, default `@Team`/`@Slot` overrides, `raycast` `include=`/`exclude=` named-argument forms (no reference/corpus evidence From 7f7f10a5145155a94fc1cc891d4b94abe36ab979 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:39:40 +0800 Subject: [PATCH 2/9] test(opy): update syntax matrix count --- crates/opy-cli/tests/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/opy-cli/tests/cli.rs b/crates/opy-cli/tests/cli.rs index e415319..fa98a70 100644 --- a/crates/opy-cli/tests/cli.rs +++ b/crates/opy-cli/tests/cli.rs @@ -133,7 +133,7 @@ fn support_filters_by_feature_id_and_category() { let slice: serde_json::Value = serde_json::from_slice(&by_category.stdout).expect("category JSON"); assert_eq!(slice["category"], "syntax"); - assert_eq!(slice["count"], 7); + assert_eq!(slice["count"], 13); let unknown = run(&["support", "nope/nothing"]); assert_eq!(unknown.status.code(), Some(2)); From 8153452cded691c703190e4861f6326e47969f93 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:42:26 +0800 Subject: [PATCH 3/9] fix(opy): repair issue 33 syntax semantics --- compatibility/differential-expectations.json | 3 + .../synthetic/issue-33-f-string/fixture.json | 14 ++ .../synthetic/issue-33-f-string/oracle.json | 27 +++ .../synthetic/issue-33-f-string/source.opy | 6 + .../issue-33-lambda-negative/fixture.json | 14 ++ .../issue-33-lambda-negative/oracle.json | 32 ++++ .../issue-33-lambda-negative/source.opy | 5 + .../issue-33-switch-break/fixture.json | 14 ++ .../issue-33-switch-break/oracle.json | 27 +++ .../issue-33-switch-break/source.opy | 19 ++ compatibility/support-matrix.json | 24 ++- crates/opy-frontend/src/cst.rs | 9 + crates/opy-frontend/src/hir/dump.rs | 7 + crates/opy-frontend/src/hir/types.rs | 8 +- crates/opy-frontend/src/hir/validate.rs | 4 +- crates/opy-frontend/src/lexer.rs | 16 +- crates/opy-frontend/src/lower.rs | 139 ++++++++++---- crates/opy-frontend/src/parser.rs | 181 ++++++++++++++++++ crates/opy-frontend/src/support.rs | 2 +- crates/opy-frontend/src/tooling.rs | 1 + crates/opy-frontend/tests/differential.rs | 18 ++ docs/hir/opy-hir-v1.md | 3 + docs/opy/support-matrix.md | 16 +- docs/opy/tooling-api.md | 2 + 24 files changed, 542 insertions(+), 49 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-33-f-string/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-33-f-string/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-33-f-string/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-33-lambda-negative/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-33-lambda-negative/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-33-switch-break/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-33-switch-break/source.opy diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index dc12ba4..b525a1c 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -11,6 +11,9 @@ {"fixture": "synthetic/control-flow", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/control-flow/oracle.json", "implementation-invariant:frontend-resolves-control-flow"], "note": "If/elif/else, for-in-range, while, and pass resolve in the OPY semantic model."}, {"fixture": "synthetic/issue-28-syntax", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-28-syntax/oracle.json", "implementation-invariant:issue-28-pure-syntax-hir"], "note": "Switch, do-while, hexadecimal literals, membership, dict indexing, comprehensions, lambda arguments, and f/w/b/c string modifiers resolve through the OPY HIR."}, {"fixture": "synthetic/issue-28-string-modifiers", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-28-string-modifiers/oracle.json", "implementation-invariant:issue-28-string-modifier-hir"], "note": "The pinned oracle accepts the inventory-backed f/w/b/c modifier forms; l/t remain syntax-carried because translation content is outside this frontend."}, + {"fixture": "synthetic/issue-33-switch-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-33-switch-break/oracle.json", "implementation-invariant:issue-33-fallthrough-break-hir"], "note": "The pinned oracle accepts source-order switch fallthrough and nested break; native HIR retains the same authored arm order and explicit break statements."}, + {"fixture": "synthetic/issue-33-f-string", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-33-f-string/oracle.json", "implementation-invariant:issue-33-format-provenance-and-lambda-slots"], "note": "The pinned oracle accepts semantic f-string interpolation and sorted key lambda syntax; native HIR retains format arguments with source provenance."}, + {"fixture": "synthetic/issue-33-lambda-negative", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-33-lambda-negative/oracle.json", "implementation-invariant:issue-33-lambda-context-diagnostic"], "note": "The pinned oracle rejects a standalone lambda argument; native emits the stable lambda-context diagnostic."}, {"fixture": "synthetic/issue-28-invalid-syntax", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-28-invalid-syntax/oracle.json", "implementation-invariant:issue-28-structured-negative-diagnostics"], "note": "Malformed do-while and dictionary syntax remain structured failures rather than silent acceptance."}, {"fixture": "synthetic/declarations-numbers", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/declarations-numbers/oracle.json", "implementation-invariant:frontend-resolves-declarations"], "note": "Numeric literals and variable-index declarations resolve in the OPY semantic model."}, {"fixture": "synthetic/declarations-rules", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/declarations-rules/oracle.json", "implementation-invariant:frontend-resolves-rule-declarations"], "note": "globalvar, playervar, subroutine, def, enum, and rule declarations resolve."}, diff --git a/compatibility/fixtures/synthetic/issue-33-f-string/fixture.json b/compatibility/fixtures/synthetic/issue-33-f-string/fixture.json new file mode 100644 index 0000000..c07679d --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-f-string/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-33-f-string", + "category": "syntax", + "features": ["syntax/string-modifiers", "syntax/lambda"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #33 pinned f-string interpolation and lambda-slot probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json b/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json new file mode 100644 index 0000000..1b2cd6d --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 33 f string\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, Custom String(\"value {0}\", Add(Global.value, 1)));\n Create HUD Text(All Players(All Teams), Null, Custom String(\"\u0455\u03bf\u1e5b\u1e6d\u1eb9\u1e0d([1, 2], \u1e33\u1eb9\u1ef5 = I\u1ea1\u1e43\u1e05\u1e0d\u1ea1 \u0456\u1e6d\u1eb9\u1e43: \u0456\u1e6d\u1eb9\u1e43)\u2028= {0}\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003{1}\", Mapped Array(Mapped Array(Array(Mapped Array(Sorted Array(Array(1, 2), Current Array Element), If-Then-Else(Or(Compare(Count Of(Current Array Element), ==, 1), And(Compare(Current Array Element, ==, Empty Array), Compare(Current Array Element, !=, Null))), Custom String(\"[{0}]\", Current Array Element), If-Then-Else(Count Of(Current Array Element), Custom String(\"[{0}, \u2026+{1}]\", Current Array Element, Subtract(Count Of(Current Array Element), 1)), Current Array Element)))), Append To Array(Append To Array(Or(Count Of(Current Array Element), And(Compare(Current Array Element, ==, Empty Array), Compare(Current Array Element, !=, Null))), If-Then-Else(And(Not(Count Of(Current Array Element)), Compare(Current Array Element, !=, Empty Array)), 3, Multiply(Count Of(Current Array Element), 3))), Current Array Element)), If-Then-Else(First Of(Current Array Element), Custom String(\"[{0}{1}]\", String Replace(Custom String(\"{0}, {1}, {2}\", Value In Array(Current Array Element, 2), Value In Array(Current Array Element, 3), Custom String(\"{0}, {1}, {2}\", Value In Array(Current Array Element, 4), Value In Array(Current Array Element, 5), Custom String(\"{0}, {1}, \u2026\u0001\", Value In Array(Current Array Element, 6), Value In Array(Current Array Element, 7)))), String Slice(Custom String(\"0, 0, 0, 0, 0, 0, \u2026\u0001\"), Add(-2, Value In Array(Current Array Element, 1)), Subtract(22, Value In Array(Current Array Element, 1))), Empty Array), If-Then-Else(Compare(Value In Array(Current Array Element, 1), >, 18), Custom String(\"+{0}\", Subtract(Divide(Value In Array(Current Array Element, 1), 3), 6)), Empty Array)), String Split(Value In Array(Current Array Element, 2), Empty Array))), Custom String(\"\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\")), Null, Left, -9999, Null, Color(White), Null, Visible To Sort Order String and Color, Default Visibility);\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 33 f string\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, Custom String(\"value {0}\", Add(Global.value, 1)));\n Create HUD Text(All Players(All Teams), Null, Custom String(\"\u0455\u03bf\u1e5b\u1e6d\u1eb9\u1e0d([1, 2], \u1e33\u1eb9\u1ef5 = I\u1ea1\u1e43\u1e05\u1e0d\u1ea1 \u0456\u1e6d\u1eb9\u1e43: \u0456\u1e6d\u1eb9\u1e43)\u2028= {0}\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003{1}\", Mapped Array(Mapped Array(Array(Mapped Array(Sorted Array(Array(1, 2), Current Array Element), If-Then-Else(Or(Compare(Count Of(Current Array Element), ==, 1), And(Compare(Current Array Element, ==, Empty Array), Compare(Current Array Element, !=, Null))), Custom String(\"[{0}]\", Current Array Element), If-Then-Else(Count Of(Current Array Element), Custom String(\"[{0}, \u2026+{1}]\", Current Array Element, Subtract(Count Of(Current Array Element), 1)), Current Array Element)))), Append To Array(Append To Array(Or(Count Of(Current Array Element), And(Compare(Current Array Element, ==, Empty Array), Compare(Current Array Element, !=, Null))), If-Then-Else(And(Not(Count Of(Current Array Element)), Compare(Current Array Element, !=, Empty Array)), 3, Multiply(Count Of(Current Array Element), 3))), Current Array Element)), If-Then-Else(First Of(Current Array Element), Custom String(\"[{0}{1}]\", String Replace(Custom String(\"{0}, {1}, {2}\", Value In Array(Current Array Element, 2), Value In Array(Current Array Element, 3), Custom String(\"{0}, {1}, {2}\", Value In Array(Current Array Element, 4), Value In Array(Current Array Element, 5), Custom String(\"{0}, {1}, \u2026\u0001\", Value In Array(Current Array Element, 6), Value In Array(Current Array Element, 7)))), String Slice(Custom String(\"0, 0, 0, 0, 0, 0, \u2026\u0001\"), Add(-2, Value In Array(Current Array Element, 1)), Subtract(22, Value In Array(Current Array Element, 1))), Empty Array), If-Then-Else(Compare(Value In Array(Current Array Element, 1), >, 18), Custom String(\"+{0}\", Subtract(Divide(Value In Array(Current Array Element, 1), 3), 6)), Empty Array)), String Split(Value In Array(Current Array Element, 2), Empty Array))), Custom String(\"\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\u2003\")), Null, Left, -9999, Null, Color(White), Null, Visible To Sort Order String and Color, Default Visibility);\n }\n}\n\n", + "workshopSha256": "4b9a57d829367185329b1d9bae060400df5c9fa3ae6bcc06e946a362e812ac8e" + }, + "fixture": "synthetic/issue-33-f-string", + "input": { + "sha256": "7ccbb48a6e68fb80f0e21c0d290697ba12f0fb77ca87bed5019a09f6c9b5f86d", + "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-33-f-string/source.opy b/compatibility/fixtures/synthetic/issue-33-f-string/source.opy new file mode 100644 index 0000000..7960b9a --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-f-string/source.opy @@ -0,0 +1,6 @@ +globalvar value + +rule "issue 33 f string": + @Event global + value = f"value {value + 1}" + debug(sorted([1, 2], key=lambda item: item)) diff --git a/compatibility/fixtures/synthetic/issue-33-lambda-negative/fixture.json b/compatibility/fixtures/synthetic/issue-33-lambda-negative/fixture.json new file mode 100644 index 0000000..9a11b9a --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-lambda-negative/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-33-lambda-negative", + "category": "diagnostics", + "features": ["syntax/lambda"], + "source": "source.opy", + "expectedStatus": "failure", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #33 pinned negative lambda-context oracle probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json b/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json new file mode 100644 index 0000000..93bb95b --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json @@ -0,0 +1,32 @@ +{ + "compile": { + "diagnostics": [ + { + "severity": "error", + "text": "Error: Expected '(' after 'lambda', but got 'item' (is 'lambda' a valid keyword/variable?)\n | line 5, col 11, at source.opy" + } + ], + "exitCode": 1, + "status": "failure", + "stdout": "", + "workshop": "", + "workshopExact": "", + "workshopSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fixture": "synthetic/issue-33-lambda-negative", + "input": { + "sha256": "ca4887606d28b86ad781c5fdc0e6e7ccb5337ab83e361c64230d8e900294a0c2", + "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-33-lambda-negative/source.opy b/compatibility/fixtures/synthetic/issue-33-lambda-negative/source.opy new file mode 100644 index 0000000..02555a7 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-lambda-negative/source.opy @@ -0,0 +1,5 @@ +globalvar value + +rule "issue 33 lambda negative": + @Event global + debug(lambda item: item) diff --git a/compatibility/fixtures/synthetic/issue-33-switch-break/fixture.json b/compatibility/fixtures/synthetic/issue-33-switch-break/fixture.json new file mode 100644 index 0000000..24be616 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-switch-break/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-33-switch-break", + "category": "syntax", + "features": ["syntax/switch", "syntax/break"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #33 pinned switch fallthrough and break probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json b/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json new file mode 100644 index 0000000..1a59339 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-switch-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 33 switch break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(3, 0, 1), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, 2);\n //__label_switch_2_1__:\n Set Global Variable(value, 3);\n Else;\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n While(Compare(Global.value, <, 10));\n If(True);\n Skip(Value In Array(Array(1, 0), Add(1, Index Of Array Value(Array(3), Global.value))));\n //__label_switch_3_0__:\n Else;\n //__label_switch_3_default__:\n Else;\n End;\n Break;\n End;\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 33 switch break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(3, 0, 1), Add(1, Index Of Array Value(Array(1, 2), Global.value))));\n //__label_switch_2_0__:\n Set Global Variable(value, 2);\n //__label_switch_2_1__:\n Set Global Variable(value, 3);\n Else;\n //__label_switch_2_default__:\n Set Global Variable(value, 4);\n End;\n While(Compare(Global.value, <, 10));\n If(True);\n Skip(Value In Array(Array(1, 0), Add(1, Index Of Array Value(Array(3), Global.value))));\n //__label_switch_3_0__:\n Else;\n //__label_switch_3_default__:\n Else;\n End;\n Break;\n End;\n }\n}\n\n", + "workshopSha256": "8a2c12836e3b5d17e5c51b17be7b5f3d6f1979263f77a3dfdd933341888a78b3" + }, + "fixture": "synthetic/issue-33-switch-break", + "input": { + "sha256": "a4fe06ada89ce89563128481e07c65356806e0c1e4c813933dd1b64680194e2b", + "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-33-switch-break/source.opy b/compatibility/fixtures/synthetic/issue-33-switch-break/source.opy new file mode 100644 index 0000000..c73686d --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-33-switch-break/source.opy @@ -0,0 +1,19 @@ +globalvar value + +rule "issue 33 switch break": + @Event global + switch value: + case 1: + value = 2 + case 2: + value = 3 + break + default: + value = 4 + while value < 10: + switch value: + case 3: + break + default: + break + break diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 1f4076d..e52415e 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -102,9 +102,22 @@ "state": "frontend-supported", "evidence": [ "fixtures:synthetic/issue-28-syntax", + "fixtures:synthetic/issue-33-switch-break", "upstream:src/tests/switches.opy" ], - "notes": "Issue #28: switch/case/default preserve arm expressions, bodies, and source spans in the OPY HIR; Workshop lowering remains out of scope." + "notes": "Issue #28/#33: switch/case/default preserve source-order arms and fall through into subsequent arms; explicit break exits the innermost switch or loop. Workshop lowering remains out of scope." + }, + { + "id": "syntax/break", + "name": "break statements in switch and loops", + "category": "syntax", + "state": "frontend-supported", + "evidence": [ + "fixtures:synthetic/issue-33-switch-break", + "upstream:src/tests/switches.opy", + "upstream:src/tests/loops.opy" + ], + "notes": "Issue #33: break is a real HIR statement, validates its enclosing switch/loop context, and is retained without implicit arm exits." }, { "id": "syntax/do-while", @@ -148,9 +161,10 @@ "evidence": [ "fixtures:synthetic/issue-28-string-modifiers", "fixtures:synthetic/issue-28-syntax", + "fixtures:synthetic/issue-33-f-string", "upstream:src/tests/strings.opy" ], - "notes": "Issue #28: modifiers are preserved as OPY HIR string nodes. f/w/b/c are oracle-corpus evidenced; l/t remain syntax-carried without duplicating translation data." + "notes": "Issue #28/#33: f-string interpolation lowers to a semantic format node with source-spanned arguments; w/b/c remain semantic string modifiers and l/t remain syntax-carried without duplicating translation data." }, { "id": "syntax/dicts", @@ -181,9 +195,11 @@ "state": "frontend-supported", "evidence": [ "fixtures:synthetic/issue-28-syntax", + "fixtures:synthetic/issue-33-f-string", + "fixtures:synthetic/issue-33-lambda-negative", "upstream:src/tests/dicts.opy" ], - "notes": "Issue #28: lambda x: expr forms are carried through sorted/map/filter/any/all argument positions; standalone lambdas remain an explicit diagnostic like the pinned reference." + "notes": "Issue #28/#33: lambda x: expr is accepted only in signature-approved positions (sorted index 1/key and array map/filter/all/any index 0); standalone and other argument positions produce a structured diagnostic." }, { "id": "syntax/settings-blocks", @@ -673,7 +689,7 @@ "end-to-end-supported": 0 }, "byCategory": { - "syntax": 13, + "syntax": 14, "semantics": 14, "preprocessing": 4, "macros": 3, diff --git a/crates/opy-frontend/src/cst.rs b/crates/opy-frontend/src/cst.rs index c1eb62d..6b18313 100644 --- a/crates/opy-frontend/src/cst.rs +++ b/crates/opy-frontend/src/cst.rs @@ -198,6 +198,9 @@ pub enum Stmt { r#default: Option>, span: Span, }, + Break { + span: Span, + }, Pass { span: Span, }, @@ -275,6 +278,12 @@ pub enum Expr { StringModifier { modifier: char, value: String, + /// The decoded f-string template, with interpolation placeholders + /// normalized to `{0}`, `{1}`, …; present only for modifier `f`. + format_text: Option, + /// Expressions parsed from f-string interpolation regions, in source + /// order. Their spans point into the original string literal. + interpolations: Vec, span: Span, }, /// A plain function call. diff --git a/crates/opy-frontend/src/hir/dump.rs b/crates/opy-frontend/src/hir/dump.rs index 57e4807..d71c679 100644 --- a/crates/opy-frontend/src/hir/dump.rs +++ b/crates/opy-frontend/src/hir/dump.rs @@ -306,6 +306,13 @@ fn dump_stmt(statement: &Stmt, out: &mut String, level: usize) { dump_stmts(default_body, out, level + 2); } } + Stmt::Break { span } => { + out.push_str(&format!( + "{}break{}\n", + indent(level), + span_suffix(span.as_ref()) + )); + } Stmt::CallSubroutine { name, span } => { out.push_str(&format!( "{}callSubroutine {}{}\n", diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index e36920b..b4dacaa 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -457,6 +457,10 @@ pub enum Stmt { #[serde(skip_serializing_if = "Option::is_none")] span: Option, }, + Break { + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + }, CallSubroutine { name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -487,13 +491,15 @@ impl Stmt { | Stmt::While { span, .. } | Stmt::DoWhile { span, .. } | Stmt::Switch { span, .. } + | Stmt::Break { span } | Stmt::CallSubroutine { span, .. } | Stmt::Pass { span } => span.as_ref(), } } } -/// One switch case in the OPY HIR. +/// One switch case in the OPY HIR. Cases execute in source order and fall +/// through to subsequent cases until a `break` statement is encountered. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SwitchCase { pub value: Box, diff --git a/crates/opy-frontend/src/hir/validate.rs b/crates/opy-frontend/src/hir/validate.rs index a8c9c19..bb0e769 100644 --- a/crates/opy-frontend/src/hir/validate.rs +++ b/crates/opy-frontend/src/hir/validate.rs @@ -41,6 +41,7 @@ const STMT_KINDS: &[&str] = &[ "while", "doWhile", "switch", + "break", "callSubroutine", "pass", ]; @@ -539,7 +540,7 @@ fn statement_exprs(statements: &[Stmt]) -> Vec<&Expr> { exprs.extend(statement_exprs(default_body)); } } - Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} + Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} } } exprs @@ -636,6 +637,7 @@ fn for_each_stmt<'a>(statements: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { } Stmt::Expr { .. } | Stmt::Assign { .. } + | Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => {} } diff --git a/crates/opy-frontend/src/lexer.rs b/crates/opy-frontend/src/lexer.rs index 63db9c9..48c8e55 100644 --- a/crates/opy-frontend/src/lexer.rs +++ b/crates/opy-frontend/src/lexer.rs @@ -69,6 +69,11 @@ pub struct Token { /// The source text of this token (numbers keep their spelling; strings /// keep their unescaped value; identifiers keep their name). pub text: String, + /// The exact characters between string quotes, before escape decoding. + /// Other token kinds leave this unset. It is retained so source-language + /// constructs such as f-string interpolations can recover expression + /// spans without losing provenance during preprocessing. + pub raw: Option, pub span: Span, } @@ -77,6 +82,7 @@ impl Token { Token { kind, text: text.into(), + raw: None, span, } } @@ -234,24 +240,29 @@ impl Lexer { let start = self.here(1); self.advance(); let mut value = String::new(); + let mut raw = String::new(); while self.pos < self.chars.len() { let ch = self.chars[self.pos]; if ch == quote { self.advance(); let end = self.here(0); - self.tokens.push(Token::new( + let mut token = Token::new( TokenKind::String, value, Span::new(self.file_id, start.start, end.start), - )); + ); + token.raw = Some(raw); + self.tokens.push(token); return Ok(()); } if ch == '\\' { + raw.push(ch); self.advance(); if self.pos >= self.chars.len() { break; } let escaped = self.chars[self.pos]; + raw.push(escaped); value.push(match escaped { 'n' => '\n', 't' => '\t', @@ -271,6 +282,7 @@ impl Lexer { start, )); } + raw.push(ch); value.push(ch); self.advance(); } diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 85c5198..0207d6e 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -55,6 +55,8 @@ enum CallPosition { Value, /// A `for ... in` iterable (only `range` is a valid builtin here). ForIterable, + /// An expression occupying a signature-approved lambda argument slot. + LambdaArgument, } /// The lowerer's symbol context, built from the CST declarations. @@ -65,7 +67,6 @@ struct Lowerer { macros: HashSet, enums: HashMap>, locals: Vec, - allow_lambda: bool, allow_dict_literal: bool, /// The authoritative builtin semantic table (issue #109). manifest: &'static Manifest, @@ -103,7 +104,6 @@ pub fn lower_with_preprocessing( macros: HashSet::new(), enums: HashMap::new(), locals: Vec::new(), - allow_lambda: false, allow_dict_literal: false, manifest, errors: Vec::new(), @@ -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, &[]), + body: lowerer.lower_block(body, &[], false), 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, &[]); + let actions = self.lower_block(&rule.actions, &[], false); Ok(Rule { name: render_rule_name( &rule.name, @@ -651,14 +651,19 @@ impl Lowerer { } /// Lower a statement block; `macro_params` names resolve to `MacroParam`. - fn lower_block(&mut self, stmts: &[Stmt], macro_params: &[String]) -> Vec { + fn lower_block( + &mut self, + stmts: &[Stmt], + macro_params: &[String], + breakable: bool, + ) -> Vec { stmts .iter() - .map(|stmt| self.lower_stmt(stmt, macro_params)) + .map(|stmt| self.lower_stmt(stmt, macro_params, breakable)) .collect() } - fn lower_stmt(&mut self, stmt: &Stmt, macro_params: &[String]) -> HirStmt { + fn lower_stmt(&mut self, stmt: &Stmt, macro_params: &[String], breakable: bool) -> HirStmt { match stmt { Stmt::Expr { expr, span } => { // A bare call of a declared subroutine becomes @@ -700,12 +705,12 @@ impl Lowerer { macro_params, CallPosition::Value, )), - body: self.lower_block(&branch.body, macro_params), + body: self.lower_block(&branch.body, macro_params, breakable), }) .collect(), r#else: r#else .as_ref() - .map(|body| self.lower_block(body, macro_params)), + .map(|body| self.lower_block(body, macro_params, breakable)), span: Some(span.into()), }, Stmt::For { @@ -735,7 +740,7 @@ impl Lowerer { CallPosition::Value, )), iterable: Box::new(self.lower_expr(iterable, macro_params, iterable_position)), - body: self.lower_block(body, macro_params), + body: self.lower_block(body, macro_params, true), span: Some(span.into()), } } @@ -745,7 +750,7 @@ impl Lowerer { span, } => HirStmt::While { condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), - body: self.lower_block(body, macro_params), + body: self.lower_block(body, macro_params, true), span: Some(span.into()), }, Stmt::DoWhile { @@ -754,7 +759,7 @@ impl Lowerer { span, } => HirStmt::DoWhile { condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), - body: self.lower_block(body, macro_params), + body: self.lower_block(body, macro_params, true), span: Some(span.into()), }, Stmt::Switch { @@ -772,15 +777,27 @@ impl Lowerer { macro_params, CallPosition::Value, )), - body: self.lower_block(&case.body, macro_params), + body: self.lower_block(&case.body, macro_params, true), span: Some(case.span.into()), }) .collect(), r#default: r#default .as_ref() - .map(|body| self.lower_block(body, macro_params)), + .map(|body| self.lower_block(body, macro_params, true)), span: Some(span.into()), }, + Stmt::Break { span } => { + if !breakable { + self.error_at( + "break-context", + "break is only valid inside a switch or loop".to_string(), + *span, + ); + } + HirStmt::Break { + span: Some(span.into()), + } + } Stmt::Pass { span } => HirStmt::Pass { span: Some(span.into()), }, @@ -788,7 +805,7 @@ impl Lowerer { } fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec { - self.lower_block(body, params) + self.lower_block(body, params, false) } fn lower_expr( @@ -882,7 +899,7 @@ impl Lowerer { } } Expr::Lambda { params, body, span } => { - if !self.allow_lambda { + if position != CallPosition::LambdaArgument { self.error_at( "lambda-context", "lambda expressions are only valid as array operation arguments" @@ -908,12 +925,36 @@ impl Lowerer { Expr::StringModifier { modifier, value, + format_text, + interpolations, span, - } => HirExpr::StringModifier { - modifier: modifier.to_string(), - value: value.clone(), - span: Some(span.into()), - }, + } => { + if *modifier == 'f' { + if let Some(format_text) = format_text { + if !interpolations.is_empty() { + return HirExpr::Format { + text: format_text.clone(), + args: interpolations + .iter() + .map(|expr| { + self.lower_expr(expr, macro_params, CallPosition::Value) + }) + .collect(), + span: Some(span.into()), + }; + } + return HirExpr::String { + value: format_text.clone(), + span: Some(span.into()), + }; + } + } + HirExpr::StringModifier { + modifier: modifier.to_string(), + value: value.clone(), + span: Some(span.into()), + } + } Expr::Name { name, span } => self.lower_name(name, *span, macro_params), Expr::Member { receiver, @@ -1118,6 +1159,9 @@ impl Lowerer { "invalid-iterable", format!("for-loop iterable '{name}' must be a range(...) call"), ), + CallPosition::LambdaArgument => { + ("unknown-value", format!("unknown value '{name}'")) + } }; self.error_at(code, message, span); } @@ -1125,14 +1169,17 @@ impl Lowerer { } match name { "sorted" => { - let previous = self.allow_lambda; - self.allow_lambda = true; let lowered = HirExpr::Call { name: name.to_string(), - args: self.lower_arg_values(args, macro_params), + args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| { + index == 1 + || arg + .keyword + .as_ref() + .is_some_and(|(name, _)| name == "key") + }), span: Some(span.into()), }; - self.allow_lambda = previous; lowered } "vect" => { @@ -1219,8 +1266,25 @@ impl Lowerer { /// Lower call arguments to HIR values in source order (used for macro /// calls and unresolved names; keyword values lose their name). fn lower_arg_values(&mut self, args: &[cst::CallArg], macro_params: &[String]) -> Vec { + self.lower_arg_values_with_lambda(args, macro_params, |_, _| false) + } + + fn lower_arg_values_with_lambda( + &mut self, + args: &[cst::CallArg], + macro_params: &[String], + allows_lambda: impl Fn(usize, &cst::CallArg) -> bool, + ) -> Vec { args.iter() - .map(|arg| self.lower_expr(&arg.value, macro_params, CallPosition::Value)) + .enumerate() + .map(|(index, arg)| { + let position = if allows_lambda(index, arg) { + CallPosition::LambdaArgument + } else { + CallPosition::Value + }; + self.lower_expr(&arg.value, macro_params, position) + }) .collect() } @@ -1551,19 +1615,13 @@ impl Lowerer { position: CallPosition, ) -> HirExpr { if matches!(name, "map" | "filter" | "all" | "any") - && args - .iter() - .any(|arg| matches!(&arg.value, Expr::Lambda { .. })) { - let previous = self.allow_lambda; - self.allow_lambda = true; let lowered = HirExpr::ReceiverCall { receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)), name: name.to_string(), - args: self.lower_arg_values(args, macro_params), + args: self.lower_arg_values_with_lambda(args, macro_params, |index, _| index == 0), span: Some(span.into()), }; - self.allow_lambda = previous; return lowered; } // `random.uniform(...)` etc. are dotted generic calls. @@ -1693,6 +1751,21 @@ impl Lowerer { ); } } + CallPosition::LambdaArgument => { + if entry.kind.is_action() { + self.error_at( + "action-in-value-position", + format!("action function '{name}' cannot be used as a value"), + span, + ); + } else if entry.context == Some(FunctionContext::ForIterable) { + self.error_at( + "invalid-call-context", + format!("'{name}' is only valid as a for-loop iterable"), + span, + ); + } + } } } diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index 81176de..9844303 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -891,6 +891,10 @@ impl Parser<'_> { "while" => return self.parse_while(), "do" => return self.parse_do_while(), "switch" => return self.parse_switch(), + "break" => { + let token = self.advance(); + return Ok(Stmt::Break { span: token.span }); + } "pass" => { let start = self.advance(); return Ok(Stmt::Pass { span: start.span }); @@ -1503,9 +1507,18 @@ impl Parser<'_> { } if is_string_modifier(&token.text) && self.peek_kind() == TokenKind::String { let string = self.advance(); + let (format_text, interpolations) = if token.text == "f" { + let raw = string.raw.as_deref().unwrap_or(&string.text); + let (format_text, interpolations) = self.parse_f_string(raw, string.span)?; + (Some(format_text), interpolations) + } else { + (None, Vec::new()) + }; return Ok(Expr::StringModifier { modifier: token.text.chars().next().unwrap_or_default(), value: string.text, + format_text, + interpolations, span: Span::new(token.span.file, token.span.start, string.span.end), }); } @@ -1662,6 +1675,174 @@ impl Parser<'_> { span: Span::new(start.file, start.start, body.span().end), }) } + + /// Parse the expression regions of a pinned-OverPy f-string. Double + /// braces are literal braces; a single brace introduces one expression. + /// The resulting expression tokens are shifted back into the source + /// string so HIR and tooling retain source provenance. + fn parse_f_string(&mut self, raw: &str, string_span: Span) -> Result<(String, Vec), ()> { + let chars: Vec = raw.chars().collect(); + let mut text = String::new(); + let mut interpolations = Vec::new(); + let mut index = 0; + while index < chars.len() { + match chars[index] { + '{' if chars.get(index + 1) == Some(&'{') => { + text.push_str("{{"); + index += 2; + } + '}' if chars.get(index + 1) == Some(&'}') => { + text.push_str("}}"); + index += 2; + } + '{' => { + let end = self.find_f_string_end(&chars, index + 1); + let Some(end) = end else { + self.errors.push(FrontendError::at( + "parse-error", + "unterminated f-string interpolation".to_string(), + string_span, + )); + return Err(()); + }; + let expression: String = chars[index + 1..end].iter().collect(); + if expression.trim().is_empty() { + self.errors.push(FrontendError::at( + "parse-error", + "f-string interpolation cannot be empty".to_string(), + Span::new( + string_span.file, + Position::new( + string_span.start.line, + string_span.start.col + index as u32 + 1, + ), + Position::new( + string_span.start.line, + string_span.start.col + end as u32 + 1, + ), + ), + )); + return Err(()); + } + let origin = Position::new( + string_span.start.line, + string_span.start.col + index as u32 + 1, + ); + let parsed = parse_expression_fragment(&expression, string_span.file, origin) + .map_err(|error| { + self.errors.push(error); + }); + let Ok(parsed) = parsed else { + return Err(()); + }; + text.push_str(&format!("{{{}}}", interpolations.len())); + interpolations.push(parsed); + index = end + 1; + } + '}' => { + self.errors.push(FrontendError::at( + "parse-error", + "single '}' is not valid in an f-string".to_string(), + string_span, + )); + return Err(()); + } + '\\' if index + 1 < chars.len() => { + text.push(decode_string_escape(chars[index + 1])); + index += 2; + } + character => { + text.push(character); + index += 1; + } + } + } + Ok((text, interpolations)) + } + + fn find_f_string_end(&self, chars: &[char], start: usize) -> Option { + let mut nested_braces = 0; + let mut quote = None; + let mut escaped = false; + for (index, character) in chars.iter().enumerate().skip(start) { + if escaped { + escaped = false; + continue; + } + if *character == '\\' && quote.is_some() { + escaped = true; + continue; + } + if let Some(active_quote) = quote { + if *character == active_quote { + quote = None; + } + continue; + } + match character { + '"' | '\'' => quote = Some(*character), + '{' => nested_braces += 1, + '}' if nested_braces == 0 => return Some(index), + '}' => nested_braces -= 1, + _ => {} + } + } + None + } +} + +/// Parse one f-string expression fragment and shift its local token spans +/// into the original source file. +fn parse_expression_fragment(text: &str, file: u32, origin: Position) -> Result { + let mut tokens = crate::lexer::lex(crate::lexer::LexInput { + file_id: file, + text, + })?; + for token in &mut tokens { + token.span = shift_span(token.span, origin); + } + let mut parser = Parser { + tokens: &tokens, + pos: 0, + errors: Vec::new(), + }; + let expression = parser.parse_expr().map_err(|()| { + parser + .errors + .first() + .cloned() + .unwrap_or_else(|| FrontendError::at("parse-error", "invalid f-string expression", Span::new(file, origin, origin))) + })?; + if parser.peek_kind() != TokenKind::Eof { + parser.error_at_current("unexpected tokens in f-string interpolation".to_string()); + } + parser.errors.into_iter().next().map_or(Ok(expression), Err) +} + +fn shift_span(span: Span, origin: Position) -> Span { + fn shift(position: Position, origin: Position) -> Position { + Position::new( + origin.line + position.line.saturating_sub(1), + if position.line == 1 { + origin.col + position.col.saturating_sub(1) + } else { + position.col + }, + ) + } + Span::new(span.file, shift(span.start, origin), shift(span.end, origin)) +} + +fn decode_string_escape(character: char) -> char { + match character { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '\\' => '\\', + '"' => '"', + '\'' => '\'', + other => other, + } } fn is_string_modifier(text: &str) -> bool { diff --git a/crates/opy-frontend/src/support.rs b/crates/opy-frontend/src/support.rs index b0f6933..2b3a883 100644 --- a/crates/opy-frontend/src/support.rs +++ b/crates/opy-frontend/src/support.rs @@ -215,7 +215,7 @@ mod tests { fn category_and_state_filters_match_the_summary() { let matrix = SupportMatrix::builtin().unwrap(); let syntax = matrix.features_by_category("syntax"); - assert_eq!(syntax.len(), 13); + assert_eq!(syntax.len(), 14); assert!(syntax.iter().all(|feature| feature.category == "syntax")); let lowering = matrix.features_by_state("lowering-dependent"); assert_eq!(lowering.len(), 13); diff --git a/crates/opy-frontend/src/tooling.rs b/crates/opy-frontend/src/tooling.rs index e1aeca8..85f822b 100644 --- a/crates/opy-frontend/src/tooling.rs +++ b/crates/opy-frontend/src/tooling.rs @@ -674,6 +674,7 @@ impl SemanticModel { } } } + HirStmt::Break { .. } => {} HirStmt::CallSubroutine { name, span } => { if let Some(span) = span { sites.push(( diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 873978b..38371ca 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -175,6 +175,24 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { true, "Issue #28 inventory-backed string modifiers; translation-dependent l/t are syntax-carried outside the fixture.", ); + resolve( + &mut cases, + "synthetic/issue-33-switch-break", + true, + "Issue #33 switch arms preserve source-order fallthrough and explicit break statements validate nested switch/loop context.", + ); + resolve( + &mut cases, + "synthetic/issue-33-f-string", + true, + "Issue #33 f-string interpolation preserves source-spanned expressions and the approved sorted lambda argument slot.", + ); + diagnostic( + &mut cases, + "synthetic/issue-33-lambda-negative", + Some("lambda-context"), + "Issue #33 standalone lambda use remains rejected outside a signature-approved argument position.", + ); diagnostic( &mut cases, "synthetic/issue-28-invalid-syntax", diff --git a/docs/hir/opy-hir-v1.md b/docs/hir/opy-hir-v1.md index 9c6074c..19a9807 100644 --- a/docs/hir/opy-hir-v1.md +++ b/docs/hir/opy-hir-v1.md @@ -312,6 +312,9 @@ whose `kind` the consumer does not recognize is an *unsupported node* (§7.3). | `if` | `branches`, `else`, `span` | Conditional. `branches` is an array of `{ "condition", "body" }`; `else` is an array of statements or `null`. | | `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. | +| `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 b272488..94627fa 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -163,7 +163,8 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, source-located diagnostics. - Statements: expression statements, `=` and augmented assignment, `if`/`elif`/`else`, `for x in range(...)`, `while`, `do: … while`, - `switch`/`case`/`default`, and `pass`. + `switch`/`case`/`default`, `break`, and `pass`. Switch arms execute in + source order and fall through; `break` exits the innermost switch or loop. - `for`-loop binder resolution: the loop variable must resolve to a global variable, either a declared `globalvar`, or an OverPy **default variable name** (`A`–`Z`, `AA`–`AZ`, …, `DA`–`DX`), which the pinned reference @@ -180,12 +181,13 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, parenthesized expressions, list comprehensions with one `for` clause and optional `if`, and the reference's context-bound lambda forms. - String modifiers `f`/`w`/`l`/`b`/`c`/`t` are preserved as OPY source - semantics. `l`/`t` translation/content behavior remains reference-limited; - the committed positive fixture covers the inventory-backed `f`/`w`/`b`/`c` - forms. -- `lambda` is accepted in the reference-supported array-operation contexts - (`sorted(..., key=...)` and array `map`/`filter`/`all`/`any`); standalone - lambda expressions are rejected with a structured frontend diagnostic. + semantics. f-string interpolation is lowered to a semantic format node, + retaining source spans for each expression; `l`/`t` translation/content + behavior remains reference-limited. +- `lambda` is accepted only in signature-approved positions + (`sorted(..., key=...)` or positional slot 1, and array + `map`/`filter`/`all`/`any` slot 0); standalone and other argument positions + are rejected with a structured frontend diagnostic. - Calls (`range`, `len`, `abs`, `sqrt`, `debug`, `print`, `wait`, `createBeam`, `playEffect`, `getAllPlayers`, `disableInspector`, …). - `vect(x, y, z)` → HIR `Vector` (3 arguments required; other arities are an diff --git a/docs/opy/tooling-api.md b/docs/opy/tooling-api.md index 826accf..824e593 100644 --- a/docs/opy/tooling-api.md +++ b/docs/opy/tooling-api.md @@ -75,6 +75,8 @@ Span layout: `file_id` indexes the registry, positions are 1-based | Code | Stage | Meaning | | --- | --- | --- | | `lex-error` | lex | Tokenization failure (e.g. expression-level `{}`) | +| `break-context` | lower | `break` is outside the innermost switch/loop context | +| `lambda-context` | lower | `lambda` is outside a signature-approved argument position | | `include-invalid` | preprocess | Malformed `#!include` directive | | `include-not-found` | preprocess | Included file missing under the root | | `include-cycle` | preprocess | Include cycle detected | From 0d9a962f17c33cf6e72f0f451e07b2959145c141 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:42:51 +0800 Subject: [PATCH 4/9] style(opy): format syntax repair --- crates/opy-frontend/src/lower.rs | 9 ++------- crates/opy-frontend/src/parser.rs | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 0207d6e..b0a3519 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -1172,11 +1172,7 @@ impl Lowerer { let lowered = HirExpr::Call { name: name.to_string(), args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| { - index == 1 - || arg - .keyword - .as_ref() - .is_some_and(|(name, _)| name == "key") + index == 1 || arg.keyword.as_ref().is_some_and(|(name, _)| name == "key") }), span: Some(span.into()), }; @@ -1614,8 +1610,7 @@ impl Lowerer { macro_params: &[String], position: CallPosition, ) -> HirExpr { - if matches!(name, "map" | "filter" | "all" | "any") - { + if matches!(name, "map" | "filter" | "all" | "any") { let lowered = HirExpr::ReceiverCall { receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)), name: name.to_string(), diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index 9844303..a926a88 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -1509,7 +1509,8 @@ impl Parser<'_> { let string = self.advance(); let (format_text, interpolations) = if token.text == "f" { let raw = string.raw.as_deref().unwrap_or(&string.text); - let (format_text, interpolations) = self.parse_f_string(raw, string.span)?; + let (format_text, interpolations) = + self.parse_f_string(raw, string.span)?; (Some(format_text), interpolations) } else { (None, Vec::new()) @@ -1793,7 +1794,11 @@ impl Parser<'_> { /// Parse one f-string expression fragment and shift its local token spans /// into the original source file. -fn parse_expression_fragment(text: &str, file: u32, origin: Position) -> Result { +fn parse_expression_fragment( + text: &str, + file: u32, + origin: Position, +) -> Result { let mut tokens = crate::lexer::lex(crate::lexer::LexInput { file_id: file, text, @@ -1807,11 +1812,13 @@ fn parse_expression_fragment(text: &str, file: u32, origin: Position) -> Result< errors: Vec::new(), }; let expression = parser.parse_expr().map_err(|()| { - parser - .errors - .first() - .cloned() - .unwrap_or_else(|| FrontendError::at("parse-error", "invalid f-string expression", Span::new(file, origin, origin))) + parser.errors.first().cloned().unwrap_or_else(|| { + FrontendError::at( + "parse-error", + "invalid f-string expression", + Span::new(file, origin, origin), + ) + }) })?; if parser.peek_kind() != TokenKind::Eof { parser.error_at_current("unexpected tokens in f-string interpolation".to_string()); @@ -1830,7 +1837,11 @@ fn shift_span(span: Span, origin: Position) -> Span { }, ) } - Span::new(span.file, shift(span.start, origin), shift(span.end, origin)) + Span::new( + span.file, + shift(span.start, origin), + shift(span.end, origin), + ) } fn decode_string_escape(character: char) -> char { From d8feaadcd88a60a03db032c0f284da066801c774 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:45:28 +0800 Subject: [PATCH 5/9] test(opy): align issue 33 corpus counts --- .../synthetic/issue-33-f-string/oracle.json | 2 +- .../issue-33-lambda-negative/oracle.json | 2 +- .../synthetic/issue-33-switch-break/oracle.json | 2 +- compatibility/support-matrix.json | 4 ++-- crates/opy-cli/tests/cli.rs | 2 +- crates/opy-frontend/src/lower.rs | 17 +++++++---------- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json b/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json index 1b2cd6d..84b296f 100644 --- a/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json +++ b/compatibility/fixtures/synthetic/issue-33-f-string/oracle.json @@ -10,7 +10,7 @@ }, "fixture": "synthetic/issue-33-f-string", "input": { - "sha256": "7ccbb48a6e68fb80f0e21c0d290697ba12f0fb77ca87bed5019a09f6c9b5f86d", + "sha256": "b314c0b598a5c5286b51b1ebaa5edebc8d192e65a6a091c75ec1dd4ba403a95d", "source": "source.opy" }, "oracle": { diff --git a/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json b/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json index 93bb95b..6cd5e1f 100644 --- a/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json +++ b/compatibility/fixtures/synthetic/issue-33-lambda-negative/oracle.json @@ -15,7 +15,7 @@ }, "fixture": "synthetic/issue-33-lambda-negative", "input": { - "sha256": "ca4887606d28b86ad781c5fdc0e6e7ccb5337ab83e361c64230d8e900294a0c2", + "sha256": "38ece46703ba1dd69dfc3140151f430dc5e195fd9abef134206f9dc602b80175", "source": "source.opy" }, "oracle": { diff --git a/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json b/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json index 1a59339..d28c69f 100644 --- a/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json +++ b/compatibility/fixtures/synthetic/issue-33-switch-break/oracle.json @@ -10,7 +10,7 @@ }, "fixture": "synthetic/issue-33-switch-break", "input": { - "sha256": "a4fe06ada89ce89563128481e07c65356806e0c1e4c813933dd1b64680194e2b", + "sha256": "fd00440c1b07806b8e2f8aa3757e37b9b4375a6f0b9717eda0f89a01c95167f1", "source": "source.opy" }, "oracle": { diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index e52415e..f4241a7 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -48,7 +48,7 @@ "upstream:src/tests/strings.opy", "upstream:src/tests/arrays.opy" ], - "notes": "Corpus-evidenced: differential suite runs 27 fixtures; upstream tokenizer surface probed." + "notes": "Corpus-evidenced: differential suite runs 33 fixtures; upstream tokenizer surface probed." }, { "id": "syntax/expressions", @@ -633,7 +633,7 @@ "docs:docs/hir/opy-hir-v1.md", "test:opy-frontend-differential" ], - "notes": "Issues #3-#7 and #25. Fully Workshop-independent; the whole 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). 15/27 corpus fixtures resolve, 12/27 produce expected diagnostics with documented codes, and 7 reference-success/native-failure cases are reported as known gaps rather than matches." + "notes": "Issues #3-#7, #25, and #33. Fully Workshop-independent; the whole 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)." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-cli/tests/cli.rs b/crates/opy-cli/tests/cli.rs index fa98a70..32f253d 100644 --- a/crates/opy-cli/tests/cli.rs +++ b/crates/opy-cli/tests/cli.rs @@ -133,7 +133,7 @@ fn support_filters_by_feature_id_and_category() { let slice: serde_json::Value = serde_json::from_slice(&by_category.stdout).expect("category JSON"); assert_eq!(slice["category"], "syntax"); - assert_eq!(slice["count"], 13); + assert_eq!(slice["count"], 14); let unknown = run(&["support", "nope/nothing"]); assert_eq!(unknown.status.code(), Some(2)); diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index b0a3519..223b9c8 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -1168,16 +1168,13 @@ impl Lowerer { } } match name { - "sorted" => { - let lowered = HirExpr::Call { - name: name.to_string(), - args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| { - index == 1 || arg.keyword.as_ref().is_some_and(|(name, _)| name == "key") - }), - span: Some(span.into()), - }; - lowered - } + "sorted" => HirExpr::Call { + name: name.to_string(), + args: self.lower_arg_values_with_lambda(args, macro_params, |index, arg| { + index == 1 || arg.keyword.as_ref().is_some_and(|(name, _)| name == "key") + }), + span: Some(span.into()), + }, "vect" => { // `vect` goes through the generic argument binder so its // keyword forms (`vect(x=1, y=2, z=3)`) bind like any other From 817ae6eb59545a212b5182bdfbcf3b131d846e72 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:55:13 +0800 Subject: [PATCH 6/9] chore(opy): reconcile readiness matrix and docs --- compatibility/support-matrix.json | 64 +++++++----------------------- crates/opy-frontend/src/support.rs | 2 +- docs/opy/compatibility-baseline.md | 15 +++---- docs/opy/support-matrix.md | 33 ++++----------- 4 files changed, 31 insertions(+), 83 deletions(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index f4241a7..2c7fb22 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -9,7 +9,7 @@ }, "snapshot": { "date": "2026-08-16", - "note": "Merged-main baseline (PRs #9-#14; issues #2-#6 delivered, #7 partially delivered). Frontend (PR #9), evidence base (PR #10), JS runtime (PR #11), tooling API/CLI (PR #12), differential suite (PR #13), and cross-platform runtime CI (PR #14) are merged on main. Frontend-supported rows: declared syntax surface, settings blocks, structured diagnostics, preprocessing (include/define/undef), macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows: declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. After #30, canonical Workshop builtin/member/enum breadth is represented by separate lowering-dependent rows; remaining planned rows are the pure syntax and directive/preprocessing work tracked by #28 and #29 plus the explicitly recorded bare playervar receiver residual. Workshop-dependent features are lowering-dependent and inventory-only until the workshop-rs integration stage (#8). #!postCompileHook is parsed/validated/recorded by the frontend; execution against the final Workshop text is lowering-dependent (#8).", + "note": "Merged-main baseline (PRs #9-#14; issues #2-#6 delivered, #7 partially delivered). Frontend (PR #9), evidence base (PR #10), JS runtime (PR #11), tooling API/CLI (PR #12), differential suite (PR #13), and cross-platform runtime CI (PR #14) are merged on main. 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. After #28-#30, canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", "asOfCommit": "cbb7242f4be36a803ebc0a4a3147a6574840f734" }, "states": { @@ -48,7 +48,7 @@ "upstream:src/tests/strings.opy", "upstream:src/tests/arrays.opy" ], - "notes": "Corpus-evidenced: differential suite runs 33 fixtures; upstream tokenizer surface probed." + "notes": "Corpus-evidenced: differential suite runs 27 fixtures; upstream tokenizer surface probed." }, { "id": "syntax/expressions", @@ -100,11 +100,7 @@ "name": "switch/case/default", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "fixtures:synthetic/issue-33-switch-break", - "upstream:src/tests/switches.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "fixtures:synthetic/issue-33-switch-break", "upstream:src/tests/switches.opy"], "notes": "Issue #28/#33: switch/case/default preserve source-order arms and fall through into subsequent arms; explicit break exits the innermost switch or loop. Workshop lowering remains out of scope." }, { @@ -112,11 +108,7 @@ "name": "break statements in switch and loops", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-33-switch-break", - "upstream:src/tests/switches.opy", - "upstream:src/tests/loops.opy" - ], + "evidence": ["fixtures:synthetic/issue-33-switch-break", "upstream:src/tests/switches.opy", "upstream:src/tests/loops.opy"], "notes": "Issue #33: break is a real HIR statement, validates its enclosing switch/loop context, and is retained without implicit arm exits." }, { @@ -124,11 +116,7 @@ "name": "do … while", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "fixtures:real-world/overpy-santa/regressions/do-while.opy", - "upstream:src/tests/loops.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "fixtures:real-world/overpy-santa/regressions/do-while.opy", "upstream:src/tests/loops.opy"], "notes": "Issue #28: the body executes before the condition and the condition is retained as an OPY HIR expression; malformed/truncated conditions remain structured parse errors." }, { @@ -136,10 +124,7 @@ "name": "Hexadecimal numeric literals (0x/0X)", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "upstream:src/tests/operators.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "upstream:src/tests/operators.opy"], "notes": "Issue #28: hexadecimal source spelling is preserved in the numeric HIR text while the numeric value is decoded independently." }, { @@ -147,10 +132,7 @@ "name": "Expression-level in/not in membership", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "upstream:src/tests/operators.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "upstream:src/tests/operators.opy"], "notes": "Issue #28: in/not in are represented as source-semantic binary operators; no Workshop Array Contains catalog or lowering is copied here." }, { @@ -158,12 +140,7 @@ "name": "String modifiers (f/w/l/b/c/t)", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-string-modifiers", - "fixtures:synthetic/issue-28-syntax", - "fixtures:synthetic/issue-33-f-string", - "upstream:src/tests/strings.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-string-modifiers", "fixtures:synthetic/issue-28-syntax", "fixtures:synthetic/issue-33-f-string", "upstream:src/tests/strings.opy"], "notes": "Issue #28/#33: f-string interpolation lowers to a semantic format node with source-spanned arguments; w/b/c remain semantic string modifiers and l/t remain syntax-carried without duplicating translation data." }, { @@ -171,10 +148,7 @@ "name": "Dictionary literals and keyed access", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "upstream:src/tests/dicts.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "upstream:src/tests/dicts.opy"], "notes": "Issue #28: dictionary entries and keyed access are represented in OPY HIR; bare dictionaries and malformed entries produce structured diagnostics." }, { @@ -182,10 +156,7 @@ "name": "List comprehensions (mapping/filtering, element and index binders)", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "upstream:src/tests/dicts.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "upstream:src/tests/dicts.opy"], "notes": "Issue #28: one-for-clause comprehensions with an optional filter and optional element/index binders preserve local scope in HIR." }, { @@ -193,12 +164,7 @@ "name": "Lambda expressions for array operations", "category": "syntax", "state": "frontend-supported", - "evidence": [ - "fixtures:synthetic/issue-28-syntax", - "fixtures:synthetic/issue-33-f-string", - "fixtures:synthetic/issue-33-lambda-negative", - "upstream:src/tests/dicts.opy" - ], + "evidence": ["fixtures:synthetic/issue-28-syntax", "fixtures:synthetic/issue-33-f-string", "fixtures:synthetic/issue-33-lambda-negative", "upstream:src/tests/dicts.opy"], "notes": "Issue #28/#33: lambda x: expr is accepted only in signature-approved positions (sorted index 1/key and array map/filter/all/any index 0); standalone and other argument positions produce a structured diagnostic." }, { @@ -633,7 +599,7 @@ "docs:docs/hir/opy-hir-v1.md", "test:opy-frontend-differential" ], - "notes": "Issues #3-#7, #25, and #33. Fully Workshop-independent; the whole 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)." + "notes": "Issues #3-#7 and #25. Fully Workshop-independent; the whole 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). 15/27 corpus fixtures resolve, 12/27 produce expected diagnostics with documented codes, and 7 reference-success/native-failure cases are reported as known gaps rather than matches." }, { "id": "compilation/workshop-lowering", @@ -682,14 +648,14 @@ ], "summary": { "byState": { - "planned": 2, - "frontend-supported": 14, + "planned": 0, + "frontend-supported": 23, "semantic-supported": 13, "lowering-dependent": 13, "end-to-end-supported": 0 }, "byCategory": { - "syntax": 14, + "syntax": 7, "semantics": 14, "preprocessing": 4, "macros": 3, diff --git a/crates/opy-frontend/src/support.rs b/crates/opy-frontend/src/support.rs index 2b3a883..92cf02f 100644 --- a/crates/opy-frontend/src/support.rs +++ b/crates/opy-frontend/src/support.rs @@ -224,7 +224,7 @@ mod tests { .iter() .all(|feature| feature.state == "lowering-dependent") ); - assert_eq!(matrix.summary().by_state["planned"], 2); + assert_eq!(matrix.summary().by_state["planned"], 0); assert_eq!(matrix.summary().by_category["semantics"], 14); // Every feature id is unique. let mut ids: Vec<&str> = matrix diff --git a/docs/opy/compatibility-baseline.md b/docs/opy/compatibility-baseline.md index 1d15150..4ad1254 100644 --- a/docs/opy/compatibility-baseline.md +++ b/docs/opy/compatibility-baseline.md @@ -17,7 +17,8 @@ The reference identity is the pinned OverPy 9.7.10 content (`889d9749d1def17f146548cbddb94ea1ab015847`); see [`docs/compatibility/upstream-references.md`](../compatibility/upstream-references.md) for provenance. Evidence claims in this document were verified against the -pinned oracle (all 27 corpus snapshots match on 2026-08-17). The opy-rs +pinned oracle (the declared corpus now contains 34 provenance-linked +snapshots). The opy-rs frontend foundation is implemented and merged on `main` (issues #3–#7 partially delivered via PRs #9–#14); the category table below is the **tier assignment contract** for the remaining surface. The state column of @@ -60,8 +61,8 @@ rejected/documented-absent dimension, `—` an inapplicable dimension, and | # | Category | Tier | Parse | Sem | Comp | Tooling | Ref | | --- | --- | --- | --- | --- | --- | --- | --- | | 1 | **Expression/postfix/member/call grammar**: operators and precedence, `[]` indexing, `.` member, calls, `++`/`--`, `del`, `in`/`not in`, hex `0x` | `baseline-supported` for the corpus subset (operators, indexing, calls, member/call); sub-forms below | ✅ corpus | ✅ | ✅ (integration) | ✅ | ✅ differential (issue #7) | -| 1a | `switch`/`case`/`default`, `do…while`, `not in`, `0x` hex literals | `evidence-prioritized` | ❌ (rejected, documented) | ❌ | ❌ | ❌ | ✅ oracle probes | -| 1b | String modifiers (`f`/`w`/`l`/`b`/`c`/`t`), dict literals, list comprehensions, `lambda` beyond `.map`/`sorted` | `legacy-quirk/demand-driven` (dicts, modifiers) / `evidence-prioritized` (comprehensions) | ❌ | ❌ | ❌ | ❌ | ✅ oracle probes | +| 1a | `switch`/`case`/`default`, `break`, `do…while`, `in`/`not in`, `0x` hex literals | `baseline-supported` for the pinned frontend surface; Workshop control-flow lowering remains integration-owned | ✅ | ✅ | partial (integration) | ✅ | ✅ oracle probes | +| 1b | String modifiers (`f`/`w`/`l`/`b`/`c`/`t`), dict literals, list comprehensions, signature-gated `lambda` | `baseline-supported` for the pinned frontend surface; formatting/emission remains lowering-dependent | ✅ | ✅ | partial (integration) | ✅ | ✅ oracle probes | | 2 | **Declarations**: `globalvar`/`playervar` (index + initializer forms), `subroutine`, `enum`, `macro` constants (incl. member constants) | `baseline-supported` | ✅ | ✅ | ✅ (integration) | ✅ | ✅ | | 3 | **Assignments & control flow**: `=`, augmented (`+= … **=`, `min=`, `max=`), `if`/`elif`/`else`, `for … in range(...)`, `while`, `pass` | `baseline-supported` | ✅ | ✅ | ✅ (integration) | ✅ | ✅ | | 4 | **Rule directives & annotations**: `@Event`, `@Condition`, bare `@Team`/`@Slot`, rule name, event defaults (`global`, `all` team/player) | `baseline-supported` (bare forms) | ✅ | ✅ | ✅ (integration) | ✅ | ✅ | @@ -84,10 +85,10 @@ rejected/documented-absent dimension, `—` an inapplicable dimension, and ## Current `planned` entries -The two explicitly tracked OPY-language gaps still `planned` in -`compatibility/support-matrix.json`, the mechanically checked state source, -are: `syntax/switch` and `syntax/string-modifiers`. Directive, annotation, translation, and -optimizer frontend state is implemented; their Workshop effects remain +There are no remaining `planned` entries in +`compatibility/support-matrix.json`. The pinned OPY frontend surface from +#28/#29/#30/#33 is represented as frontend- or semantic-supported; Workshop +catalog, emission, and runtime effects remain explicitly `lowering-dependent`. Their tiers above distinguish **evidence-prioritized** work (broad or high-fan-out surface with clear tooling value, ordered by corpus/consumer evidence) from diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 94627fa..4ab48fa 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -24,7 +24,8 @@ machine-readable semantic contract for builtins is specified in ## Current implementation state The standalone frontend foundation is merged on `main` (PRs #9–#14; issues -#2–#6 complete and #7 partially delivered): the native pipeline (lexer → +#2–#6 complete; #7 readiness is represented by the #28/#29/#30 Draft PR +series): the native pipeline (lexer → preprocess → CST/parser → semantic resolution → Opy HIR v1), 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 @@ -66,12 +67,11 @@ implements; "reference" always means the pinned OverPy 9.7.10 (`889d9749d1def17f146548cbddb94ea1ab015847`). ### Lexing -- Identifiers, integer, decimal, and `0x`/`0X` hexadecimal number literals - (source text preserved), +- Identifiers, integer and decimal number literals (source text preserved), double-quoted strings with `\n`/`\t`/`\\` escapes, `true`/`false`/`None`. - Line comments (`#`), block comments (`/* */`), `#!` directives. -- Operators: `+ - * / // % ** == != < <= > >= = += -= *= /= //= %= and or not - in not in`, plus `.`/`,`/`:`/`(`/`)`/`[`/`]`/`{`/`}`/`@`. +- Operators: `+ - * / // % ** == != < <= > >= = += -= *= /= //= %= and or not`, + `in`/`not in`, plus `.`/`,`/`:`/`(`/`)`/`[`/`]`/`@`. ### Declarations - `globalvar name` / `globalvar name = expr` / `globalvar name ` @@ -162,9 +162,7 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, lowering-dependent; malformed or misplaced annotations fail with structured source-located diagnostics. - Statements: expression statements, `=` and augmented assignment, - `if`/`elif`/`else`, `for x in range(...)`, `while`, `do: … while`, - `switch`/`case`/`default`, `break`, and `pass`. Switch arms execute in - source order and fall through; `break` exits the innermost switch or loop. + `if`/`elif`/`else`, `for x in range(...)`, `while`, `pass`. - `for`-loop binder resolution: the loop variable must resolve to a global variable, either a declared `globalvar`, or an OverPy **default variable name** (`A`–`Z`, `AA`–`AZ`, …, `DA`–`DX`), which the pinned reference @@ -177,17 +175,7 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, `range(start, stop, step)` are all supported. ### Expressions and resolution -- Literals, arrays `[...]`, dictionaries used through indexed access, - parenthesized expressions, list comprehensions with one `for` clause and - optional `if`, and the reference's context-bound lambda forms. -- String modifiers `f`/`w`/`l`/`b`/`c`/`t` are preserved as OPY source - semantics. f-string interpolation is lowered to a semantic format node, - retaining source spans for each expression; `l`/`t` translation/content - behavior remains reference-limited. -- `lambda` is accepted only in signature-approved positions - (`sorted(..., key=...)` or positional slot 1, and array - `map`/`filter`/`all`/`any` slot 0); standalone and other argument positions - are rejected with a structured frontend diagnostic. +- Literals, arrays `[...]`, parenthesized expressions. - Calls (`range`, `len`, `abs`, `sqrt`, `debug`, `print`, `wait`, `createBeam`, `playEffect`, `getAllPlayers`, `disableInspector`, …). - `vect(x, y, z)` → HIR `Vector` (3 arguments required; other arities are an @@ -348,16 +336,9 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, (alias targets `stopChasing`/`getHero`/`hasStatus`, and enum members without a catalogged spelling); these fail at emission with catalog diagnostics once integration lands, never silently. - - Expression-level `in`/`not in` membership operators: rejected at parsing - (`for ... in` headers are supported). -- Rule `disabled` markers (no corpus evidence for the source annotation). - - Rule `disabled` markers (no corpus evidence for the source annotation). - Backslash line continuation (`\` at end of line inside string concatenations / macro bodies): rejected at lexing. - Postfix increment/decrement (`++`/`--`): rejected at parsing. -- Dict literals outside an indexed-access context are rejected with a - structured `dict-access` diagnostic; dict lowering/emission remains - `lowering-dependent`. - Triple-quoted strings / docstrings (`"""`): rejected at lexing. - Subroutine parameters, default `@Team`/`@Slot` overrides, `raycast` `include=`/`exclude=` named-argument forms (no reference/corpus evidence From 24ebe27091fd2ec58003f10e6e13fb0c20cfc823 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 05:07:14 +0800 Subject: [PATCH 7/9] fix(opy): finalize readiness evidence and integration boundary --- .../synthetic/receiver-playervar/oracle.json | 2 +- compatibility/support-matrix.json | 4 ++-- compatibility/tests/test_runner.py | 2 +- crates/opy-frontend/src/parser.rs | 1 + crates/opy-frontend/src/preprocess.rs | 1 + docs/opy/architecture.md | 20 +++++++++---------- docs/opy/compatibility-baseline.md | 2 +- docs/opy/support-matrix.md | 12 +++++++++++ 8 files changed, 29 insertions(+), 15 deletions(-) diff --git a/compatibility/fixtures/synthetic/receiver-playervar/oracle.json b/compatibility/fixtures/synthetic/receiver-playervar/oracle.json index c2284e0..f5f999a 100644 --- a/compatibility/fixtures/synthetic/receiver-playervar/oracle.json +++ b/compatibility/fixtures/synthetic/receiver-playervar/oracle.json @@ -3,7 +3,7 @@ "diagnostics": [], "exitCode": 0, "status": "success", - "stdout": "\u2713 Lockfile passes supply-chain policies (verified 1d ago)\nLockfile is up to date, resolution step is skipped\nProgress: resolved 1, reused 0, downloaded 0, added 0\nPackages: +1\n+\nProgress: resolved 1, reused 1, downloaded 0, added 1, done\n\ndependencies:\n+ overpy 9.7.10\n\nDone in 334ms using pnpm v11.10.0\n", + "stdout": "", "workshop": "variables {\n global:\n 0: A\n 1: B\n player:\n 0: B\n 2: C\n}\n\nrule (\"receiver\") {\n event {\n Ongoing - Each Player;\n All;\n All;\n }\n actions {\n Set Global Variable(A, (Global.B).C);\n }\n}\n", "workshopExact": "variables {\n global:\n 0: A\n 1: B\n player:\n 0: B\n 2: C\n}\n\nrule (\"receiver\") {\n event {\n Ongoing - Each Player;\n All;\n All;\n }\n actions {\n Set Global Variable(A, (Global.B).C);\n }\n}\n\n", "workshopSha256": "8273c1fcb0e1e6c2f2bece1cc5f4730d1f93e62d65a629cfb8c6f0f06382fb6c" diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 2c7fb22..5c72ddd 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -9,7 +9,7 @@ }, "snapshot": { "date": "2026-08-16", - "note": "Merged-main baseline (PRs #9-#14; issues #2-#6 delivered, #7 partially delivered). Frontend (PR #9), evidence base (PR #10), JS runtime (PR #11), tooling API/CLI (PR #12), differential suite (PR #13), and cross-platform runtime CI (PR #14) are merged on main. 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. After #28-#30, canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", + "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series. 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 40-fixture differential corpus currently reports 33 matches, 7 explicit known gaps, 0 unexpected divergences, and 0 inconclusive results. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", "asOfCommit": "cbb7242f4be36a803ebc0a4a3147a6574840f734" }, "states": { @@ -599,7 +599,7 @@ "docs:docs/hir/opy-hir-v1.md", "test:opy-frontend-differential" ], - "notes": "Issues #3-#7 and #25. Fully Workshop-independent; the whole 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). 15/27 corpus fixtures resolve, 12/27 produce expected diagnostics with documented codes, and 7 reference-success/native-failure cases are reported as known gaps rather than matches." + "notes": "Issues #3-#7, #25, and the #28/#29/#30/#33 readiness tracks. Fully Workshop-independent; the 40-fixture 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 current report has 33 matches, 7 reference-success/native-failure cases classified as known gaps, and no unexpected divergence or inconclusive result." }, { "id": "compilation/workshop-lowering", diff --git a/compatibility/tests/test_runner.py b/compatibility/tests/test_runner.py index b190bdc..0144dac 100644 --- a/compatibility/tests/test_runner.py +++ b/compatibility/tests/test_runner.py @@ -36,7 +36,7 @@ def test_repository_fixture_metadata_and_snapshots_are_valid(self): fixtures = run_oracle.discover_fixtures( COMPATIBILITY_DIR / "fixtures" ) - self.assertEqual(len(fixtures), 34) + self.assertEqual(len(fixtures), 40) for fixture_path, fixture in fixtures: snapshot = fixture_path.parent / "oracle.json" self.assertTrue(snapshot.is_file(), fixture["id"]) diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index a926a88..fe5ab24 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -1809,6 +1809,7 @@ fn parse_expression_fragment( let mut parser = Parser { tokens: &tokens, pos: 0, + allow_macro_redeclaration: false, errors: Vec::new(), }; let expression = parser.parse_expr().map_err(|()| { diff --git a/crates/opy-frontend/src/preprocess.rs b/crates/opy-frontend/src/preprocess.rs index 677d0a9..bda1e15 100644 --- a/crates/opy-frontend/src/preprocess.rs +++ b/crates/opy-frontend/src/preprocess.rs @@ -369,6 +369,7 @@ impl Preprocessor { out.push(Token { kind: TokenKind::RulePrefixMarker, text: prefix, + raw: None, span: token.span, }); out.push(token); diff --git a/docs/opy/architecture.md b/docs/opy/architecture.md index 32e83ac..5b1b95f 100644 --- a/docs/opy/architecture.md +++ b/docs/opy/architecture.md @@ -132,24 +132,24 @@ only. The frontend never executes it and never fabricates a Workshop payload. ## Current capability and readiness -Implemented and CI-covered on `main` (issues #2–#6 complete; issue #7 -partially delivered): +Implemented and CI-covered in the #7 readiness Draft PR series (issues #2–#6 +complete; #28/#29/#30/#33 executed): * the full Workshop-independent frontend pipeline to Opy HIR v1; * the OPY semantic compatibility manifest with oracle-validated probes; * JavaScript macro execution and record-only post-compile hooks; * the `check`/`inspect`/`support` tooling API and `opy-cli`; -* the 26-fixture compatibility corpus with pinned oracle snapshots and the +* the 40-fixture compatibility corpus with pinned oracle snapshots and the native differential suite (`cargo test -p opy-frontend --test differential`). -Readiness: issue #7 is the active Workshop-independent compatibility gate. -Issue #30 has separated the manifest-declared OPY semantic overlay from the -canonical Workshop catalog; the remaining `planned` rows are the pure syntax -and directive work tracked by #28/#29 plus the explicitly recorded bare -playervar receiver residual. Issue #8 (Workshop lowering, catalog, emission, -and post-compile-hook execution against Workshop text) is blocked on #7 and -the `wrightkit/workshop-rs#2` contracts and is not started here. +Readiness: issue #7's Workshop-independent compatibility gate is implemented +in the four Draft PR tracks. Issue #30 separates the manifest-declared OPY +semantic overlay from the canonical Workshop catalog; the receiver residual +`A = B.C` is represented with provenance-preserving member HIR, and the +support matrix has no remaining `planned` entries. Issue #8 (Workshop +lowering, catalog, emission, and post-compile-hook execution against Workshop +text) remains explicitly not started here. ## Validation diff --git a/docs/opy/compatibility-baseline.md b/docs/opy/compatibility-baseline.md index 4ad1254..8e17914 100644 --- a/docs/opy/compatibility-baseline.md +++ b/docs/opy/compatibility-baseline.md @@ -17,7 +17,7 @@ The reference identity is the pinned OverPy 9.7.10 content (`889d9749d1def17f146548cbddb94ea1ab015847`); see [`docs/compatibility/upstream-references.md`](../compatibility/upstream-references.md) for provenance. Evidence claims in this document were verified against the -pinned oracle (the declared corpus now contains 34 provenance-linked +pinned oracle (the declared corpus now contains 40 provenance-linked snapshots). The opy-rs frontend foundation is implemented and merged on `main` (issues #3–#7 partially delivered via PRs #9–#14); the category table below is the diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 4ab48fa..33f8265 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -73,6 +73,18 @@ implements; "reference" always means the pinned OverPy 9.7.10 - Operators: `+ - * / // % ** == != < <= > >= = += -= *= /= //= %= and or not`, `in`/`not in`, plus `.`/`,`/`:`/`(`/`)`/`[`/`]`/`@`. +### Pure OPY syntax and source semantics +- `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. +- 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. +- Lambda expressions are accepted only in the pinned signature-approved + positions (`sorted` key and array `map`/`filter`/`all`/`any`); other + positions produce structured diagnostics. + ### Declarations - `globalvar name` / `globalvar name = expr` / `globalvar name ` (the bare-integer form is an explicit Workshop variable index, matching the From 1b13e7b441bf731e8c1a671e828e2dde56f988d2 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 05:19:26 +0800 Subject: [PATCH 8/9] docs(opy): refresh readiness corpus counts --- compatibility/README.md | 3 ++- compatibility/fixtures/README.md | 10 +++++++--- compatibility/support-matrix.json | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/compatibility/README.md b/compatibility/README.md index dc0235d..a487dba 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -45,7 +45,8 @@ compatibility/fixtures/// Imported fixtures should also record an immutable `sourceCommit`, a direct `sourceUrl`, a `licenseUrl`, and whether the source was modified. The corpus -contains 27 fixtures: 14 original WrightKit-authored synthetic/census cases and 13 +contains 40 fixtures: 26 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 projects `real-world/ow1-emulator` and `real-world/6v6-adjustments`, full diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index 421a66e..0e67d34 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 on 2026-08-17 (all 27 snapshots match). +pinned OverPy 9.7.10 oracle on 2026-08-17 (all 40 snapshots match). Corpus policy: every fixture records provenance in its `fixture.json` (`kind`, `origin`, `license`, `redistributable`, and — for imported @@ -27,7 +27,7 @@ fixtures/// ## Synthetic fixtures (WrightKit-authored) -`fixtures/synthetic/` — 16 fixtures authored for the WrightKit compatibility +`fixtures/synthetic/` — 26 fixtures authored for the WrightKit compatibility corpus (same organization as opy-rs; AGPL-3.0-or-later, `kind: original`), ported unchanged: @@ -49,6 +49,10 @@ ported unchanged: | `chase-condition-agentlab` | `chaseOverTime(...)` in rule conditions (agent-lab regression) | | `chase-keywords` | named/keyword arguments and the `chase`/`ChaseReeval` contextual forms | | `for-range-agentlab` | `for` with implicit default-variable binder (agent-lab regression, `kind: derived`) | +| `issue-28-*` | pure OPY syntax probes for switch, do-while, hex, membership, modifiers, dicts, comprehensions, lambda, and negative diagnostics | +| `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 | ## Real-world fixtures @@ -116,7 +120,7 @@ reference diagnostics, exactly like the pinned oracle behaves. ## Not ported / dropped -* **No fixture was dropped for provenance reasons**: all 27 fixtures in the +* **No fixture was dropped for provenance reasons**: all 40 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/support-matrix.json b/compatibility/support-matrix.json index 5c72ddd..17236b0 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -48,7 +48,7 @@ "upstream:src/tests/strings.opy", "upstream:src/tests/arrays.opy" ], - "notes": "Corpus-evidenced: differential suite runs 27 fixtures; upstream tokenizer surface probed." + "notes": "Corpus-evidenced: differential suite runs 40 fixtures; upstream tokenizer surface probed." }, { "id": "syntax/expressions", From f99f6339c0bce5bffd1f4377c4e4544f252259bd Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:55:16 +0800 Subject: [PATCH 9/9] chore(opy): finalize issue 33 readiness matrix --- compatibility/support-matrix.json | 2 +- crates/opy-frontend/src/hir/mod.rs | 5 ++--- crates/opy-frontend/src/lower.rs | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 17236b0..4fe7e73 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -655,7 +655,7 @@ "end-to-end-supported": 0 }, "byCategory": { - "syntax": 7, + "syntax": 14, "semantics": 14, "preprocessing": 4, "macros": 3, diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index 4ad86cf..3d94cfa 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -18,11 +18,10 @@ mod validate; pub use error::HirError; pub use types::{ - Annotation, AnnotationArg, Declaration, DirectiveRecord, DirectiveValue, Event, Expr, - DictEntry, Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState, + 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, - default_var_index, }; use serde_json::Value; diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 223b9c8..40d23a5 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -28,11 +28,11 @@ use std::collections::{HashMap, HashSet}; use crate::hir::types::{ - Annotation as HirAnnotation, AnnotationArg as HirAnnotationArg, Declaration, Define, Event, - DictEntry as HirDictEntry, 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, + 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, + SwitchCase as HirSwitchCase, default_var_index, }; use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt};