From f5b21a0e8eccb7917204c3fdb591f0e6e58aa450 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:29:42 +0800 Subject: [PATCH 1/7] feat(opy): complete directive preprocessing semantics (Fixes #29) --- compatibility/differential-expectations.json | 3 + .../issue-29-directives/fixture.json | 14 + .../synthetic/issue-29-directives/oracle.json | 27 + .../synthetic/issue-29-directives/source.opy | 35 ++ .../synthetic/issue-29-invalid/fixture.json | 14 + .../synthetic/issue-29-invalid/oracle.json | 32 ++ .../synthetic/issue-29-invalid/source.opy | 6 + .../synthetic/issue-29-main-file/child.opy | 5 + .../synthetic/issue-29-main-file/entry.opy | 6 + .../synthetic/issue-29-main-file/fixture.json | 14 + .../synthetic/issue-29-main-file/oracle.json | 27 + .../synthetic/issue-29-main-file/source.opy | 1 + compatibility/support-matrix.json | 66 ++- compatibility/tests/test_runner.py | 2 +- crates/opy-frontend/src/cst.rs | 22 + crates/opy-frontend/src/hir/mod.rs | 6 +- crates/opy-frontend/src/hir/types.rs | 101 ++++ crates/opy-frontend/src/lexer.rs | 3 + crates/opy-frontend/src/lower.rs | 46 +- crates/opy-frontend/src/parser.rs | 343 ++++++++++++- crates/opy-frontend/src/preprocess.rs | 467 +++++++++++++++++- crates/opy-frontend/src/support.rs | 4 +- crates/opy-frontend/src/tooling.rs | 23 +- crates/opy-frontend/tests/differential.rs | 18 + docs/opy/compatibility-baseline.md | 14 +- docs/opy/support-matrix.md | 20 +- 26 files changed, 1238 insertions(+), 81 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-29-directives/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-29-directives/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-29-directives/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-29-invalid/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-29-invalid/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-29-invalid/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-29-main-file/child.opy create mode 100644 compatibility/fixtures/synthetic/issue-29-main-file/entry.opy create mode 100644 compatibility/fixtures/synthetic/issue-29-main-file/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-29-main-file/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-29-main-file/source.opy diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 075f51a..2fdf5dc 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -13,6 +13,9 @@ {"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."}, {"fixture": "synthetic/preprocessing", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/preprocessing/oracle.json", "implementation-invariant:preprocessor-include-define-undef"], "note": "Include, object-like and function-like defines, and undef are preserved through preprocessing."}, + {"fixture": "synthetic/issue-29-directives", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-directives/oracle.json", "implementation-invariant:issue-29-preprocessing-state"], "note": "Advanced directive state and source annotations resolve without executing Workshop optimizer, localization, or emission effects."}, + {"fixture": "synthetic/issue-29-invalid", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-29-invalid/oracle.json", "implementation-invariant:issue-29-structured-directive-diagnostics"], "note": "Malformed preprocessing and annotation forms remain source-located structured diagnostics."}, + {"fixture": "synthetic/issue-29-main-file", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-main-file/oracle.json", "implementation-invariant:issue-29-main-file-scope"], "note": "mainFile redirects the frontend entry point and preserves child-include prefix state without copying catalog data."}, {"fixture": "synthetic/settings", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/settings/oracle.json", "implementation-invariant:settings-structure-only"], "note": "Settings are structurally represented; Workshop key and leaf validation remains lowering-dependent."}, {"fixture": "synthetic/receiver-calls", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/receiver-calls/oracle.json", "implementation-invariant:receiver-call-resolution"], "note": "The exercised receiver/member forms resolve through the OPY semantic model."}, {"fixture": "synthetic/receiver-playervar", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/receiver-playervar/oracle.json", "implementation-invariant:receiver-playervar-member-expression"], "note": "A bare variable member expression is retained as an OPY HIR member node; canonical Workshop member validation remains lowering-dependent."}, diff --git a/compatibility/fixtures/synthetic/issue-29-directives/fixture.json b/compatibility/fixtures/synthetic/issue-29-directives/fixture.json new file mode 100644 index 0000000..e8d348f --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-directives/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-29-directives", + "category": "preprocessing", + "features": ["preprocessing/advanced-directives", "preprocessing/translations", "preprocessing/optimization", "annotations/rule-state"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #29 minimized pinned-oracle directive and annotation probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-29-directives/oracle.json b/compatibility/fixtures/synthetic/issue-29-directives/oracle.json new file mode 100644 index 0000000..7f9f657 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-directives/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n 127: __overpyTranslationHelper__\n}\n\nsubroutines {\n 0: helper\n}\n\nrule (\"[] Initialize global variables\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(__overpyTranslationHelper__, String Split(Custom String(\"\uec480White0Blanc\"), First Of(Null)));\n }\n}\n\n//Optimizations disabled\n//Optimizations enabled\n//Optimize for size enabled\n//Optimize for size disabled\n//Strict optimizations enabled\n//Strict optimizations disabled\nrule (\"[Source] renamed helper\") {\n event {\n Subroutine;\n helper;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\ndisabled rule (\"directive annotations\") {\n event {\n Ongoing - Each Player;\n Team 1;\n D.Mon;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n 127: __overpyTranslationHelper__\n}\n\nsubroutines {\n 0: helper\n}\n\nrule (\"[] Initialize global variables\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(__overpyTranslationHelper__, String Split(Custom String(\"\uec480White0Blanc\"), First Of(Null)));\n }\n}\n\n//Optimizations disabled\n//Optimizations enabled\n//Optimize for size enabled\n//Optimize for size disabled\n//Strict optimizations enabled\n//Strict optimizations disabled\nrule (\"[Source] renamed helper\") {\n event {\n Subroutine;\n helper;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\nrule (\"Page\") {\n event {\n Ongoing - Global;\n }\n}\n\ndisabled rule (\"directive annotations\") {\n event {\n Ongoing - Each Player;\n Team 1;\n D.Mon;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n\n", + "workshopSha256": "0bc40fc11a26e600f5407ec8fa4c5422116a777ad561617a05f74e7a68d8cdc1" + }, + "fixture": "synthetic/issue-29-directives", + "input": { + "sha256": "1d2f7a6fc4f2685dfbd928f1c558a7435d7df2d8e40424fe57041b8bef362c52", + "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-29-directives/source.opy b/compatibility/fixtures/synthetic/issue-29-directives/source.opy new file mode 100644 index 0000000..f982f69 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-directives/source.opy @@ -0,0 +1,35 @@ +#!allowMacroRedeclaration +#!translations en fr +#!rulePrefix "Effects" +#!rulePrefixTemplate +#!disableOptimizations +#!enableOptimizations +#!optimizeForSize +#!optimizeForSizeAggressive +#!disableOptimizeForSize +#!optimizeStrict +#!disableOptimizeStrict +#!replace0ByCapturePercentage +#!replace1ByMatchRound +#!replaceTeam1ByControlScoringTeam +#!replaceEmptyStringByEmptyArray +#!define VALUE 1 +#!define VALUE 2 +globalvar value + +subroutine helper + +def helper(): + @Name "renamed helper" + @SuppressWarnings unusedVariable + value = VALUE + +rule "directive annotations": + @Event eachPlayer + @Team 1 + @Hero dmon + @Disabled + @Delimiter + @NewPage "Page" + @SuppressWarnings unusedVariable + value = VALUE diff --git a/compatibility/fixtures/synthetic/issue-29-invalid/fixture.json b/compatibility/fixtures/synthetic/issue-29-invalid/fixture.json new file mode 100644 index 0000000..8c448f8 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-invalid/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-29-invalid", + "category": "preprocessing", + "features": ["preprocessing/directive-diagnostics", "annotations/directive-diagnostics"], + "source": "source.opy", + "expectedStatus": "failure", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #29 malformed directive and annotation probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-29-invalid/oracle.json b/compatibility/fixtures/synthetic/issue-29-invalid/oracle.json new file mode 100644 index 0000000..24c0650 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-invalid/oracle.json @@ -0,0 +1,32 @@ +{ + "compile": { + "diagnostics": [ + { + "severity": "error", + "text": "Error: Rule name was already declared\n | line 5, col 11, at source.opy" + } + ], + "exitCode": 1, + "status": "failure", + "stdout": "", + "workshop": "", + "workshopExact": "", + "workshopSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fixture": "synthetic/issue-29-invalid", + "input": { + "sha256": "2c3977deb6a40d8a4d38f5b4873ce89e388543e967f2643975d449490704d462", + "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-29-invalid/source.opy b/compatibility/fixtures/synthetic/issue-29-invalid/source.opy new file mode 100644 index 0000000..3e09085 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-invalid/source.opy @@ -0,0 +1,6 @@ +globalvar value + +rule "invalid directive": + @Event global + @Name "wrong" + value = 1 diff --git a/compatibility/fixtures/synthetic/issue-29-main-file/child.opy b/compatibility/fixtures/synthetic/issue-29-main-file/child.opy new file mode 100644 index 0000000..a78974a --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-main-file/child.opy @@ -0,0 +1,5 @@ +#!rulePrefix "Child" + +rule "included rule": + @Event global + value = 2 diff --git a/compatibility/fixtures/synthetic/issue-29-main-file/entry.opy b/compatibility/fixtures/synthetic/issue-29-main-file/entry.opy new file mode 100644 index 0000000..f2cc0eb --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-main-file/entry.opy @@ -0,0 +1,6 @@ +globalvar value +#!include "child.opy" + +rule "main-file entry": + @Event global + value = 1 diff --git a/compatibility/fixtures/synthetic/issue-29-main-file/fixture.json b/compatibility/fixtures/synthetic/issue-29-main-file/fixture.json new file mode 100644 index 0000000..0727b94 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-main-file/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-29-main-file", + "category": "preprocessing", + "features": ["preprocessing/main-file", "preprocessing/include-scope"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #29 mainFile and child-include scope probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-29-main-file/oracle.json b/compatibility/fixtures/synthetic/issue-29-main-file/oracle.json new file mode 100644 index 0000000..c688167 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-main-file/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"[Child] included rule\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n\nrule (\"main-file entry\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 1);\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: value\n}\n\nrule (\"[Child] included rule\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 2);\n }\n}\n\nrule (\"main-file entry\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(value, 1);\n }\n}\n\n", + "workshopSha256": "460311f71353f8bcf9d4e2779c9824a70a2c69967370c506233d8a4ee6e68475" + }, + "fixture": "synthetic/issue-29-main-file", + "input": { + "sha256": "0656503322a79c4a7eb9d7fd04bf797b5998ebbed82f0757b15e53d61a8eb2ce", + "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-29-main-file/source.opy b/compatibility/fixtures/synthetic/issue-29-main-file/source.opy new file mode 100644 index 0000000..21d5eea --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-29-main-file/source.opy @@ -0,0 +1 @@ +#!mainFile "entry.opy" diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 11f3fee..773ddc6 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -353,13 +353,26 @@ "id": "preprocessing/advanced-directives", "name": "#!mainFile, #!allowMacroRedeclaration, #!optimize*/#!replace0By* family, #!rulePrefix*", "category": "preprocessing", - "state": "planned", + "state": "semantic-supported", "evidence": [ + "fixtures:synthetic/issue-29-directives", + "fixtures:synthetic/issue-29-main-file", "upstream:src/tests/rulePrefix.opy", "upstream:src/tests/compression.opy", "upstream:src/data/opy/preprocessing.ts" ], - "notes": "Baseline category 5a: legacy-quirk/demand-driven; explicit rejection until the declared compatibility target requires them." + "notes": "Issue #29: frontend records directive state, macro-redeclaration policy, main-file provenance, and rule-prefix application. Optimizer and generated Workshop effects remain separate." + }, + { + "id": "preprocessing/directive-effects", + "name": "Preprocessing directive effects on Workshop emission and optimization", + "category": "preprocessing", + "state": "lowering-dependent", + "evidence": [ + "fixtures:synthetic/issue-29-directives", + "upstream:src/data/opy/preprocessing.ts" + ], + "notes": "#8/workshop-rs owns optimizer execution, replacement rewrites, generated translation helpers, and final Workshop output; opy-rs does not approximate them." }, { "id": "macros/definitions", @@ -411,7 +424,7 @@ }, { "id": "directives/rule-annotations", - "name": "Rule annotations: @Event, @Condition, bare @Team/@Slot; evidence-prioritized: @Team/@Slot args, @Name, @Hero, @Disabled, @Delimiter, @NewPage, @SuppressWarnings", + "name": "Rule annotations: @Event, @Condition, bare @Team/@Slot", "category": "directives", "state": "frontend-supported", "evidence": [ @@ -421,7 +434,19 @@ "upstream:src/tests/rules.opy", "upstream:README.md" ], - "notes": "Bare @Event/@Condition/@Team/@Slot; argument forms and @Name/@Hero/@Disabled etc. remain planned (no corpus evidence in declared surface)." + "notes": "Core rule annotations and event/condition structure are frontend-owned." + }, + { + "id": "directives/advanced-rule-annotations", + "name": "Rule annotations: @Team/@Slot args, @Name, @Hero, @Disabled, @Delimiter, @NewPage, @SuppressWarnings", + "category": "directives", + "state": "semantic-supported", + "evidence": [ + "fixtures:synthetic/issue-29-directives", + "fixtures:synthetic/issue-29-invalid", + "upstream:src/data/opy/annotations.ts" + ], + "notes": "Issue #29: annotation arguments, rule metadata, and source diagnostics are retained in OPY HIR; hero/team/slot domain validation and Workshop UI effects remain lowering-dependent." }, { "id": "directives/rule-model", @@ -441,15 +466,16 @@ "id": "translations/directive", "name": "#!translations directive recognition and .po generation surface", "category": "translations", - "state": "planned", + "state": "semantic-supported", "evidence": [ + "fixtures:synthetic/issue-29-directives", "upstream:src/tests/translations.opy", "upstream:src/tests/translations2.opy", "upstream:src/tests/translations.es.po", "upstream:src/tests/translations2.fr.po", "upstream:src/compiler/translations.ts" ], - "notes": "Baseline category 5a: legacy-quirk/demand-driven; directive recognition is frontend-owned; the emitted locale data path is lowering-dependent." + "notes": "Issue #29: language-tag syntax, uniqueness/conflict validation, provenance, and translation state are frontend-owned; locale availability, .po content, and generated Workshop helpers remain lowering-dependent." }, { "id": "translations/locale-emission", @@ -466,12 +492,24 @@ "id": "optimization/controls", "name": "#!optimize*/#!replace0By* family and compression controls", "category": "optimization", - "state": "planned", + "state": "semantic-supported", "evidence": [ + "fixtures:synthetic/issue-29-directives", "upstream:src/tests/compression.opy", "upstream:src/data/opy/preprocessing.ts" ], - "notes": "Baseline category 5a: legacy-quirk/demand-driven. Optimizer choices are not a compatibility contract; only observable semantics count." + "notes": "Issue #29: optimizer/replacement controls are parsed, validated, ordered, and exposed as frontend state; transformation execution is not part of opy-rs." + }, + { + "id": "optimization/backend-effects", + "name": "Optimizer transformations and replacement rewrites", + "category": "optimization", + "state": "lowering-dependent", + "evidence": [ + "fixtures:synthetic/issue-29-directives", + "upstream:src/data/opy/preprocessing.ts" + ], + "notes": "#8/workshop-rs owns optimizer execution and any Workshop element-count or replacement consequences; output-text identity is not required." }, { "id": "optimization/emission-form", @@ -560,20 +598,20 @@ ], "summary": { "byState": { - "planned": 5, + "planned": 2, "frontend-supported": 14, - "semantic-supported": 9, - "lowering-dependent": 11, + "semantic-supported": 13, + "lowering-dependent": 14, "end-to-end-supported": 0 }, "byCategory": { "syntax": 7, "semantics": 14, - "preprocessing": 3, + "preprocessing": 4, "macros": 3, - "directives": 2, + "directives": 3, "translations": 2, - "optimization": 2, + "optimization": 3, "runtime": 1, "compilation": 3, "decompilation": 2 diff --git a/compatibility/tests/test_runner.py b/compatibility/tests/test_runner.py index f724ce9..32935c1 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), 28) + self.assertEqual(len(fixtures), 31) 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/cst.rs b/crates/opy-frontend/src/cst.rs index bf397da..5c2982a 100644 --- a/crates/opy-frontend/src/cst.rs +++ b/crates/opy-frontend/src/cst.rs @@ -112,6 +112,8 @@ pub enum RuleEntry { /// The exact span of the defined identifier token in `def name():`. name_span: Span, body: Vec, + annotations: Vec, + rule_prefix: Option, }, } @@ -123,11 +125,31 @@ pub struct Rule { /// The exact span of the rule name inside its string literal. pub name_span: Span, pub disabled: bool, + pub delimiter: bool, + pub new_page: Option, + pub annotations: Vec, + pub rule_prefix: Option, pub event: Event, pub conditions: Vec, pub actions: Vec, } +/// A source annotation retained for tooling and provenance. +#[derive(Debug, Clone)] +pub struct Annotation { + pub name: String, + pub args: Vec, + pub span: Span, +} + +/// One raw annotation argument. Values such as heroes, teams, and slots stay +/// opaque here because their canonical domains belong to workshop-rs. +#[derive(Debug, Clone)] +pub struct AnnotationArg { + pub text: String, + pub span: Span, +} + /// A rule event or an `@Event` directive. #[derive(Debug, Clone)] pub struct Event { diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index ecf97c4..f7e782c 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -18,8 +18,10 @@ mod validate; pub use error::HirError; pub use types::{ - Declaration, Event, Expr, Generator, Position, Program, Protocol, Rule, RuleEntry, Settings, - SettingsListElement, SettingsNode, SourceFile, Span, Stmt, default_var_index, + Annotation, AnnotationArg, Declaration, DirectiveRecord, DirectiveValue, Event, Expr, + Generator, OptimizationState, Position, PreprocessingState, Program, Protocol, Rule, RuleEntry, + Settings, SettingsListElement, SettingsNode, SourceFile, Span, Stmt, TranslationState, + default_var_index, }; use serde_json::Value; diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index ca9aeac..649958a 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -109,6 +109,82 @@ pub struct Program { /// The typed custom-game-settings block, when the source had one (#86). #[serde(default, skip_serializing_if = "Option::is_none")] pub settings: Option, + /// Frontend preprocessing state. Workshop execution of optimizer, + /// translation, and replacement choices remains lowering-dependent. + #[serde(default)] + pub preprocessing: PreprocessingState, +} + +/// The source-level preprocessing state observed by the frontend. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct PreprocessingState { + #[serde(skip_serializing_if = "Option::is_none")] + pub main_file: Option, + pub allow_macro_redeclaration: bool, + #[serde(default)] + pub rule_prefix: Option, + #[serde(default)] + pub rule_prefix_template: Option, + #[serde(default)] + pub translations: Option, + #[serde(default)] + pub optimization: OptimizationState, + #[serde(default)] + pub replacements: Vec, + #[serde(default)] + pub suppressed_warnings: Vec, + #[serde(default)] + pub directives: Vec, +} + +/// A directive value plus its source provenance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DirectiveValue { + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, +} + +/// Translation language selection. Locale/catalog data is intentionally not +/// represented here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TranslationState { + pub languages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, +} + +/// Frontend-visible optimization controls. The optimizer itself is outside +/// this repository and remains lowering-dependent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OptimizationState { + pub enabled: bool, + pub for_size: bool, + pub for_size_aggressive: bool, + pub strict: bool, +} + +impl Default for OptimizationState { + fn default() -> Self { + Self { + enabled: true, + for_size: false, + for_size_aggressive: false, + strict: false, + } + } +} + +/// One preprocessing event, retained so block-scoped state transitions remain +/// inspectable without executing a backend optimizer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DirectiveRecord { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + pub scope_col: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, } /// A custom-game-settings block (`settings { ... }`, #86). @@ -252,6 +328,8 @@ pub enum RuleEntry { name_span: Option, #[serde(default)] body: Vec, + #[serde(default)] + annotations: Vec, }, } @@ -266,6 +344,12 @@ pub struct Rule { pub name_span: Option, #[serde(default)] pub disabled: bool, + #[serde(default)] + pub delimiter: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_page: Option, + #[serde(default)] + pub annotations: Vec, pub event: Event, #[serde(default)] pub conditions: Vec, @@ -273,6 +357,23 @@ pub struct Rule { pub actions: Vec, } +/// A source annotation retained on a rule or subroutine definition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Annotation { + pub name: String, + #[serde(default)] + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnnotationArg { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, +} + /// A rule event. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Event { diff --git a/crates/opy-frontend/src/lexer.rs b/crates/opy-frontend/src/lexer.rs index e693f70..5772d98 100644 --- a/crates/opy-frontend/src/lexer.rs +++ b/crates/opy-frontend/src/lexer.rs @@ -18,6 +18,9 @@ pub enum TokenKind { String, /// A `#!` directive line (`text` holds everything after `#!`). Directive, + /// A preprocessing marker carrying the rule-prefix state active at the + /// following top-level rule or subroutine. + RulePrefixMarker, /// `@Event` / `@Condition` / other `@` directives. At, Newline, diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index 084b764..e29e2ba 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -28,10 +28,10 @@ use std::collections::{HashMap, HashSet}; use crate::hir::types::{ - Declaration, Define, Event, Expr as HirExpr, Generator, IfBranch, Position, - Program as HirProgram, Protocol, Rule, RuleEntry, Settings as HirSettings, - SettingsNode as HirSettingsNode, SourceFile, Span as HirSpan, Stmt as HirStmt, - default_var_index, + 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, }; use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt}; @@ -169,13 +169,16 @@ pub fn lower( span, name_span, body, + annotations, + rule_prefix, } => { rules.push(RuleEntry::SubroutineDef { kind: "subroutineDef".to_string(), - name: name.clone(), + name: prefixed_rule_name(name, rule_prefix.as_deref(), false), span: Some(span.into()), name_span: Some(name_span.into()), body: lowerer.lower_block(body, &[]), + annotations: lower_annotations(annotations), }); } } @@ -200,9 +203,37 @@ pub fn lower( declarations, rules, settings: program.settings.as_ref().map(lower_settings), + preprocessing: PreprocessingState::default(), }) } +fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> String { + match prefix { + Some(prefix) if !prefix.is_empty() && !delimiter && !name.is_empty() => { + format!("[{prefix}] {name}") + } + _ => name.to_string(), + } +} + +fn lower_annotations(annotations: &[cst::Annotation]) -> Vec { + annotations + .iter() + .map(|annotation| HirAnnotation { + name: annotation.name.clone(), + args: annotation + .args + .iter() + .map(|arg| HirAnnotationArg { + text: arg.text.clone(), + span: Some(arg.span.into()), + }) + .collect(), + span: Some(annotation.span.into()), + }) + .collect() +} + /// Map a parsed CST settings block onto the protocol settings tree (#86). fn lower_settings(settings: &cst::Settings) -> HirSettings { HirSettings { @@ -301,10 +332,13 @@ impl Lowerer { .collect(); let actions = self.lower_block(&rule.actions, &[]); Rule { - name: rule.name.clone(), + name: prefixed_rule_name(&rule.name, rule.rule_prefix.as_deref(), rule.delimiter), span: Some(rule.span.into()), name_span: Some(rule.name_span.into()), disabled: rule.disabled, + delimiter: rule.delimiter, + new_page: rule.new_page.clone(), + annotations: lower_annotations(&rule.annotations), event: Event { name: rule.event.name.clone(), args: rule diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index a8f9e5a..81ef566 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -7,7 +7,9 @@ //! reported. The returned [`ParseOutput`] carries either a complete program //! or the collected errors (never both). -use crate::cst::{CallArg, Decl, Event, Expr, IfBranch, Program, Rule, RuleEntry, Stmt}; +use crate::cst::{ + Annotation, AnnotationArg, CallArg, Decl, Event, Expr, IfBranch, Program, Rule, RuleEntry, Stmt, +}; use crate::diag::{FrontendError, Position, Span}; use crate::lexer::{Token, TokenKind}; @@ -47,6 +49,24 @@ struct Parser<'a> { errors: Vec, } +fn is_identifier(text: &str) -> bool { + !text.is_empty() + && text.chars().enumerate().all(|(index, ch)| { + if index == 0 { + ch.is_ascii_alphabetic() || ch == '_' + } else { + ch.is_ascii_alphanumeric() || ch == '_' + } + }) +} + +fn unquote_annotation_arg(text: &str) -> String { + text.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(text) + .to_string() +} + impl Parser<'_> { fn peek(&self) -> &Token { &self.tokens[self.pos.min(self.tokens.len() - 1)] @@ -108,7 +128,12 @@ impl Parser<'_> { if self.peek_kind() == TokenKind::Eof { break; } - let ok = self.parse_top_level(&mut declarations, &mut rules); + let rule_prefix = if self.peek_kind() == TokenKind::RulePrefixMarker { + Some(self.advance().text) + } else { + None + }; + let ok = self.parse_top_level(&mut declarations, &mut rules, rule_prefix); if !ok { self.recover_line(); } @@ -124,12 +149,13 @@ impl Parser<'_> { &mut self, declarations: &mut Vec, rules: &mut Vec, + rule_prefix: Option, ) -> bool { let token = self.peek(); if token.kind == TokenKind::Ident { match token.text.as_str() { - "rule" => return self.parse_rule(rules), - "def" => return self.parse_def(rules), + "rule" => return self.parse_rule(rules, rule_prefix), + "def" => return self.parse_def(rules, rule_prefix), "globalvar" => return self.parse_variable(declarations, true), "playervar" => return self.parse_variable(declarations, false), "subroutine" => return self.parse_subroutine(declarations), @@ -357,7 +383,7 @@ impl Parser<'_> { // ---- rules and definitions ---- - fn parse_rule(&mut self, rules: &mut Vec) -> bool { + fn parse_rule(&mut self, rules: &mut Vec, rule_prefix: Option) -> bool { let start = self.advance(); let name = match self.peek_kind() { TokenKind::String => self.advance().text, @@ -394,6 +420,10 @@ impl Parser<'_> { }; let mut event = None; let mut conditions = Vec::new(); + let mut annotations = Vec::new(); + let mut disabled = false; + let mut delimiter = false; + let mut new_page = None; let mut actions = Vec::new(); loop { self.skip_newlines(); @@ -401,7 +431,15 @@ impl Parser<'_> { break; } if self.peek_kind() == TokenKind::At { - if !self.parse_directive(&mut event, &mut conditions) { + if !self.parse_directive( + &mut event, + &mut conditions, + &mut annotations, + &mut disabled, + &mut delimiter, + &mut new_page, + false, + ) { self.recover_line(); } continue; @@ -415,7 +453,11 @@ impl Parser<'_> { name, span: Span::new(start.span.file, start.span.start, name_token_span.end), name_span, - disabled: false, + disabled, + delimiter, + new_page, + annotations, + rule_prefix, event: event.unwrap_or_else(|| Event { name: "global".to_string(), args: Vec::new(), @@ -427,18 +469,39 @@ impl Parser<'_> { true } - fn parse_directive(&mut self, event: &mut Option, conditions: &mut Vec) -> bool { + fn parse_directive( + &mut self, + event: &mut Option, + conditions: &mut Vec, + annotations: &mut Vec, + disabled: &mut bool, + delimiter: &mut bool, + new_page: &mut Option, + subroutine: bool, + ) -> bool { let at = self.advance(); let name = match self.expect_ident("a directive name after '@'") { Ok(name) => name, Err(()) => return false, }; + if matches!( + name.as_str(), + "Event" | "Team" | "Slot" | "Hero" | "Name" | "Disabled" | "Delimiter" | "NewPage" + ) && annotations.iter().any(|annotation| annotation.name == name) + { + self.error_at_current(format!("annotation '@{name}' was already declared")); + return false; + } match name.as_str() { "Event" => { let event_name = match self.expect_ident("an event name after @Event") { Ok(name) => name, Err(()) => return false, }; + let event_annotation_arg = AnnotationArg { + text: event_name.clone(), + span: self.tokens[self.pos.saturating_sub(1)].span, + }; let mut args = Vec::new(); if self.peek_kind() == TokenKind::LParen && self.parse_event_args(&mut args).is_err() @@ -451,26 +514,140 @@ impl Parser<'_> { args, span: Span::new(at.span.file, at.span.start, end), }); + annotations.push(Annotation { + name, + args: vec![event_annotation_arg], + span: Span::new(at.span.file, at.span.start, end), + }); true } - "Condition" => match self.parse_expr() { - Ok(expr) => { - conditions.push(expr); - true + "Condition" => { + let start = self.pos; + match self.parse_expr() { + Ok(expr) => { + let end = self.peek().span.start; + conditions.push(expr); + annotations.push(Annotation { + name, + args: vec![self.raw_annotation_arg(start, self.pos)], + span: Span::new(at.span.file, at.span.start, end), + }); + true + } + Err(()) => false, } - Err(()) => false, - }, - "Team" | "Slot" => { - // Accepted for compatibility; the corpus events use OverPy - // defaults, so explicit values are recorded as args only when - // present. Unsupported arguments fail explicitly. - let _ = self.advance(); - if self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { - self.error_at_current(format!( - "unsupported @{name} directive arguments in the current support matrix" - )); + } + "Team" | "Slot" | "Hero" => { + let args = self.consume_annotation_args(); + if args.len() != 1 { + self.error_at_current(format!("@{name} expects exactly one argument")); + return false; + } + if subroutine { + self.error_at_current(format!("@{name} is not valid on a subroutine")); + return false; + } + if (name == "Slot" + && annotations + .iter() + .any(|annotation| annotation.name == "Hero")) + || (name == "Hero" + && annotations + .iter() + .any(|annotation| annotation.name == "Slot")) + { + self.error_at_current("@Slot and @Hero cannot be used together".to_string()); + return false; + } + let end = self.peek().span.start; + annotations.push(Annotation { + name, + args, + span: Span::new(at.span.file, at.span.start, end), + }); + true + } + "Name" => { + let args = self.consume_annotation_args(); + if args.len() != 1 || !self.annotation_arg_is_string(&args[0]) { + self.error_at_current( + "@Name expects exactly one plain string literal".to_string(), + ); + return false; + } + if !subroutine { + self.error_at_current( + "@Name is only supported on subroutine definitions".to_string(), + ); + return false; + } + let end = self.peek().span.start; + annotations.push(Annotation { + name, + args, + span: Span::new(at.span.file, at.span.start, end), + }); + true + } + "SuppressWarnings" => { + let args = self.consume_annotation_args(); + if args.is_empty() || args.iter().any(|arg| !is_identifier(&arg.text)) { + self.error_at_current( + "@SuppressWarnings expects one or more warning identifiers".to_string(), + ); + return false; + } + let end = self.peek().span.start; + annotations.push(Annotation { + name, + args, + span: Span::new(at.span.file, at.span.start, end), + }); + true + } + "Disabled" => { + if !self.expect_annotation_end("@Disabled") { return false; } + *disabled = true; + annotations.push(Annotation { + name, + args: Vec::new(), + span: at.span, + }); + true + } + "Delimiter" => { + if !self.expect_annotation_end("@Delimiter") { + return false; + } + *delimiter = true; + annotations.push(Annotation { + name, + args: Vec::new(), + span: at.span, + }); + true + } + "NewPage" => { + let args = self.consume_annotation_args(); + if args.len() > 1 + || args + .first() + .is_some_and(|arg| !self.annotation_arg_is_string(arg)) + { + self.error_at_current( + "@NewPage expects at most one plain string literal".to_string(), + ); + return false; + } + let end = self.peek().span.start; + *new_page = args.first().map(|arg| unquote_annotation_arg(&arg.text)); + annotations.push(Annotation { + name, + args, + span: Span::new(at.span.file, at.span.start, end), + }); true } other => { @@ -480,7 +657,68 @@ impl Parser<'_> { } } - fn parse_def(&mut self, rules: &mut Vec) -> bool { + fn consume_annotation_args(&mut self) -> Vec { + let start = self.pos; + while self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { + self.advance(); + } + if self.pos == start { + return Vec::new(); + } + let tokens = &self.tokens[start..self.pos]; + if tokens.len() == 3 + && tokens[1].kind == TokenKind::Dot + && tokens[0].kind == TokenKind::Ident + { + return vec![AnnotationArg { + text: tokens.iter().map(|token| token.text.as_str()).collect(), + span: Span::new( + tokens[0].span.file, + tokens[0].span.start, + tokens[2].span.end, + ), + }]; + } + tokens + .iter() + .map(|token| AnnotationArg { + text: if token.kind == TokenKind::String { + format!("\"{}\"", token.text) + } else { + token.text.clone() + }, + span: token.span, + }) + .collect() + } + + fn raw_annotation_arg(&self, start: usize, end: usize) -> AnnotationArg { + let tokens = &self.tokens[start..end]; + let first = tokens + .first() + .map(|token| token.span) + .unwrap_or(self.peek().span); + let last = tokens.last().map(|token| token.span).unwrap_or(first); + AnnotationArg { + text: tokens.iter().map(|token| token.text.as_str()).collect(), + span: Span::new(first.file, first.start, last.end), + } + } + + fn annotation_arg_is_string(&self, arg: &AnnotationArg) -> bool { + arg.text.starts_with('"') && arg.text.ends_with('"') + } + + fn expect_annotation_end(&mut self, name: &str) -> bool { + if self.peek_kind() == TokenKind::Newline || self.peek_kind() == TokenKind::Eof { + true + } else { + self.error_at_current(format!("{name} takes no arguments")); + false + } + } + + fn parse_def(&mut self, rules: &mut Vec, rule_prefix: Option) -> bool { let start = self.advance(); // The name token follows the `def` keyword. `span` covers the // definition (`def name`), and `name_span` is the exact identifier @@ -516,6 +754,41 @@ impl Parser<'_> { Some(indent) => indent, None => return false, }; + let mut annotations = Vec::new(); + let mut event = None; + let mut conditions = Vec::new(); + let mut disabled = false; + let mut delimiter = false; + let mut new_page = None; + loop { + self.skip_newlines(); + if self.peek_kind() != TokenKind::At { + break; + } + if !self.parse_directive( + &mut event, + &mut conditions, + &mut annotations, + &mut disabled, + &mut delimiter, + &mut new_page, + true, + ) { + self.recover_line(); + return false; + } + } + if event.is_some() || !conditions.is_empty() { + self.error_at_current("subroutines cannot have events or conditions".to_string()); + return false; + } + let _ = (disabled, delimiter, new_page); + let name = annotations + .iter() + .find(|annotation| annotation.name == "Name") + .and_then(|annotation| annotation.args.first()) + .map(|arg| unquote_annotation_arg(&arg.text)) + .unwrap_or(name); let body = self.parse_block(body_indent); let span = if name_token.kind == TokenKind::Ident { Span::new(start.span.file, start.span.start, name_token.span.end) @@ -527,6 +800,8 @@ impl Parser<'_> { span, name_span, body, + annotations, + rule_prefix, }); true } @@ -1387,4 +1662,24 @@ mod tests { Expr::Member { member, .. } if member == "moveSpeed" )); } + + #[test] + fn parses_advanced_rule_annotations_with_source_arguments() { + let program = parse_ok( + "subroutine helper\ndef helper():\n @Name \"renamed\"\n @SuppressWarnings unusedVariable\n pass\nrule \"r\":\n @Event eachPlayer\n @Team 1\n @Hero dmon\n @Disabled\n @Delimiter\n @NewPage \"Page\"\n @SuppressWarnings unusedVariable\n pass\n", + ); + let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else { + panic!("expected subroutine"); + }; + assert_eq!(annotations.len(), 2); + let RuleEntry::Rule(rule) = &program.rules[1] else { + panic!("expected rule"); + }; + assert!(rule.disabled); + assert!(rule.delimiter); + assert_eq!(rule.new_page.as_deref(), Some("Page")); + assert_eq!(rule.annotations.len(), 7); + assert_eq!(rule.annotations[1].args[0].text, "1"); + assert_eq!(rule.annotations[2].args[0].text, "dmon"); + } } diff --git a/crates/opy-frontend/src/preprocess.rs b/crates/opy-frontend/src/preprocess.rs index c05f42a..7229ad8 100644 --- a/crates/opy-frontend/src/preprocess.rs +++ b/crates/opy-frontend/src/preprocess.rs @@ -45,6 +45,9 @@ use std::path::{Path, PathBuf}; use opy_macro_js::{Limits, MacroArg, MacroError, MacroRuntime}; use crate::diag::{FrontendError, FrontendResult, Span}; +use crate::hir::types::{ + DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingState, TranslationState, +}; use crate::lexer::{LexInput, Token, TokenKind, lex}; use crate::settings::SettingsBlock; @@ -92,6 +95,8 @@ pub struct Preprocessed { pub settings: Option, /// The registered `#!postCompileHook` script, when declared. pub post_compile_hook: Option, + /// Frontend-visible preprocessing state; backend effects are not run. + pub preprocessing: PreprocessingState, } /// The output file registry: the main file only (reference convention). @@ -151,11 +156,98 @@ pub fn preprocess_with_overlay_outcome( macros: Vec::new(), defines: Vec::new(), post_compile_hook: None, + preprocessing: PreprocessingState::default(), }; + let mut owned_main_text = None; + let mut source_file_id = 0; + let first_line = main_text.lines().next().unwrap_or_default(); + if first_line.trim_start().starts_with("#!mainFile") + && first_main_file_directive(main_text).is_none() + { + let span = Span::new( + 0, + crate::diag::Position::new(1, 1), + crate::diag::Position::new(1, first_line.chars().count() as u32 + 1), + ); + return PreprocessOutcome { + result: Err(FrontendError::at( + "main-file-invalid", + "`#!mainFile` expects one quoted path on the first line", + span, + )), + files: pre.files, + }; + } + if let Some((main_file, span)) = first_main_file_directive(main_text) { + let candidate = root.join(&main_file); + let canonical = std::fs::canonicalize(&candidate).ok(); + let overlay_text = overlay + .get(&main_file) + .or_else(|| { + canonical + .as_ref() + .and_then(|path| overlay.get(&path.to_string_lossy().into_owned())) + }) + .cloned(); + let (text, display_path, new_root) = match overlay_text { + Some(text) => { + let display_path = candidate.to_string_lossy().into_owned(); + let new_root = candidate + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()); + (text, display_path, new_root) + } + None => { + let Some(canonical) = canonical else { + return PreprocessOutcome { + result: Err(FrontendError::at( + "main-file-not-found", + format!("cannot find main file '{main_file}'"), + span, + )), + files: pre.files, + }; + }; + let text = match std::fs::read_to_string(&canonical) { + Ok(text) => text, + Err(error) => { + return PreprocessOutcome { + result: Err(FrontendError::at( + "main-file-not-found", + format!("cannot read main file '{main_file}': {error}"), + span, + )), + files: pre.files, + }; + } + }; + let new_root = canonical + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()); + (text, canonical.to_string_lossy().into_owned(), new_root) + } + }; + owned_main_text = Some(text); + source_file_id = 1; + pre.files.push(FileRecord { + id: source_file_id, + path: display_path, + }); + pre.next_file_id = 2; + pre.root = new_root; + pre.preprocessing.main_file = Some(DirectiveValue { + value: main_file.clone(), + span: Some(span.into()), + }); + pre.record("mainFile", Some(&main_file), span); + } + let source_text = owned_main_text.as_deref().unwrap_or(main_text); // The top-of-file settings block is extracted before lexing and blanked // out of the lexed text, so the lexer never sees the block's braces // (scoped settings lexing, #86). - let settings = match crate::settings::find_blocks(main_text, 0) { + let settings = match crate::settings::find_blocks(source_text, source_file_id) { Ok(mut blocks) => blocks.pop(), Err(error) => { return PreprocessOutcome { @@ -166,15 +258,15 @@ pub fn preprocess_with_overlay_outcome( }; let tokens = match &settings { Some(block) => { - let sanitized = crate::settings::sanitize_for_lex(main_text, block); + let sanitized = crate::settings::sanitize_for_lex(source_text, block); lex(LexInput { - file_id: 0, + file_id: source_file_id, text: &sanitized, }) } None => lex(LexInput { - file_id: 0, - text: main_text, + file_id: source_file_id, + text: source_text, }), }; let mut tokens = match tokens { @@ -200,6 +292,7 @@ pub fn preprocess_with_overlay_outcome( defines: pre.defines, settings, post_compile_hook: pre.post_compile_hook, + preprocessing: pre.preprocessing, }, pre.files.clone(), )); @@ -224,6 +317,7 @@ struct Preprocessor { macros: Vec, defines: Vec, post_compile_hook: Option, + preprocessing: PreprocessingState, } /// A registered macro: object-like, function-like, or a script macro. @@ -237,6 +331,22 @@ struct MacroDef { script: Option, } +fn first_main_file_directive(text: &str) -> Option<(String, Span)> { + let line = text.lines().next()?.trim_end_matches('\r'); + let rest = line.strip_prefix("#!mainFile")?; + let value = rest.trim(); + let value = strip_quoted(value)?.to_string(); + let end_col = line.chars().count() as u32 + 1; + Some(( + value, + Span::new( + 0, + crate::diag::Position::new(1, 1), + crate::diag::Position::new(1, end_col), + ), + )) +} + impl Preprocessor { /// Process `#!` directive tokens, splicing includes and registering /// defines. Non-directive tokens are kept in place. @@ -245,6 +355,22 @@ impl Preprocessor { for token in tokens.drain(..) { if token.kind == TokenKind::Directive { self.handle_directive(token, &mut out)?; + } else if token.kind == TokenKind::Ident + && matches!(token.text.as_str(), "rule" | "def") + && self.preprocessing.rule_prefix.is_some() + { + let prefix = self + .preprocessing + .rule_prefix + .as_ref() + .map(|value| value.value.clone()) + .unwrap_or_default(); + out.push(Token { + kind: TokenKind::RulePrefixMarker, + text: prefix, + span: token.span, + }); + out.push(token); } else { out.push(token); } @@ -256,7 +382,8 @@ impl Preprocessor { fn handle_directive(&mut self, token: Token, out: &mut Vec) -> FrontendResult<()> { let text = token.text.trim(); let span = token.span; - if let Some(rest) = text.strip_prefix("include") { + let (name, rest) = split_directive(text); + if name == "include" { let rest = rest.trim(); let include = rest .strip_prefix('"') @@ -274,16 +401,25 @@ impl Preprocessor { self.include(include, span, out)?; return Ok(()); } - if let Some(rest) = text.strip_prefix("define") { + if name == "define" { self.define(rest.trim(), span)?; return Ok(()); } - if let Some(rest) = text.strip_prefix("undef") { + if name == "undef" { let name = rest.trim(); + if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) { + return Err(FrontendError::at( + "undef-invalid", + "malformed `#!undef` directive: expected one macro name", + span, + )); + } self.macros.retain(|m| m.name != name); + self.defines.retain(|define| define.name != name); + self.record("undef", Some(name), span); return Ok(()); } - if let Some(rest) = text.strip_prefix("postCompileHook") { + if name == "postCompileHook" { let rest = rest.trim(); let Some(path) = strip_quoted(rest) else { return Err(FrontendError::at( @@ -307,6 +443,94 @@ impl Preprocessor { source: hook.source, span, }); + self.record("postCompileHook", Some(path), span); + return Ok(()); + } + if name == "mainFile" { + return Err(FrontendError::at( + "main-file-placement", + "`#!mainFile` must be the first directive in the main source", + span, + )); + } + if name == "allowMacroRedeclaration" { + self.preprocessing.allow_macro_redeclaration = true; + self.record(name, None, span); + return Ok(()); + } + if name == "translations" { + let languages = parse_translations(rest.trim(), span)?; + self.preprocessing.translations = Some(TranslationState { + languages: languages.clone(), + span: Some(span.into()), + }); + self.record(name, Some(&languages.join(" ")), span); + return Ok(()); + } + if name == "suppressWarnings" { + let warnings = parse_words(rest, "suppressWarnings", span)?; + self.preprocessing + .suppressed_warnings + .extend(warnings.clone()); + self.record(name, Some(&warnings.join(" ")), span); + return Ok(()); + } + if name == "rulePrefix" { + let prefix = strip_quoted(rest.trim()).ok_or_else(|| { + FrontendError::at( + "rule-prefix-invalid", + "`#!rulePrefix` expects one quoted string", + span, + ) + })?; + self.preprocessing.rule_prefix = Some(DirectiveValue { + value: prefix.to_string(), + span: Some(span.into()), + }); + self.record(name, Some(prefix), span); + return Ok(()); + } + if name == "rulePrefixTemplate" { + if self.preprocessing.rule_prefix_template.is_some() { + return Err(FrontendError::at( + "rule-prefix-template-duplicate", + "a rule prefix template is already defined", + span, + )); + } + let template = rest.trim(); + self.preprocessing.rule_prefix_template = Some(DirectiveValue { + value: template.to_string(), + span: Some(span.into()), + }); + self.record(name, Some(template), span); + return Ok(()); + } + if let Some((directive, control)) = optimization_directive(name) { + apply_optimization(&mut self.preprocessing.optimization, control); + self.record(directive, None, span); + return Ok(()); + } + if let Some(replacement) = replacement_directive(name) { + let family = replacement_family(name).expect("replacement directive family"); + if self + .preprocessing + .directives + .iter() + .filter_map(|item| replacement_family(&item.name)) + .any(|item_family| item_family == family) + { + return Err(FrontendError::at( + "replacement-duplicate", + format!("a replacement for `{family}` is already defined"), + span, + )); + } + self.preprocessing.replacements.push(DirectiveValue { + value: replacement.to_string(), + span: Some(span.into()), + }); + self.record(name, Some(replacement), span); return Ok(()); } Err(FrontendError::at( @@ -316,6 +540,15 @@ impl Preprocessor { )) } + fn record(&mut self, name: &str, value: Option<&str>, span: Span) { + self.preprocessing.directives.push(DirectiveRecord { + name: name.to_string(), + value: value.map(str::to_string), + scope_col: span.start.col, + span: Some(span.into()), + }); + } + /// Resolve a script path root-relative (the reference's /// `getFilePaths(path, rootPath)` convention) and read its text. fn resolve_script(&self, path: &str, span: Span) -> FrontendResult { @@ -405,6 +638,7 @@ impl Preprocessor { path: include.to_string(), }); self.include_stack.push(identity); + let saved_prefix = self.preprocessing.rule_prefix.clone(); // Settings blocks are only supported in the main file; an included // file's block is rejected at its keyword span (file id of the // included file, #86). @@ -423,7 +657,9 @@ impl Preprocessor { file_id, text: &text, })?; - self.process_directives(&mut included)?; + let processed = self.process_directives(&mut included); + self.preprocessing.rule_prefix = saved_prefix; + processed?; // Drop the included file's Eof token (it terminates the file, not // the spliced stream). included.retain(|token| token.kind != TokenKind::Eof); @@ -480,6 +716,17 @@ impl Preprocessor { span, )); } + if self.macros.iter().any(|macro_def| macro_def.name == name) { + if !self.preprocessing.allow_macro_redeclaration { + return Err(FrontendError::at( + "macro-redeclaration", + format!("macro '{name}' is already defined"), + span, + )); + } + self.macros.retain(|macro_def| macro_def.name != name); + self.defines.retain(|define| define.name != name); + } let script = if is_function_like && body_text.starts_with("__script__(") { // The OverPy script-macro ABI: the replacement is exactly // `__script__("path.js")`; the reference extracts the path from @@ -517,7 +764,7 @@ impl Preprocessor { .into_iter() .filter(|t| t.kind != TokenKind::Eof) .collect(); - let is_function = !params.is_empty(); + let is_function = is_function_like; self.defines.push(DefineRecord { name: name.clone(), is_function, @@ -764,6 +1011,165 @@ impl Preprocessor { } } +fn split_directive(text: &str) -> (&str, &str) { + text.split_once(char::is_whitespace) + .map_or((text, ""), |(name, rest)| (name, rest)) +} + +fn is_identifier_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + +fn parse_words(rest: &str, directive: &str, span: Span) -> FrontendResult> { + let words: Vec = rest.split_whitespace().map(str::to_string).collect(); + if words.is_empty() { + return Err(FrontendError::at( + "directive-invalid", + format!("`#!{directive}` expects at least one argument"), + span, + )); + } + if words + .iter() + .any(|word| word.chars().any(|ch| !is_identifier_char(ch))) + { + return Err(FrontendError::at( + "directive-invalid", + format!("`#!{directive}` arguments must be identifiers"), + span, + )); + } + Ok(words) +} + +fn parse_translations(rest: &str, span: Span) -> FrontendResult> { + let values: Vec = rest + .split_whitespace() + .map(|language| language.replace('-', "_").to_lowercase()) + .collect(); + if values.is_empty() { + return Err(FrontendError::at( + "translations-invalid", + "`#!translations` expects at least one language", + span, + )); + } + if values.iter().any(|language| !is_language_tag(language)) { + return Err(FrontendError::at( + "translations-invalid", + "translation languages must use an alphabetic language tag with an optional region", + span, + )); + } + let mut seen = std::collections::BTreeSet::new(); + if values.iter().any(|language| !seen.insert(language)) { + return Err(FrontendError::at( + "translations-invalid", + "translation languages must be unique", + span, + )); + } + if values.iter().any(|value| value == "es") + && values + .iter() + .any(|value| value == "es_es" || value == "es_mx") + { + return Err(FrontendError::at( + "translations-invalid", + "cannot combine `es` with `es_es` or `es_mx`", + span, + )); + } + if values.iter().any(|value| value == "zh") + && values + .iter() + .any(|value| value == "zh_cn" || value == "zh_tw") + { + return Err(FrontendError::at( + "translations-invalid", + "cannot combine `zh` with `zh_cn` or `zh_tw`", + span, + )); + } + Ok(values) +} + +fn is_language_tag(value: &str) -> bool { + let mut parts = value.split('_'); + let Some(language) = parts.next() else { + return false; + }; + let language_len = language.chars().count(); + if !(2..=3).contains(&language_len) || !language.chars().all(|ch| ch.is_ascii_lowercase()) { + return false; + } + parts.all(|region| { + (2..=4).contains(®ion.chars().count()) + && region.chars().all(|ch| ch.is_ascii_alphanumeric()) + }) +} + +#[derive(Clone, Copy)] +enum OptimizationControl { + Enable, + Disable, + ForSize, + DisableForSize, + ForSizeAggressive, + Strict, + DisableStrict, +} + +fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> { + Some(match name { + "disableOptimizations" => (name, OptimizationControl::Disable), + "enableOptimizations" => (name, OptimizationControl::Enable), + "optimizeForSize" => (name, OptimizationControl::ForSize), + "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize), + "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive), + "optimizeStrict" => (name, OptimizationControl::Strict), + "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict), + _ => return None, + }) +} + +fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) { + match control { + OptimizationControl::Enable => state.enabled = true, + OptimizationControl::Disable => state.enabled = false, + OptimizationControl::ForSize => state.for_size = true, + OptimizationControl::DisableForSize => state.for_size = false, + OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true, + OptimizationControl::Strict => state.strict = true, + OptimizationControl::DisableStrict => state.strict = false, + } +} + +fn replacement_directive(name: &str) -> Option<&str> { + Some(match name { + "replace0ByCapturePercentage" => "getCapturePercentage", + "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage", + "replace0ByIsMatchComplete" => "isMatchComplete", + "replace1ByMatchRound" => "getMatchRound", + "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam", + "replaceEmptyStringByEmptyArray" => "emptyArray", + "replaceEmptyStringByVariable" => "variable", + _ => return None, + }) +} + +fn replacement_family(name: &str) -> Option<&str> { + Some(match name { + "replace0ByCapturePercentage" + | "replace0ByPayloadProgressPercentage" + | "replace0ByIsMatchComplete" => "0", + "replace1ByMatchRound" => "1", + "replaceTeam1ByControlScoringTeam" => "team1", + "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString", + _ => return None, + }) +} + /// Strips a matched `"…"` or `'…'` pair, returning the inner text. fn strip_quoted(text: &str) -> Option<&str> { text.strip_prefix('"') @@ -999,4 +1405,43 @@ mod tests { assert!(error.message.contains("unexpected character '{'")); assert_eq!(error.span.unwrap().start.line, 2); } + + #[test] + fn advanced_directives_preserve_frontend_state_without_catalog_data() { + let (pre, _) = preprocess( + "#!allowMacroRedeclaration\n#!translations en fr\n#!rulePrefix \"Effects\"\n#!optimizeForSize\n#!optimizeStrict\n#!replace0ByCapturePercentage\n#!define VALUE 1\n#!define VALUE 2\nrule \"r\":\n x = VALUE\n", + "main.opy", + Path::new("."), + ) + .unwrap(); + assert!(pre.preprocessing.allow_macro_redeclaration); + assert_eq!( + pre.preprocessing + .translations + .as_ref() + .map(|state| state.languages.as_slice()), + Some(["en".to_string(), "fr".to_string()].as_slice()) + ); + assert_eq!( + pre.preprocessing + .rule_prefix + .as_ref() + .map(|value| value.value.as_str()), + Some("Effects") + ); + assert!(pre.preprocessing.optimization.for_size); + assert!(pre.preprocessing.optimization.strict); + assert_eq!( + pre.preprocessing.replacements[0].value, + "getCapturePercentage" + ); + assert_eq!(pre.defines.len(), 1); + } + + #[test] + fn malformed_translation_state_is_source_located() { + let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err(); + assert_eq!(error.code, "translations-invalid"); + assert!(error.span.is_some()); + } } diff --git a/crates/opy-frontend/src/support.rs b/crates/opy-frontend/src/support.rs index beacd48..b6ca211 100644 --- a/crates/opy-frontend/src/support.rs +++ b/crates/opy-frontend/src/support.rs @@ -218,13 +218,13 @@ mod tests { assert_eq!(syntax.len(), 7); assert!(syntax.iter().all(|feature| feature.category == "syntax")); let lowering = matrix.features_by_state("lowering-dependent"); - assert_eq!(lowering.len(), 11); + assert_eq!(lowering.len(), 14); assert!( lowering .iter() .all(|feature| feature.state == "lowering-dependent") ); - assert_eq!(matrix.summary().by_state["planned"], 5); + assert_eq!(matrix.summary().by_state["planned"], 2); assert_eq!(matrix.summary().by_category["semantics"], 14); // Every feature id is unique. let mut ids: Vec<&str> = matrix diff --git a/crates/opy-frontend/src/tooling.rs b/crates/opy-frontend/src/tooling.rs index 3dcc946..c2cea7e 100644 --- a/crates/opy-frontend/src/tooling.rs +++ b/crates/opy-frontend/src/tooling.rs @@ -144,16 +144,19 @@ pub fn check_with_overlay( }) .collect(); match crate::lower::lower(&program, hir_files, defines) { - Ok(hir) => CheckOutcome { - diagnostics: Vec::new(), - model: Some(SemanticModel::build(hir, &program)), - files, - // The directive was parsed, validated, and recorded by - // preprocessing; the frontend never executes the hook (real hook - // execution receives the final Workshop text and is - // lowering-dependent, issue #8). - post_compile_hook: preprocessed.post_compile_hook, - }, + Ok(mut hir) => { + hir.preprocessing = preprocessed.preprocessing; + CheckOutcome { + diagnostics: Vec::new(), + model: Some(SemanticModel::build(hir, &program)), + files, + // The directive was parsed, validated, and recorded by + // preprocessing; the frontend never executes the hook (real hook + // execution receives the final Workshop text and is + // lowering-dependent, issue #8). + post_compile_hook: preprocessed.post_compile_hook, + } + } Err(error) => CheckOutcome { diagnostics: vec![Diagnostic::from_error(error, &files)], model: None, diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 321633c..13bcef6 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -187,6 +187,24 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { false, "include + object/function-like defines + undef; oracle status success.", ); + resolve( + &mut cases, + "synthetic/issue-29-directives", + true, + "advanced directive state and source annotations; oracle status success.", + ); + diagnostic( + &mut cases, + "synthetic/issue-29-invalid", + None, + "malformed directive and annotation forms; oracle status failure.", + ); + resolve( + &mut cases, + "synthetic/issue-29-main-file", + true, + "mainFile entry-point redirect and child-include scope; oracle status success.", + ); resolve( &mut cases, "synthetic/settings", diff --git a/docs/opy/compatibility-baseline.md b/docs/opy/compatibility-baseline.md index 0e3f9b0..1d15150 100644 --- a/docs/opy/compatibility-baseline.md +++ b/docs/opy/compatibility-baseline.md @@ -65,9 +65,9 @@ rejected/documented-absent dimension, `—` an inapplicable dimension, and | 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) | ✅ | ✅ | -| 4a | `@Team`/`@Slot` with arguments, `@Name`, `@Hero`, `@Disabled`, `@Delimiter`, `@NewPage`, `@SuppressWarnings` | `evidence-prioritized` | ❌ | ❌ | ❌ | ❌ | ✅ oracle probes | +| 4a | `@Team`/`@Slot` with arguments, `@Name`, `@Hero`, `@Disabled`, `@Delimiter`, `@NewPage`, `@SuppressWarnings` | `baseline-supported` for frontend state; Workshop domain/UI effects remain lowering-dependent | ✅ | ✅ | partial | ✅ | ✅ oracle probes | | 5 | **Preprocessing/include/macro**: `#!include`, `#!define` (object- and function-like), `#!undef`, include cycle detection | `baseline-supported` | ✅ | ✅ | ✅ (integration) | ✅ | ✅ | -| 5a | `#!mainFile`, `#!allowMacroRedeclaration`, `#!optimize*`/`#!replace0By*` family, `#!translations`, `#!rulePrefix*`, `__script__` JS hooks | `legacy-quirk/demand-driven` | ❌ | ❌ | ❌ | ❌ | partial | +| 5a | `#!mainFile`, `#!allowMacroRedeclaration`, `#!optimize*`/`#!replace0By*` family, `#!translations`, `#!rulePrefix*`, `__script__` JS hooks | `baseline-supported` for frontend state; optimizer/locale/hook effects remain lowering-dependent | ✅ | ✅ | partial | ✅ | ✅ oracle probes | | 6 | **OPY builtin actions & values (generic)**: manifest identities, signatures, aliases, and call semantics | `baseline-supported` for the probe-validated manifest overlay; canonical Workshop existence/content/emission is `lowering-dependent` | ✅ | ✅ | partial (integration) | ✅ | ✅ probes | | 6a | **Canonical Workshop builtin action/value catalog**: full catalog existence, content, and emission | `lowering-dependent` (`workshop-rs`, #8) | — | — | ❌ (integration) | — | ✅ inventory/oracle evidence | | 7 | **OPY receiver/member semantics**: receiver categories, explicit-argument signatures, variable receivers | `baseline-supported` for the manifest-declared OPY overlay; canonical member existence/content/emission is `lowering-dependent` | ✅ | ✅ | partial (integration) | ✅ | ✅ probes | @@ -84,13 +84,11 @@ rejected/documented-absent dimension, `—` an inapplicable dimension, and ## Current `planned` entries -The five explicitly tracked gaps still `planned` in +The two explicitly tracked OPY-language gaps still `planned` in `compatibility/support-matrix.json`, the mechanically checked state source, -are: `syntax/switch`, `syntax/string-modifiers`, -`preprocessing/advanced-directives`, -`translations/directive`, and `optimization/controls`. The three semantic -overlay rows are supported at the OPY manifest boundary; their canonical -Workshop catalog rows are explicitly `lowering-dependent`. Their tiers above +are: `syntax/switch` and `syntax/string-modifiers`. Directive, annotation, translation, and +optimizer frontend state is implemented; their Workshop effects remain +`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 **legacy-quirk/demand-driven** compatibility (rare historical quirks and diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index ecb87d2..af05f6c 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -103,6 +103,14 @@ implements; "reference" always means the pinned OverPy 9.7.10 - `#!define name(args) __script__("path.js")`: OverPy-compatible **JavaScript macros** (see below). - `#!undef NAME`. +- `#!mainFile "path.opy"` redirects the frontend entry point and preserves + file provenance; `#!allowMacroRedeclaration` changes duplicate macro + handling; `#!rulePrefix` and `#!rulePrefixTemplate` are retained as source + state and prefix subsequent rule/subroutine names. +- `#!translations`, `#!optimize*`, and `#!replace0By*` forms are parsed, + validated, and exposed as frontend preprocessing state. Locale availability, + `.po` content, generated translation helpers, optimizer rewrites, and + replacement effects are lowering-dependent and are not fabricated here. - `#!postCompileHook "hook.js"`: post-compile hook registration (see below). - Unsupported directives fail explicitly (`unsupported-directive`). @@ -145,8 +153,11 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, ### Rules and directives - `rule "name":` with `@Event global` / `@Event eachPlayer` / `@Condition `. -- `@Team`/`@Slot` are accepted only without arguments (corpus events use - OverPy defaults); other `@` directives fail explicitly. +- `@Team`/`@Slot` arguments, `@Hero`, `@Name`, `@Disabled`, `@Delimiter`, + `@NewPage`, and `@SuppressWarnings` are parsed, validated, and retained in + the OPY HIR. Hero/team/slot domain checks and Workshop UI effects remain + 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`. - `for`-loop binder resolution: the loop variable must resolve to a global @@ -318,7 +329,10 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, gaps. Manifest entries without a direct catalog id carry an explicit `catalogLink` reason (`special-lowering`, `legacy-alias`, or `catalog-gap`) and remain visible to the integration adapter. -- Rule `disabled` markers (no corpus evidence for the source annotation). + - Emission spellings for manifest-valid entries not yet catalog-covered + (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). - Backslash line continuation (`\` at end of line inside string From 212ce71532843f21f69f697e1deb7f6b21ee6a7d Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:40:04 +0800 Subject: [PATCH 2/7] fix(opy): keep directive parser clippy-clean --- crates/opy-frontend/src/parser.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index 81ef566..ebfbe1c 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -469,6 +469,7 @@ impl Parser<'_> { true } + #[allow(clippy::too_many_arguments)] fn parse_directive( &mut self, event: &mut Option, From 88e826d5ce5be443e3f6b41c5d2e95042b39ef03 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:45:17 +0800 Subject: [PATCH 3/7] fix(opy): repair directive preprocessing semantics Fixes #31 --- compatibility/differential-expectations.json | 3 + .../synthetic/issue-31-negative/fixture.json | 14 + .../synthetic/issue-31-negative/oracle.json | 32 ++ .../synthetic/issue-31-negative/source.opy | 3 + .../synthetic/issue-31-nested-scope/child.opy | 8 + .../issue-31-nested-scope/fixture.json | 14 + .../issue-31-nested-scope/grandchild.opy | 3 + .../issue-31-nested-scope/oracle.json | 27 ++ .../issue-31-nested-scope/source.opy | 9 + .../synthetic/issue-31-positive/child.opy | 3 + .../synthetic/issue-31-positive/fixture.json | 14 + .../synthetic/issue-31-positive/oracle.json | 27 ++ .../synthetic/issue-31-positive/source.en.po | 5 + .../synthetic/issue-31-positive/source.opy | 28 ++ .../issue-31-positive/source.zh_cn.po | 5 + compatibility/tests/test_runner.py | 2 +- crates/opy-frontend/src/cst.rs | 1 + crates/opy-frontend/src/hir/dump.rs | 13 +- crates/opy-frontend/src/hir/mod.rs | 6 +- crates/opy-frontend/src/hir/types.rs | 33 ++ crates/opy-frontend/src/hir/validate.rs | 20 +- crates/opy-frontend/src/lower.rs | 354 +++++++++++++++++- crates/opy-frontend/src/parser.rs | 56 ++- crates/opy-frontend/src/preprocess.rs | 133 +++++-- crates/opy-frontend/src/tooling.rs | 19 +- crates/opy-frontend/tests/differential.rs | 18 + 26 files changed, 801 insertions(+), 49 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-31-negative/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-31-negative/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-31-negative/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-nested-scope/child.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-nested-scope/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-31-nested-scope/grandchild.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-nested-scope/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-31-nested-scope/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/child.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/source.en.po create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/source.opy create mode 100644 compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 2fdf5dc..f01a8d6 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -16,6 +16,9 @@ {"fixture": "synthetic/issue-29-directives", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-directives/oracle.json", "implementation-invariant:issue-29-preprocessing-state"], "note": "Advanced directive state and source annotations resolve without executing Workshop optimizer, localization, or emission effects."}, {"fixture": "synthetic/issue-29-invalid", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-29-invalid/oracle.json", "implementation-invariant:issue-29-structured-directive-diagnostics"], "note": "Malformed preprocessing and annotation forms remain source-located structured diagnostics."}, {"fixture": "synthetic/issue-29-main-file", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-29-main-file/oracle.json", "implementation-invariant:issue-29-main-file-scope"], "note": "mainFile redirects the frontend entry point and preserves child-include prefix state without copying catalog data."}, + {"fixture": "synthetic/issue-31-positive", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-31-positive/oracle.json", "implementation-invariant:issue-31-directive-surfaces"], "note": "Pinned positive probe covers global rulePrefixTemplate, include prefix restoration, AST macro/enum redeclaration, and translation normalization."}, + {"fixture": "synthetic/issue-31-negative", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-31-negative/oracle.json", "implementation-invariant:issue-31-pinned-translation-set"], "note": "An outside-code translation is rejected by both the pinned oracle and native frontend."}, + {"fixture": "synthetic/issue-31-nested-scope", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-31-nested-scope/oracle.json", "implementation-invariant:issue-31-scoped-preprocessing"], "note": "Nested include optimization directives remain observable as scoped preprocessing events; optimizer execution is not claimed by opy-rs."}, {"fixture": "synthetic/settings", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/settings/oracle.json", "implementation-invariant:settings-structure-only"], "note": "Settings are structurally represented; Workshop key and leaf validation remains lowering-dependent."}, {"fixture": "synthetic/receiver-calls", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/receiver-calls/oracle.json", "implementation-invariant:receiver-call-resolution"], "note": "The exercised receiver/member forms resolve through the OPY semantic model."}, {"fixture": "synthetic/receiver-playervar", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/receiver-playervar/oracle.json", "implementation-invariant:receiver-playervar-member-expression"], "note": "A bare variable member expression is retained as an OPY HIR member node; canonical Workshop member validation remains lowering-dependent."}, diff --git a/compatibility/fixtures/synthetic/issue-31-negative/fixture.json b/compatibility/fixtures/synthetic/issue-31-negative/fixture.json new file mode 100644 index 0000000..ffc0cac --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-negative/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-31-negative", + "category": "preprocessing", + "features": ["preprocessing/translations"], + "source": "source.opy", + "expectedStatus": "failure", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #31 minimized pinned-oracle negative probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-31-negative/oracle.json b/compatibility/fixtures/synthetic/issue-31-negative/oracle.json new file mode 100644 index 0000000..f98b651 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-negative/oracle.json @@ -0,0 +1,32 @@ +{ + "compile": { + "diagnostics": [ + { + "severity": "error", + "text": "Error: Invalid language 'en_us'\n | line 1, col 1, at source.opy" + } + ], + "exitCode": 1, + "status": "failure", + "stdout": "", + "workshop": "", + "workshopExact": "", + "workshopSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fixture": "synthetic/issue-31-negative", + "input": { + "sha256": "613387a057b0ba8958db08b661435b5c53d69f0d4c6b5510214c89589d8fe2ad", + "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-31-negative/source.opy b/compatibility/fixtures/synthetic/issue-31-negative/source.opy new file mode 100644 index 0000000..936f8bb --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-negative/source.opy @@ -0,0 +1,3 @@ +#!translations en_US +rule "invalid translation": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-nested-scope/child.opy b/compatibility/fixtures/synthetic/issue-31-nested-scope/child.opy new file mode 100644 index 0000000..93a4b3c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-nested-scope/child.opy @@ -0,0 +1,8 @@ +#!optimizeForSize +rule "child": + pass + +#!include "grandchild.opy" + +rule "child after": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-nested-scope/fixture.json b/compatibility/fixtures/synthetic/issue-31-nested-scope/fixture.json new file mode 100644 index 0000000..71a10ca --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-nested-scope/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-31-nested-scope", + "category": "preprocessing", + "features": ["preprocessing/scoped-state", "preprocessing/include-restoration", "preprocessing/optimization"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #31 minimized pinned-oracle nested-scope probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-31-nested-scope/grandchild.opy b/compatibility/fixtures/synthetic/issue-31-nested-scope/grandchild.opy new file mode 100644 index 0000000..e5390bb --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-nested-scope/grandchild.opy @@ -0,0 +1,3 @@ +#!optimizeStrict +rule "grandchild": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-nested-scope/oracle.json b/compatibility/fixtures/synthetic/issue-31-nested-scope/oracle.json new file mode 100644 index 0000000..b28c34c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-nested-scope/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "//Optimizations disabled\nrule (\"before include\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Optimize for size enabled\nrule (\"child\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Strict optimizations enabled\nrule (\"grandchild\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\nrule (\"child after\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Optimizations enabled\n", + "workshopExact": "//Optimizations disabled\nrule (\"before include\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Optimize for size enabled\nrule (\"child\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Strict optimizations enabled\nrule (\"grandchild\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\nrule (\"child after\") {\n event {\n Ongoing - Global;\n }\n actions {\n \"pass\"\n disabled Abort;\n }\n}\n\n//Optimizations enabled\n", + "workshopSha256": "42b33beccc478c3eff934c727a2e0063bd0a76e591f109fa901415b3fdf3bd77" + }, + "fixture": "synthetic/issue-31-nested-scope", + "input": { + "sha256": "933baf034eb509b4bb09610645dbe20a24dbae9e903e91bbfb43409877f31991", + "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-31-nested-scope/source.opy b/compatibility/fixtures/synthetic/issue-31-nested-scope/source.opy new file mode 100644 index 0000000..19bee5f --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-nested-scope/source.opy @@ -0,0 +1,9 @@ +#!disableOptimizations +rule "before include": + pass + +#!include "child.opy" + +#!enableOptimizations +rule "after include": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-positive/child.opy b/compatibility/fixtures/synthetic/issue-31-positive/child.opy new file mode 100644 index 0000000..facbea8 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/child.opy @@ -0,0 +1,3 @@ +#!rulePrefix "Child" +rule "included": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-positive/fixture.json b/compatibility/fixtures/synthetic/issue-31-positive/fixture.json new file mode 100644 index 0000000..4e0ec76 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-31-positive", + "category": "preprocessing", + "features": ["preprocessing/rule-prefix-template", "preprocessing/macro-redeclaration", "preprocessing/translations"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #31 minimized pinned-oracle positive probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-31-positive/oracle.json b/compatibility/fixtures/synthetic/issue-31-positive/oracle.json new file mode 100644 index 0000000..b0959a8 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 127: __overpyTranslationHelper__\n}\n\nsubroutines {\n 0: worker\n}\n\nrule (\"[] Initialize global variables\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(__overpyTranslationHelper__, String Split(Custom String(\"\uec480White0\u767d\u82720White\"), First Of(Null)));\n }\n}\n", + "workshopExact": "variables {\n global:\n 127: __overpyTranslationHelper__\n}\n\nsubroutines {\n 0: worker\n}\n\nrule (\"[] Initialize global variables\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(__overpyTranslationHelper__, String Split(Custom String(\"\uec480White0\u767d\u82720White\"), First Of(Null)));\n }\n}\n\n", + "workshopSha256": "d886f0e73cb14eb02cbc5c90f7f0b0eefea76da57bc529cc0ac7e309d94ff452" + }, + "fixture": "synthetic/issue-31-positive", + "input": { + "sha256": "aa688034210c921381c0d0ddb7c876c05279bac306a6c3cd3103fc47e501a104", + "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-31-positive/source.en.po b/compatibility/fixtures/synthetic/issue-31-positive/source.en.po new file mode 100644 index 0000000..24ccfd7 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/source.en.po @@ -0,0 +1,5 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"X-Generator: OverPy\n" diff --git a/compatibility/fixtures/synthetic/issue-31-positive/source.opy b/compatibility/fixtures/synthetic/issue-31-positive/source.opy new file mode 100644 index 0000000..4570432 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/source.opy @@ -0,0 +1,28 @@ +#!allowMacroRedeclaration +#!translations EN zh-cn EN +#!rulePrefixTemplate +#!rulePrefix "Outer" +#!define VALUE 1 +#!define VALUE 2 + +enum Kind: + First + First + +macro helper(): + pass + +macro helper(): + pass + +rule "before": + pass + +#!include "child.opy" + +def worker(): + @Name "Friendly worker" + pass + +rule "after": + pass diff --git a/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po b/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po new file mode 100644 index 0000000..83a43a5 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po @@ -0,0 +1,5 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh_cn\n" +"X-Generator: OverPy\n" diff --git a/compatibility/tests/test_runner.py b/compatibility/tests/test_runner.py index 32935c1..b190bdc 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), 31) + self.assertEqual(len(fixtures), 34) 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/cst.rs b/crates/opy-frontend/src/cst.rs index 5c2982a..774a1f2 100644 --- a/crates/opy-frontend/src/cst.rs +++ b/crates/opy-frontend/src/cst.rs @@ -108,6 +108,7 @@ pub enum RuleEntry { Rule(Rule), SubroutineDef { name: String, + presentation_name: Option, span: Span, /// The exact span of the defined identifier token in `def name():`. name_span: Span, diff --git a/crates/opy-frontend/src/hir/dump.rs b/crates/opy-frontend/src/hir/dump.rs index 46cf5ee..3505573 100644 --- a/crates/opy-frontend/src/hir/dump.rs +++ b/crates/opy-frontend/src/hir/dump.rs @@ -56,12 +56,21 @@ pub fn dump(program: &Program) -> String { match entry { RuleEntry::Rule(rule) => dump_rule(rule, &mut out, 1), RuleEntry::SubroutineDef { - name, span, body, .. + name, + source_name, + span, + body, + .. } => { out.push_str(&format!( - "{}subroutineDef {}{}\n", + "{}subroutineDef {} source={}{}\n", indent(1), name, + if source_name.is_empty() { + name + } else { + source_name + }, span_suffix(span.as_ref()) )); dump_stmts(body, &mut out, 2); diff --git a/crates/opy-frontend/src/hir/mod.rs b/crates/opy-frontend/src/hir/mod.rs index f7e782c..3c4bfe0 100644 --- a/crates/opy-frontend/src/hir/mod.rs +++ b/crates/opy-frontend/src/hir/mod.rs @@ -19,9 +19,9 @@ mod validate; pub use error::HirError; pub use types::{ Annotation, AnnotationArg, Declaration, DirectiveRecord, DirectiveValue, Event, Expr, - Generator, OptimizationState, Position, PreprocessingState, Program, Protocol, Rule, RuleEntry, - Settings, SettingsListElement, SettingsNode, SourceFile, Span, Stmt, TranslationState, - default_var_index, + Generator, OptimizationState, Position, PreprocessingSnapshot, PreprocessingState, Program, + Protocol, Rule, RuleEntry, Settings, SettingsListElement, SettingsNode, SourceFile, Span, Stmt, + TranslationState, default_var_index, }; use serde_json::Value; diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index 649958a..60b46dd 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -183,10 +183,41 @@ pub struct DirectiveRecord { #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, pub scope_col: u32, + #[serde(default)] + pub scope_depth: u32, + #[serde(default)] + pub state: PreprocessingSnapshot, #[serde(skip_serializing_if = "Option::is_none")] pub span: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PreprocessingSnapshot { + pub allow_macro_redeclaration: bool, + pub optimization: OptimizationState, + #[serde(default)] + pub rule_prefix: Option, + #[serde(default)] + pub rule_prefix_template: Option, + #[serde(default)] + pub translations: Option>, + #[serde(default)] + pub replacements: Vec, +} + +impl Default for PreprocessingSnapshot { + fn default() -> Self { + Self { + allow_macro_redeclaration: false, + optimization: OptimizationState::default(), + rule_prefix: None, + rule_prefix_template: None, + translations: None, + replacements: Vec::new(), + } + } +} + /// A custom-game-settings block (`settings { ... }`, #86). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Settings { @@ -321,6 +352,8 @@ pub enum RuleEntry { #[serde(rename = "kind")] kind: String, name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + source_name: String, #[serde(skip_serializing_if = "Option::is_none")] span: Option, /// The exact span of the defined identifier token in `def name():`. diff --git a/crates/opy-frontend/src/hir/validate.rs b/crates/opy-frontend/src/hir/validate.rs index 5596dc5..6352d4f 100644 --- a/crates/opy-frontend/src/hir/validate.rs +++ b/crates/opy-frontend/src/hir/validate.rs @@ -246,9 +246,16 @@ pub(crate) fn validate_program(program: &Program) -> Result<(), HirError> { match entry { RuleEntry::Rule(rule) => validate_rule(rule, program, &tables)?, RuleEntry::SubroutineDef { - name, span, body, .. + name, + source_name, + span, + body, + .. } => { check_name(name, "subroutine definition", *span)?; + if !source_name.is_empty() { + check_name(source_name, "subroutine source", *span)?; + } validate_stmts(body, program, &tables, |statement| { statement.span().copied() })?; @@ -374,7 +381,16 @@ fn validate_stmts( match statement { Stmt::CallSubroutine { name, span } => { let known = tables.subroutines.contains(&name.as_str()) - || program.rules.iter().any(|entry| matches!(entry, RuleEntry::SubroutineDef { name: n, .. } if n == name)); + || program.rules.iter().any(|entry| { + matches!( + entry, + RuleEntry::SubroutineDef { + name: presentation, + source_name, + .. + } if presentation == name || source_name == name + ) + }); if !known { errors.push(invalid( "unresolved-reference", diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index e29e2ba..e464e26 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -73,6 +73,15 @@ pub fn lower( program: &cst::Program, files: Vec, defines: Vec, +) -> FrontendResult { + lower_with_preprocessing(program, files, defines, &PreprocessingState::default()) +} + +pub fn lower_with_preprocessing( + program: &cst::Program, + files: Vec, + defines: Vec, + preprocessing: &PreprocessingState, ) -> FrontendResult { let manifest = match Manifest::builtin() { Ok(manifest) => manifest, @@ -163,18 +172,36 @@ pub fn lower( let mut rules = Vec::new(); for entry in &program.rules { match entry { - CstRuleEntry::Rule(rule) => rules.push(RuleEntry::Rule(lowerer.lower_rule(rule))), + CstRuleEntry::Rule(rule) => rules.push(RuleEntry::Rule(lowerer.lower_rule( + rule, + files.as_slice(), + preprocessing, + )?)), CstRuleEntry::SubroutineDef { name, + presentation_name, span, name_span, body, annotations, rule_prefix, } => { + let base_name = presentation_name + .as_deref() + .map(str::to_string) + .unwrap_or_else(|| format!("Subroutine {name}")); + let generated_name = render_rule_name( + &base_name, + rule_prefix.as_deref(), + false, + *span, + files.as_slice(), + preprocessing, + )?; rules.push(RuleEntry::SubroutineDef { kind: "subroutineDef".to_string(), - name: prefixed_rule_name(name, rule_prefix.as_deref(), false), + name: generated_name, + source_name: name.clone(), span: Some(span.into()), name_span: Some(name_span.into()), body: lowerer.lower_block(body, &[]), @@ -203,7 +230,7 @@ pub fn lower( declarations, rules, settings: program.settings.as_ref().map(lower_settings), - preprocessing: PreprocessingState::default(), + preprocessing: preprocessing.clone(), }) } @@ -216,6 +243,256 @@ fn prefixed_rule_name(name: &str, prefix: Option<&str>, delimiter: bool) -> Stri } } +#[derive(Clone, Debug)] +enum TemplateValue { + String(String), + Bool(bool), +} + +fn render_rule_name( + name: &str, + prefix: Option<&str>, + delimiter: bool, + span: Span, + files: &[SourceFile], + preprocessing: &PreprocessingState, +) -> FrontendResult { + let Some(template) = preprocessing + .rule_prefix_template + .as_ref() + .map(|value| value.value.as_str()) + else { + return Ok(prefixed_rule_name(name, prefix, delimiter)); + }; + let (file, path) = rule_file_parts(span.file, files); + let prefix = prefix.unwrap_or_default(); + let values = [ + ("$rule", TemplateValue::String(name.to_string())), + ("$prefix", TemplateValue::String(prefix.to_string())), + ("$file", TemplateValue::String(file.clone())), + ("$path", TemplateValue::String(path.clone())), + ("$isDelimiter", TemplateValue::Bool(delimiter)), + ("$prefixTitle", TemplateValue::String(title_case(prefix))), + ("$prefixUpper", TemplateValue::String(prefix.to_uppercase())), + ("$prefixLower", TemplateValue::String(prefix.to_lowercase())), + ("$fileTitle", TemplateValue::String(title_case(&file))), + ("$fileUpper", TemplateValue::String(file.to_uppercase())), + ("$fileLower", TemplateValue::String(file.to_lowercase())), + ("$pathTitle", TemplateValue::String(title_case(&path))), + ("$pathUpper", TemplateValue::String(path.to_uppercase())), + ("$pathLower", TemplateValue::String(path.to_lowercase())), + ]; + evaluate_template(template, &values).map_err(|message| { + FrontendError::at( + "rule-prefix-template-invalid", + format!("could not resolve rule prefix template: {message}"), + span, + ) + }) +} + +fn rule_file_parts(file_id: u32, files: &[SourceFile]) -> (String, String) { + let path = files + .iter() + .find(|file| file.id == file_id) + .map(|file| file.path.replace('\\', "/")) + .unwrap_or_default(); + let without_extension = path + .strip_suffix(".opy") + .or_else(|| path.strip_suffix(".OPY")) + .unwrap_or(&path) + .to_string(); + let file = without_extension + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + (file, without_extension) +} + +fn title_case(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut capitalize = true; + for ch in value.chars() { + if ch == '_' { + result.push(' '); + capitalize = true; + } else if capitalize && ch.is_ascii_alphabetic() { + result.push(ch.to_ascii_uppercase()); + capitalize = false; + } else { + result.push(ch); + if !ch.is_whitespace() && ch != '/' { + capitalize = false; + } + } + if ch == '/' || ch.is_whitespace() { + capitalize = true; + } + } + result +} + +fn evaluate_template(template: &str, values: &[(&str, TemplateValue)]) -> Result { + if let Some((then_value, condition, else_value)) = split_conditional(template) { + let branch = if evaluate_condition(condition, values)? { + then_value + } else { + else_value + }; + return evaluate_string(branch, values); + } + evaluate_string(template, values) +} + +fn split_conditional(value: &str) -> Option<(&str, &str, &str)> { + let mut quote = None; + let mut depth = 0usize; + let mut if_start = None; + let mut else_start = None; + for (index, ch) in value.char_indices() { + match (ch, quote) { + ('"' | '\'', None) => quote = Some(ch), + (ch, Some(current)) if ch == current => quote = None, + ('{', None) => depth += 1, + ('}', None) => depth = depth.saturating_sub(1), + _ => {} + } + if quote.is_none() && depth == 0 { + if value[index..].starts_with(" if ") && if_start.is_none() { + if_start = Some(index); + } else if value[index..].starts_with(" else ") && else_start.is_none() { + else_start = Some(index); + } + } + } + let (Some(if_start), Some(else_start)) = (if_start, else_start) else { + return None; + }; + Some(( + value[..if_start].trim(), + value[if_start + 4..else_start].trim(), + value[else_start + 6..].trim(), + )) +} + +fn evaluate_condition(value: &str, values: &[(&str, TemplateValue)]) -> Result { + let value = value.trim(); + if let Some(rest) = value.strip_prefix("not ") { + return Ok(!evaluate_condition(rest, values)?); + } + if let Some((left, right)) = value.split_once(" or ") { + return Ok(evaluate_condition(left, values)? || evaluate_condition(right, values)?); + } + if let Some((left, right)) = value.split_once(" and ") { + return Ok(evaluate_condition(left, values)? && evaluate_condition(right, values)?); + } + match lookup_template_value(value, values)? { + TemplateValue::Bool(value) => Ok(value), + TemplateValue::String(value) => Ok(!value.is_empty()), + } +} + +fn evaluate_string(value: &str, values: &[(&str, TemplateValue)]) -> Result { + let value = value.trim(); + if let Some(body) = value + .strip_prefix("f\"") + .and_then(|body| body.strip_suffix('"')) + { + return interpolate_fstring(body, values); + } + if let Some(body) = value + .strip_prefix("f'") + .and_then(|body| body.strip_suffix('\'')) + { + return interpolate_fstring(body, values); + } + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + return Ok(value[1..value.len() - 1].to_string()); + } + match lookup_template_value(value, values)? { + TemplateValue::String(value) => Ok(value), + TemplateValue::Bool(value) => Ok(value.to_string()), + } +} + +fn interpolate_fstring(body: &str, values: &[(&str, TemplateValue)]) -> Result { + let mut result = String::new(); + let mut remaining = body; + while let Some(start) = remaining.find('{') { + result.push_str(&remaining[..start]); + let end = remaining[start + 1..] + .find('}') + .ok_or_else(|| "unterminated interpolation".to_string())? + + start + + 1; + result.push_str(&evaluate_string(&remaining[start + 1..end], values)?); + remaining = &remaining[end + 1..]; + } + result.push_str(remaining); + Ok(result) +} + +fn lookup_template_value( + value: &str, + values: &[(&str, TemplateValue)], +) -> Result { + let value = value.trim(); + let (base, mut methods) = value + .split_once('.') + .map_or((value, ""), |(base, methods)| (base, methods)); + let mut result = values + .iter() + .find(|(name, _)| *name == base) + .map(|(_, value)| value.clone()) + .ok_or_else(|| format!("unsupported expression '{value}'"))?; + while !methods.is_empty() { + let (method, rest) = methods + .split_once('.') + .map_or((methods, ""), |(method, rest)| (method, rest)); + if method == "upper()" { + result = TemplateValue::String(as_string(&result).to_uppercase()); + } else if method == "lower()" { + result = TemplateValue::String(as_string(&result).to_lowercase()); + } else if let Some(args) = method + .strip_prefix("replace(") + .and_then(|v| v.strip_suffix(')')) + { + let (from, to) = args + .split_once(',') + .ok_or_else(|| "replace expects two arguments".to_string())?; + let from = unquote_template_arg(from.trim())?; + let to = unquote_template_arg(to.trim())?; + result = TemplateValue::String(as_string(&result).replace(&from, &to)); + } else { + return Err(format!("unsupported method '{method}'")); + } + methods = rest; + } + Ok(result) +} + +fn as_string(value: &TemplateValue) -> String { + match value { + TemplateValue::String(value) => value.clone(), + TemplateValue::Bool(value) => value.to_string(), + } +} + +fn unquote_template_arg(value: &str) -> Result { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + Ok(value[1..value.len() - 1].to_string()) + } else { + Err(format!("expected a quoted string argument, got '{value}'")) + } +} + fn lower_annotations(annotations: &[cst::Annotation]) -> Vec { annotations .iter() @@ -324,15 +601,27 @@ impl Lowerer { } } - fn lower_rule(&mut self, rule: &cst::Rule) -> Rule { + fn lower_rule( + &mut self, + rule: &cst::Rule, + files: &[SourceFile], + preprocessing: &PreprocessingState, + ) -> FrontendResult { let conditions = rule .conditions .iter() .map(|condition| self.lower_expr(condition, &[], CallPosition::Value)) .collect(); let actions = self.lower_block(&rule.actions, &[]); - Rule { - name: prefixed_rule_name(&rule.name, rule.rule_prefix.as_deref(), rule.delimiter), + Ok(Rule { + name: render_rule_name( + &rule.name, + rule.rule_prefix.as_deref(), + rule.delimiter, + rule.span, + files, + preprocessing, + )?, span: Some(rule.span.into()), name_span: Some(rule.name_span.into()), disabled: rule.disabled, @@ -351,7 +640,7 @@ impl Lowerer { }, conditions, actions, - } + }) } /// Lower a statement block; `macro_params` names resolve to `MacroParam`. @@ -1445,6 +1734,57 @@ mod tests { assert!(matches!(receiver.as_ref(), HirExpr::PlayerVar { name, .. } if name == "B")); } + #[test] + fn rule_prefix_template_is_global_and_subroutine_identity_is_preserved() { + let text = "rule \"before\":\n pass\ndef source_name():\n @Name \"Friendly\"\n pass\nrule \"after\":\n pass\n"; + let tokens = lex(LexInput { file_id: 0, text }).expect("lexes"); + let output = parse(&tokens); + assert!( + output.errors.is_empty(), + "unexpected parse errors: {:?}", + output.errors + ); + let program = output.program.expect("program"); + let preprocessing = PreprocessingState { + rule_prefix_template: Some(crate::hir::types::DirectiveValue { + value: "f\"[{$pathTitle.replace('_', ' ')}] {$rule}\" if $rule and not $isDelimiter else $rule".to_string(), + span: None, + }), + ..PreprocessingState::default() + }; + let hir = lower_with_preprocessing( + &program, + vec![SourceFile { + id: 0, + path: "main.opy".to_string(), + }], + vec![], + &preprocessing, + ) + .expect("lowers"); + let names: Vec<_> = hir + .rules + .iter() + .map(|entry| match entry { + HirRuleEntry::Rule(rule) => rule.name.clone(), + HirRuleEntry::SubroutineDef { name, .. } => name.clone(), + }) + .collect(); + assert_eq!( + names, + vec!["[Main] before", "[Main] Friendly", "[Main] after"] + ); + let HirRuleEntry::SubroutineDef { + name, source_name, .. + } = &hir.rules[1] + else { + panic!("expected subroutine definition"); + }; + assert_eq!(name, "[Main] Friendly"); + assert_eq!(source_name, "source_name"); + } + } + #[test] fn receiver_call_values_lower_in_conditions() { // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value; diff --git a/crates/opy-frontend/src/parser.rs b/crates/opy-frontend/src/parser.rs index ebfbe1c..fc0838f 100644 --- a/crates/opy-frontend/src/parser.rs +++ b/crates/opy-frontend/src/parser.rs @@ -24,10 +24,16 @@ pub struct ParseOutput { /// Parse an expanded token stream into a CST program. pub fn parse(tokens: &[Token]) -> ParseOutput { + parse_with_options(tokens, false) +} + +/// Parse with the global redeclaration policy observed by the pinned oracle. +pub fn parse_with_options(tokens: &[Token], allow_macro_redeclaration: bool) -> ParseOutput { let mut parser = Parser { tokens, pos: 0, errors: Vec::new(), + allow_macro_redeclaration, }; let program = parser.parse_program(); if parser.errors.is_empty() { @@ -47,6 +53,7 @@ struct Parser<'a> { tokens: &'a [Token], pos: usize, errors: Vec, + allow_macro_redeclaration: bool, } fn is_identifier(text: &str) -> bool { @@ -296,6 +303,15 @@ impl Parser<'_> { if self.peek_kind() == TokenKind::Ident { let member = self.advance(); let member_span = member.span; + if !self.allow_macro_redeclaration + && members.iter().any(|(name, _)| name == &member.text) + { + self.errors.push(FrontendError::at( + "macro-redeclaration", + format!("enum member '{name}.{}' is already defined", member.text), + member_span, + )); + } members.push((member.text, member_span)); } else { self.error_at_current("expected an enum member name".to_string()); @@ -323,6 +339,7 @@ impl Parser<'_> { fn parse_macro(&mut self, declarations: &mut Vec) -> bool { let start = self.advance(); + let name_token = self.peek().clone(); let name = match self.expect_ident("a macro name") { Ok(name) => name, Err(()) => return false, @@ -343,6 +360,17 @@ impl Parser<'_> { None => return false, }; let body = self.parse_block(body_indent); + if !self.allow_macro_redeclaration + && declarations.iter().any(|declaration| { + matches!(declaration, Decl::Macro { name: existing, .. } if existing == &name) + }) + { + self.errors.push(FrontendError::at( + "macro-redeclaration", + format!("macro '{name}' is already defined"), + name_token.span, + )); + } declarations.push(Decl::Macro { name, args, @@ -784,12 +812,11 @@ impl Parser<'_> { return false; } let _ = (disabled, delimiter, new_page); - let name = annotations + let presentation_name = annotations .iter() .find(|annotation| annotation.name == "Name") .and_then(|annotation| annotation.args.first()) - .map(|arg| unquote_annotation_arg(&arg.text)) - .unwrap_or(name); + .map(|arg| unquote_annotation_arg(&arg.text)); let body = self.parse_block(body_indent); let span = if name_token.kind == TokenKind::Ident { Span::new(start.span.file, start.span.start, name_token.span.end) @@ -798,6 +825,7 @@ impl Parser<'_> { }; rules.push(RuleEntry::SubroutineDef { name, + presentation_name, span, name_span, body, @@ -1517,6 +1545,28 @@ mod tests { assert_eq!(body.len(), 1); } + #[test] + fn macro_and_enum_redeclarations_are_checked_at_ast_surfaces() { + let text = "enum Kind:\n First\n First\nmacro helper():\n pass\nmacro helper():\n pass\n"; + let errors = parse_err(text); + assert_eq!( + errors + .iter() + .filter(|error| error.code == "macro-redeclaration") + .count(), + 2 + ); + + let tokens = lex(LexInput { file_id: 0, text }).unwrap(); + let output = parse_with_options(&tokens, true); + assert!( + output.errors.is_empty(), + "unexpected errors: {:?}", + output.errors + ); + assert!(output.program.is_some()); + } + #[test] fn multiple_errors_are_reported() { let errors = diff --git a/crates/opy-frontend/src/preprocess.rs b/crates/opy-frontend/src/preprocess.rs index 7229ad8..6983697 100644 --- a/crates/opy-frontend/src/preprocess.rs +++ b/crates/opy-frontend/src/preprocess.rs @@ -46,7 +46,8 @@ use opy_macro_js::{Limits, MacroArg, MacroError, MacroRuntime}; use crate::diag::{FrontendError, FrontendResult, Span}; use crate::hir::types::{ - DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingState, TranslationState, + DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingSnapshot, PreprocessingState, + TranslationState, }; use crate::lexer::{LexInput, Token, TokenKind, lex}; use crate::settings::SettingsBlock; @@ -498,7 +499,11 @@ impl Preprocessor { span, )); } - let template = rest.trim(); + let template = if rest.trim().is_empty() { + r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"# + } else { + rest.trim() + }; self.preprocessing.rule_prefix_template = Some(DirectiveValue { value: template.to_string(), span: Some(span.into()), @@ -541,10 +546,37 @@ impl Preprocessor { } fn record(&mut self, name: &str, value: Option<&str>, span: Span) { + let state = PreprocessingSnapshot { + allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration, + optimization: self.preprocessing.optimization.clone(), + rule_prefix: self + .preprocessing + .rule_prefix + .as_ref() + .map(|value| value.value.clone()), + rule_prefix_template: self + .preprocessing + .rule_prefix_template + .as_ref() + .map(|value| value.value.clone()), + translations: self + .preprocessing + .translations + .as_ref() + .map(|translations| translations.languages.clone()), + replacements: self + .preprocessing + .replacements + .iter() + .map(|value| value.value.clone()) + .collect(), + }; self.preprocessing.directives.push(DirectiveRecord { name: name.to_string(), value: value.map(str::to_string), scope_col: span.start.col, + scope_depth: self.include_stack.len() as u32, + state, span: Some(span.into()), }); } @@ -639,6 +671,7 @@ impl Preprocessor { }); self.include_stack.push(identity); let saved_prefix = self.preprocessing.rule_prefix.clone(); + let saved_optimization = self.preprocessing.optimization.clone(); // Settings blocks are only supported in the main file; an included // file's block is rejected at its keyword span (file id of the // included file, #86). @@ -659,7 +692,11 @@ impl Preprocessor { })?; let processed = self.process_directives(&mut included); self.preprocessing.rule_prefix = saved_prefix; - processed?; + self.preprocessing.optimization = saved_optimization; + if let Err(error) = processed { + self.include_stack.pop(); + return Err(error); + } // Drop the included file's Eof token (it terminates the file, not // the spliced stream). included.retain(|token| token.kind != TokenKind::Eof); @@ -669,6 +706,7 @@ impl Preprocessor { // in diagnostics (include cycles/not-found name the real path). out.extend(included); self.include_stack.pop(); + self.record("include", Some(include), span); Ok(()) } @@ -1054,18 +1092,17 @@ fn parse_translations(rest: &str, span: Span) -> FrontendResult> { span, )); } - if values.iter().any(|language| !is_language_tag(language)) { - return Err(FrontendError::at( - "translations-invalid", - "translation languages must use an alphabetic language tag with an optional region", - span, - )); - } - let mut seen = std::collections::BTreeSet::new(); - if values.iter().any(|language| !seen.insert(language)) { + const PINNED_LANGUAGES: &[&str] = &[ + "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr", + "zh", "zh_cn", "zh_tw", + ]; + if values + .iter() + .any(|language| !PINNED_LANGUAGES.contains(&language.as_str())) + { return Err(FrontendError::at( "translations-invalid", - "translation languages must be unique", + "invalid translation language; expected one of the pinned OverPy language codes", span, )); } @@ -1094,21 +1131,6 @@ fn parse_translations(rest: &str, span: Span) -> FrontendResult> { Ok(values) } -fn is_language_tag(value: &str) -> bool { - let mut parts = value.split('_'); - let Some(language) = parts.next() else { - return false; - }; - let language_len = language.chars().count(); - if !(2..=3).contains(&language_len) || !language.chars().all(|ch| ch.is_ascii_lowercase()) { - return false; - } - parts.all(|region| { - (2..=4).contains(®ion.chars().count()) - && region.chars().all(|ch| ch.is_ascii_alphanumeric()) - }) -} - #[derive(Clone, Copy)] enum OptimizationControl { Enable, @@ -1438,6 +1460,61 @@ mod tests { assert_eq!(pre.defines.len(), 1); } + #[test] + fn translations_follow_pinned_codes_without_local_deduplication() { + let (pre, _) = preprocess( + "#!translations EN zh-cn en\nrule \"r\":\n pass\n", + "main.opy", + Path::new("."), + ) + .unwrap(); + assert_eq!( + pre.preprocessing.translations.unwrap().languages, + vec!["en", "zh_cn", "en"] + ); + } + + #[test] + fn translations_reject_codes_outside_the_pinned_oracle_set() { + let error = preprocess( + "#!translations en_US\nrule \"r\":\n pass\n", + "main.opy", + Path::new("."), + ) + .unwrap_err(); + assert_eq!(error.code, "translations-invalid"); + } + + #[test] + fn directive_records_expose_state_transitions_and_include_depth() { + let root = + std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("child.opy"), + "#!rulePrefix \"inner\"\n#!disableOptimizations\n", + ) + .unwrap(); + let (pre, _) = preprocess( + "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n", + "main.opy", + &root, + ) + .unwrap(); + let records = &pre.preprocessing.directives; + assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer")); + assert_eq!(records[0].scope_depth, 0); + assert_eq!(records[1].name, "rulePrefix"); + assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner")); + assert!(!records[2].state.optimization.enabled); + assert_eq!(records[2].scope_depth, 1); + assert_eq!(records[3].name, "include"); + assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer")); + assert_eq!(records[4].name, "enableOptimizations"); + assert!(records[4].state.optimization.enabled); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn malformed_translation_state_is_source_located() { let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err(); diff --git a/crates/opy-frontend/src/tooling.rs b/crates/opy-frontend/src/tooling.rs index c2cea7e..7274f79 100644 --- a/crates/opy-frontend/src/tooling.rs +++ b/crates/opy-frontend/src/tooling.rs @@ -97,7 +97,10 @@ pub fn check_with_overlay( }; } }; - let parsed = crate::parser::parse(&preprocessed.tokens); + let parsed = crate::parser::parse_with_options( + &preprocessed.tokens, + preprocessed.preprocessing.allow_macro_redeclaration, + ); let Some(mut program) = parsed.program else { // The parser recovers at statement boundaries; every collected error // is reported (the compile pipeline reads only the first). @@ -143,7 +146,12 @@ pub fn check_with_overlay( path: file.path.clone(), }) .collect(); - match crate::lower::lower(&program, hir_files, defines) { + match crate::lower::lower_with_preprocessing( + &program, + hir_files, + defines, + &preprocessed.preprocessing, + ) { Ok(mut hir) => { hir.preprocessing = preprocessed.preprocessing; CheckOutcome { @@ -404,6 +412,7 @@ impl SemanticModel { for entry in &self.hir.rules { let RuleEntry::SubroutineDef { name, + source_name, name_span, span, .. @@ -418,7 +427,11 @@ impl SemanticModel { continue; }; self.symbols.push(Symbol { - name: name.clone(), + name: if source_name.is_empty() { + name.clone() + } else { + source_name.clone() + }, kind: SymbolKind::Def, declaration, references: Vec::new(), diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 13bcef6..bcf3be6 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -205,6 +205,24 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { true, "mainFile entry-point redirect and child-include scope; oracle status success.", ); + resolve( + &mut cases, + "synthetic/issue-31-positive", + false, + "pinned positive probe for global rulePrefixTemplate, include prefix restoration, AST macro/enum redeclaration, and translation normalization.", + ); + diagnostic( + &mut cases, + "synthetic/issue-31-negative", + Some("translations-invalid"), + "pinned negative probe for a language code outside the exact translation set.", + ); + resolve( + &mut cases, + "synthetic/issue-31-nested-scope", + false, + "nested include optimization directives are retained as observable scoped state; optimizer execution remains outside opy-rs.", + ); resolve( &mut cases, "synthetic/settings", From 26bfd035d35c2d2d5707cc1e68398469764cb9fb Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:45:35 +0800 Subject: [PATCH 4/7] chore(opy): remove generated translation fixture files --- .../fixtures/synthetic/issue-31-positive/source.en.po | 5 ----- .../fixtures/synthetic/issue-31-positive/source.zh_cn.po | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 compatibility/fixtures/synthetic/issue-31-positive/source.en.po delete mode 100644 compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po diff --git a/compatibility/fixtures/synthetic/issue-31-positive/source.en.po b/compatibility/fixtures/synthetic/issue-31-positive/source.en.po deleted file mode 100644 index 24ccfd7..0000000 --- a/compatibility/fixtures/synthetic/issue-31-positive/source.en.po +++ /dev/null @@ -1,5 +0,0 @@ -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"Language: en\n" -"X-Generator: OverPy\n" diff --git a/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po b/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po deleted file mode 100644 index 83a43a5..0000000 --- a/compatibility/fixtures/synthetic/issue-31-positive/source.zh_cn.po +++ /dev/null @@ -1,5 +0,0 @@ -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"Language: zh_cn\n" -"X-Generator: OverPy\n" From 0157e7f93945637d93361b04e83398235cccf527 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:46:05 +0800 Subject: [PATCH 5/7] fix(opy): satisfy preprocessing snapshot lint --- crates/opy-frontend/src/hir/types.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/opy-frontend/src/hir/types.rs b/crates/opy-frontend/src/hir/types.rs index 60b46dd..117197e 100644 --- a/crates/opy-frontend/src/hir/types.rs +++ b/crates/opy-frontend/src/hir/types.rs @@ -191,7 +191,7 @@ pub struct DirectiveRecord { pub span: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct PreprocessingSnapshot { pub allow_macro_redeclaration: bool, pub optimization: OptimizationState, @@ -205,19 +205,6 @@ pub struct PreprocessingSnapshot { pub replacements: Vec, } -impl Default for PreprocessingSnapshot { - fn default() -> Self { - Self { - allow_macro_redeclaration: false, - optimization: OptimizationState::default(), - rule_prefix: None, - rule_prefix_template: None, - translations: None, - replacements: Vec::new(), - } - } -} - /// A custom-game-settings block (`settings { ... }`, #86). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Settings { From 68efc7ce7ce168249152f1448e5e501dcafbe6d3 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 04:47:39 +0800 Subject: [PATCH 6/7] docs(opy): record issue 31 directive evidence --- compatibility/fixtures/README.md | 5 ++++- docs/opy/support-matrix.md | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index fdfd0b9..421a66e 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -27,7 +27,7 @@ fixtures/// ## Synthetic fixtures (WrightKit-authored) -`fixtures/synthetic/` — 13 fixtures authored for the WrightKit compatibility +`fixtures/synthetic/` — 16 fixtures authored for the WrightKit compatibility corpus (same organization as opy-rs; AGPL-3.0-or-later, `kind: original`), ported unchanged: @@ -39,6 +39,9 @@ ported unchanged: | `declarations-numbers` | numeric literal forms and variable-index declarations | | `expressions-values` | expressions, arrays, strings, vectors, calls, `.format` | | `preprocessing` | `#!include` (with `shared.opy`), `#!define` object/function-like, `#!undef` | +| `issue-31-positive` | Pinned positive probe for rule-prefix templates, include prefix restoration, macro/enum redeclaration, and normalized translations | +| `issue-31-negative` | Pinned negative probe for a translation code outside the oracle's exact set | +| `issue-31-nested-scope` | Pinned nested-include probe for observable optimization state transitions | | `diagnostics` | expected-failure fixture with a syntax diagnostic | | `settings` | top-of-file `settings { … }` JSONC block | | `receiver-calls` | receiver/member call forms (derived from the real-world overpy-meipocalypse corpus; see its `fixture.json` provenance note) | diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index af05f6c..2d127dc 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -104,11 +104,15 @@ implements; "reference" always means the pinned OverPy 9.7.10 **JavaScript macros** (see below). - `#!undef NAME`. - `#!mainFile "path.opy"` redirects the frontend entry point and preserves - file provenance; `#!allowMacroRedeclaration` changes duplicate macro - handling; `#!rulePrefix` and `#!rulePrefixTemplate` are retained as source - state and prefix subsequent rule/subroutine names. + file provenance; `#!allowMacroRedeclaration` changes duplicate handling at + `#!define`, enum-member, and AST-macro surfaces. +- `#!rulePrefix`/`#!rulePrefixTemplate` are retained as source state and + rendered against every rule/subroutine (including rules before the template + directive); include prefix state is restored after child includes. - `#!translations`, `#!optimize*`, and `#!replace0By*` forms are parsed, - validated, and exposed as frontend preprocessing state. Locale availability, + validated, and exposed as frontend preprocessing state. Directive records + retain include depth and state snapshots, so nested transitions are + observable without claiming optimizer execution. Locale availability, `.po` content, generated translation helpers, optimizer rewrites, and replacement effects are lowering-dependent and are not fabricated here. - `#!postCompileHook "hook.js"`: post-compile hook registration (see below). From 895908aaee34c77c4176d34eca766975e99e73bf Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 05:10:41 +0800 Subject: [PATCH 7/7] fix(opy): preserve default subroutine display names --- compatibility/support-matrix.json | 2 +- crates/opy-frontend/src/lower.rs | 3 +-- crates/opy-frontend/src/support.rs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 773ddc6..370de27 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -601,7 +601,7 @@ "planned": 2, "frontend-supported": 14, "semantic-supported": 13, - "lowering-dependent": 14, + "lowering-dependent": 13, "end-to-end-supported": 0 }, "byCategory": { diff --git a/crates/opy-frontend/src/lower.rs b/crates/opy-frontend/src/lower.rs index e464e26..7af8e35 100644 --- a/crates/opy-frontend/src/lower.rs +++ b/crates/opy-frontend/src/lower.rs @@ -189,7 +189,7 @@ pub fn lower_with_preprocessing( let base_name = presentation_name .as_deref() .map(str::to_string) - .unwrap_or_else(|| format!("Subroutine {name}")); + .unwrap_or_else(|| name.clone()); let generated_name = render_rule_name( &base_name, rule_prefix.as_deref(), @@ -1783,7 +1783,6 @@ mod tests { assert_eq!(name, "[Main] Friendly"); assert_eq!(source_name, "source_name"); } - } #[test] fn receiver_call_values_lower_in_conditions() { diff --git a/crates/opy-frontend/src/support.rs b/crates/opy-frontend/src/support.rs index b6ca211..9e0d051 100644 --- a/crates/opy-frontend/src/support.rs +++ b/crates/opy-frontend/src/support.rs @@ -218,7 +218,7 @@ mod tests { assert_eq!(syntax.len(), 7); assert!(syntax.iter().all(|feature| feature.category == "syntax")); let lowering = matrix.features_by_state("lowering-dependent"); - assert_eq!(lowering.len(), 14); + assert_eq!(lowering.len(), 13); assert!( lowering .iter()