From e2a67c76db896b560d397fba2dc85c6663feb75d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:42:55 +0800 Subject: [PATCH 1/5] fix(workshop): converge on released workshop-rs surface Fixes #191 --- .github/workflows/ci.yml | 48 ++++ Cargo.lock | 5 +- Cargo.toml | 2 +- crates/wright-analyzer/src/analysis.rs | 1 + crates/wright-analyzer/src/symbols.rs | 1 + crates/wright-cli/Cargo.toml | 1 + crates/wright-cli/tests/workshop_p0.rs | 231 ++++++++++++++++++ crates/wright-driver/src/session.rs | 26 +- crates/wright-driver/src/workshop_provider.rs | 27 +- .../wright-driver/tests/manifest_context.rs | 89 +++---- crates/wright-opy/src/reconstruct.rs | 7 + crates/wright-ostw/src/reconstruct.rs | 9 + crates/wright-ostw/tests/differential.rs | 1 + crates/wright-ostw/tests/reconstruct.rs | 1 + 14 files changed, 349 insertions(+), 100 deletions(-) create mode 100644 crates/wright-cli/tests/workshop_p0.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a74b7c5..91d06a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,54 @@ jobs: - name: Run tests run: cargo test --locked --workspace --all-targets --all-features + # ------------------------------------------------------------------------- + # [1a] RAW WORKSHOP PRODUCT DOGFOOD (Wright's released Workshop consumer) + # Runs the owner-pinned real-project inputs through the actual `wright` + # check/lint commands. The source snapshots and residual expectations remain + # owned by the released workshop-rs corpus. + # ------------------------------------------------------------------------- + workshop-integration: + name: Raw Workshop product dogfood + needs: [paths, rust-quality] + if: needs.paths.outputs.rust_core == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out Wright + uses: actions/checkout@v7 + + - name: Check out released workshop-rs P0 corpus + uses: actions/checkout@v7 + with: + repository: wrightkit/workshop-rs + ref: f2834ce09b83144070a5c8670695663be6f02daf # v0.1.8 + path: workshop-rs-pinned + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.85.0 + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: linux-quality + cache-targets: true + cache-all-crates: false + cache-workspace-crates: false + cache-bin: false + save-if: false + cache-on-failure: false + + - name: Run owner-contract cross-validation + env: + WRIGHTKIT_P0_ARTIFACT_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects + run: cargo test --locked -p wright-driver --test p0_cross_validate -- --ignored + + - name: Run Wright check and lint dogfood + env: + WRIGHTKIT_P0_ARTIFACT_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects + run: cargo test --locked -p wright-cli --test workshop_p0 -- --ignored --nocapture + # ------------------------------------------------------------------------- # [2] OPY INTEGRATION (Wright's consumer contract with OverPy/OPY) # Covers: diff --git a/Cargo.lock b/Cargo.lock index 9c6eb31..2c8ea06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1779,9 +1779,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e741d6faadf9c0df949d76dbf4faa2af6799fae56ffbc780be975ed10855066a" +checksum = "fb8b6c25a292fbb06742c158966a628de20aecc543e218ddfef03219c431aad4" dependencies = [ "serde", "serde_json", @@ -1824,6 +1824,7 @@ dependencies = [ "sha2", "tar", "ureq", + "workshop-rs", "wright-driver", "wright-transform", ] diff --git a/Cargo.toml b/Cargo.toml index f57553a..2a4cf24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/wrightkit/wright" # parser, emitter, detection, validation, and Workshop IR. This is the single # released reference for the cutover — workspace crates consume it via # `workshop-rs.workspace = true`. -workshop-rs = "=0.1.5" +workshop-rs = "=0.1.8" libc = "0.2" serde = "1" serde_json = "1" diff --git a/crates/wright-analyzer/src/analysis.rs b/crates/wright-analyzer/src/analysis.rs index f75f5c6..c9318e1 100644 --- a/crates/wright-analyzer/src/analysis.rs +++ b/crates/wright-analyzer/src/analysis.rs @@ -643,6 +643,7 @@ fn visit_value_children(value: &Value, f: &mut impl FnMut(ValueId)) { } Value::Number { .. } | Value::String(_) + | Value::LocalizedString(_) | Value::Bool(_) | Value::Null | Value::Enum { .. } diff --git a/crates/wright-analyzer/src/symbols.rs b/crates/wright-analyzer/src/symbols.rs index a0ea771..618218a 100644 --- a/crates/wright-analyzer/src/symbols.rs +++ b/crates/wright-analyzer/src/symbols.rs @@ -537,6 +537,7 @@ impl<'a> Builder<'a> { } Value::Number { .. } | Value::String(_) + | Value::LocalizedString(_) | Value::Bool(_) | Value::Null | Value::Enum { .. } diff --git a/crates/wright-cli/Cargo.toml b/crates/wright-cli/Cargo.toml index 5d3f780..ed18bf8 100644 --- a/crates/wright-cli/Cargo.toml +++ b/crates/wright-cli/Cargo.toml @@ -28,3 +28,4 @@ wright-transform.workspace = true [dev-dependencies] insta = { version = "1", features = ["json"] } jsonschema = "0.18" +workshop-rs.workspace = true diff --git a/crates/wright-cli/tests/workshop_p0.rs b/crates/wright-cli/tests/workshop_p0.rs new file mode 100644 index 0000000..302b4d4 --- /dev/null +++ b/crates/wright-cli/tests/workshop_p0.rs @@ -0,0 +1,231 @@ +//! Product dogfood for the pinned raw Workshop corpus (#191). +//! +//! The source artifacts are owned by `workshop-rs`; this test consumes the +//! owner-provided expectation contract and invokes the actual `wright` binary +//! for both public product commands. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use workshop_rs::p0::{P0_EXPECTATION, P0ResidualExpectation}; +use wright_driver::workshop_provider::{diagnostic_code, status_for_classification}; + +fn wright() -> &'static str { + env!("CARGO_BIN_EXE_wright") +} + +#[test] +#[ignore = "requires the released workshop-rs P0 fixture checkout"] +fn pinned_real_projects_run_check_and_lint_through_wright() { + let artifact_root = std::env::var_os("WRIGHTKIT_P0_ARTIFACT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| panic!("WRIGHTKIT_P0_ARTIFACT_DIR must be set for this test")); + assert!( + artifact_root.is_absolute() && artifact_root.is_dir(), + "WRIGHTKIT_P0_ARTIFACT_DIR must be an absolute directory: {}", + artifact_root.display() + ); + + for case in P0_EXPECTATION.cases { + let path = find_artifact_by_hash(&artifact_root, case.source_sha256, case.id); + let (check, check_success) = run_json("check", &path); + let (lint, lint_success) = run_json("lint", &path); + + let expected_diagnostics = case + .residuals + .iter() + .map(expected_diagnostic) + .collect::>(); + let actual_diagnostics = check["diagnostics"] + .as_array() + .expect("check diagnostics array") + .iter() + .map(|diagnostic| { + ( + diagnostic["code"] + .as_str() + .expect("diagnostic code") + .to_string(), + diagnostic["status"] + .as_str() + .expect("diagnostic status") + .to_string(), + ) + }) + .collect::>(); + assert_eq!( + actual_diagnostics, expected_diagnostics, + "{} check diagnostics", + case.id + ); + assert_eq!( + check["ok"], + serde_json::Value::Bool(expected_diagnostics.is_empty()), + "{} check status", + case.id + ); + assert_eq!( + check_success, + expected_diagnostics.is_empty(), + "{} check exit status", + case.id + ); + assert_eq!(check["command"], "check"); + + for diagnostic in check["diagnostics"].as_array().unwrap() { + assert_eq!( + diagnostic["span"]["path"], + path.to_string_lossy().as_ref(), + "{} diagnostics retain source attribution", + case.id + ); + } + + assert_eq!(lint["ok"], check["ok"], "{} lint status", case.id); + assert_eq!(lint_success, check_success, "{} lint exit status", case.id); + assert_eq!(lint["command"], "lint"); + assert_eq!(lint["result"]["program"]["origin"]["kind"], "workshop"); + assert_eq!( + lint["result"]["program"]["origin"]["locale"], + case.locale.to_ascii_lowercase(), + "{} locale detection", + case.id + ); + assert!( + lint["result"]["program"]["rules"] + .as_u64() + .is_some_and(|rules| rules > 0), + "{} lint must reach the canonical semantic program", + case.id + ); + assert_eq!( + lint["result"]["rules"].as_array().map(Vec::len), + Some(5), + "{} lint must execute every default rule", + case.id + ); + + let file_name = path + .file_name() + .expect("fixture filename") + .to_string_lossy(); + let findings = lint["result"]["findings"] + .as_array() + .expect("lint findings array"); + let review = findings + .iter() + .map(|finding| { + let code = finding["code"].as_str().expect("finding code"); + let evidence = finding["evidence"].as_str().expect("finding evidence"); + assert!( + matches!(evidence, "exact" | "heuristic" | "static-indicator"), + "{} finding {code} has unknown evidence class {evidence}", + case.id + ); + assert_eq!( + finding["span"]["path"], + file_name.as_ref(), + "{} finding {code} retains source attribution", + case.id + ); + serde_json::json!({ + "code": code, + "severity": finding["severity"], + "evidence": evidence, + "span": finding["span"], + }) + }) + .collect::>(); + println!( + "WORKSHOP_P0 {}", + serde_json::json!({ + "corpus": workshop_rs::p0::P0_CORPUS_ID, + "case": case.id, + "artifact": path, + "sourceSha256": case.source_sha256, + "check": { + "ok": check["ok"], + "diagnostics": check["diagnostics"], + }, + "lint": { + "ok": lint["ok"], + "rules": lint["result"]["rules"].as_array().map(Vec::len), + "findings": review, + }, + }) + ); + } +} + +fn run_json(command: &str, path: &Path) -> (serde_json::Value, bool) { + let output = Command::new(wright()) + .args([command, "--kind", "workshop"]) + .arg(path) + .args(["-f", "json"]) + .output() + .unwrap_or_else(|error| panic!("wright {command} failed to start: {error}")); + match serde_json::from_slice(&output.stdout) { + Ok(value) => (value, output.status.success()), + Err(error) => panic!( + "wright {command} returned invalid JSON (exit {}): {error}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ), + } +} + +fn expected_diagnostic(residual: &P0ResidualExpectation) -> (String, String) { + ( + diagnostic_code(residual_kind_code(residual.kind), residual.identity), + serde_json::to_value(status_for_classification(residual.classification)) + .expect("provider status serializes") + .as_str() + .expect("provider status is a string") + .to_string(), + ) +} + +fn residual_kind_code(kind: workshop_rs::semantic::IncompletenessKind) -> &'static str { + match kind { + workshop_rs::semantic::IncompletenessKind::RawSetting => "raw-setting", + workshop_rs::semantic::IncompletenessKind::UnknownAction => "unknown-action", + workshop_rs::semantic::IncompletenessKind::UnknownValue => "unknown-value", + workshop_rs::semantic::IncompletenessKind::OpaqueAction => "opaque-action", + } +} + +fn find_artifact_by_hash(root: &Path, expected_hash: &str, case_id: &str) -> PathBuf { + let mut matches = Vec::new(); + collect_artifact_matches(root, expected_hash, &mut matches); + assert_eq!( + matches.len(), + 1, + "{case_id} must resolve exactly one artifact by owner-provided SHA-256, found {}", + matches.len() + ); + matches.pop().expect("one artifact match") +} + +fn collect_artifact_matches(root: &Path, expected_hash: &str, matches: &mut Vec) { + for entry in std::fs::read_dir(root).unwrap_or_else(|error| { + panic!( + "cannot read P0 artifact directory {}: {error}", + root.display() + ) + }) { + let entry = entry.expect("read P0 artifact directory entry"); + let path = entry.path(); + let file_type = entry.file_type().expect("inspect P0 artifact entry"); + if file_type.is_dir() { + collect_artifact_matches(&path, expected_hash, matches); + } else if file_type.is_file() { + let bytes = std::fs::read(&path).unwrap_or_else(|error| { + panic!("cannot read P0 artifact {}: {error}", path.display()) + }); + if wright_driver::sha256_hex(&bytes) == expected_hash { + matches.push(path); + } + } + } +} diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 3fbbf38..d7d74f1 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -279,26 +279,11 @@ impl CompilerSession { override_locale.as_ref(), ) .map_err(|error| workshop_diag(error, resolved))?; - // Context-sensitive bare-enum resolution (#111): the #109 canonical - // signature metadata pins the expected domain for call arguments, so - // emitter-produced text like `Chase Global Variable Over Time(..., - // None)` reparses instead of failing on the ambiguous `None`. The - // canonical Workshop catalog supplies the remaining domains the - // manifest does not document (e.g. Create HUD Text's `HudReeval` - // reevaluation argument, #118). - let manifest = wright_opy::manifest::Manifest::builtin().map_err(|error| { - Diagnostic::error( - "manifest-error", - Stage::Frontend, - format!("cannot load the OPY semantic compatibility manifest: {error}"), - ) - })?; - let context = wright_core::signatures::ChainedExpectedDomain::new(manifest, &self.catalog); let program = workshop_rs::parser::parse_with_context( &resolved.text, &self.catalog, &locale, - &context, + &self.catalog, ) .map_err(|error| workshop_diag(error, resolved))?; self.progress(ProgressEvent::new(ProgressPhase::Validation)); @@ -542,10 +527,10 @@ impl CompilerSession { /// `lint`: load and produce the source identity, program summary, rule /// metadata, effective configuration, and findings (#98). /// - /// Lint findings are reported in `result.findings`, not in the envelope - /// diagnostics (like `analyze`). Rule enable/disable and severity come - /// from `self.config.lint`, the same configuration the CLI flags and - /// programmatic consumers set. + /// Lint rule findings are reported in `result.findings`; frontend and + /// Workshop semantic-completeness diagnostics remain in the envelope. + /// Rule enable/disable and severity come from `self.config.lint`, the same + /// configuration the CLI flags and programmatic consumers set. pub fn lint(&mut self) -> Envelope { let command = "lint"; let loaded = match self.load() { @@ -560,6 +545,7 @@ impl CompilerSession { // then run the shared semantic service over the lowered program. self.push_ostw_diagnostics(&loaded); } + self.attach_workshop_completeness(&loaded); let service = match self.service_with(&loaded, self.config.lint.clone()) { Ok(service) => service, Err(diagnostic) => { diff --git a/crates/wright-driver/src/workshop_provider.rs b/crates/wright-driver/src/workshop_provider.rs index 47d2a10..745ff0a 100644 --- a/crates/wright-driver/src/workshop_provider.rs +++ b/crates/wright-driver/src/workshop_provider.rs @@ -6,8 +6,6 @@ use wright_core::provider::{ Diagnostic as ProviderDiagnostic, LanguageProvider, ProviderError, Result as ProviderResult, Severity as ProviderSeverity, SourceSpan as ProviderSourceSpan, Status, }; -use wright_core::signatures::ExpectedDomain as WrightExpectedDomain; - /// Wright's in-process provider for localized raw Workshop source. pub struct WorkshopProvider { catalog: workshop_rs::catalog::Catalog, @@ -26,14 +24,8 @@ impl LanguageProvider for WorkshopProvider { fn check(&self, source: &str, path: &Path) -> ProviderResult> { let locale = workshop_rs::detect::resolve_locale(source, &self.catalog, None) .map_err(|error| ProviderError::new("workshop.locale", error.to_string()))?; - let manifest = wright_opy::manifest::Manifest::builtin() - .map_err(|error| ProviderError::new("workshop.manifest", error.to_string()))?; - let context = ProviderExpectedDomain { - manifest, - catalog: &self.catalog, - }; let program = - workshop_rs::parser::parse_with_context(source, &self.catalog, &locale, &context) + workshop_rs::parser::parse_with_context(source, &self.catalog, &locale, &self.catalog) .map_err(|error| ProviderError::new("workshop.parse", error.to_string()))?; program .validate() @@ -47,23 +39,6 @@ impl LanguageProvider for WorkshopProvider { } } -struct ProviderExpectedDomain<'a> { - manifest: &'a wright_opy::manifest::Manifest, - catalog: &'a workshop_rs::catalog::Catalog, -} - -impl workshop_rs::signatures::ExpectedDomain for ProviderExpectedDomain<'_> { - fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> { - WrightExpectedDomain::expected_domain(self.manifest, catalog_id, arg_index).or_else(|| { - workshop_rs::signatures::ExpectedDomain::expected_domain( - self.catalog, - catalog_id, - arg_index, - ) - }) - } -} - fn map_issue(issue: workshop_rs::semantic::SemanticIssue, path: &Path) -> ProviderDiagnostic { let (kind_code, severity) = match issue.kind { workshop_rs::semantic::IncompletenessKind::RawSetting => { diff --git a/crates/wright-driver/tests/manifest_context.rs b/crates/wright-driver/tests/manifest_context.rs index b0edfc0..f977765 100644 --- a/crates/wright-driver/tests/manifest_context.rs +++ b/crates/wright-driver/tests/manifest_context.rs @@ -1,19 +1,16 @@ -//! Manifest-wired Workshop parse context (#109/#111). +//! Canonical Workshop signature context (#111). //! -//! These regressions protect the shipped driver wiring: the session parses -//! Workshop text with the OPY compatibility manifest chained onto the catalog -//! as the expected-domain context (`CompilerSession`'s parse path, #111), so -//! emitter-produced ambiguous bare `None` members reparse to their pinned -//! enum domains and the emission is a fixed point. Preserved from the -//! removed `wright-workshop` cutover adapter's test suite; the context-free -//! parser/emitter/round-trip behavior itself is owned and tested by -//! `workshop-rs`. +//! These regressions protect the consumer path against ambiguous bare `None` +//! members. The expected-domain context is supplied by the canonical +//! `workshop-rs` catalog; the catalog data and context-free parser behavior +//! remain owned and tested by `workshop-rs`. use std::path::Path; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::parser; use workshop_rs::roundtrip; +use workshop_rs::signatures::ExpectedDomain; use workshop_rs::wir; fn catalog() -> Catalog { @@ -24,10 +21,14 @@ fn en() -> Locale { Locale::new("en-US") } -/// The canonical signature context from the #109 manifest, as the shipped -/// driver wires it into the Workshop parse path (#111). -fn manifest_context() -> &'static dyn wright_core::signatures::ExpectedDomain { - wright_opy::manifest::Manifest::builtin().expect("builtin manifest") +fn parse_with_catalog(text: &str) -> wir::Program { + let catalog = catalog(); + parser::parse_with_context(text, &catalog, &en(), &catalog).expect("catalog context parses") +} + +fn round_trip_with_catalog(text: &str) -> roundtrip::RoundTripRecord { + let catalog = catalog(); + roundtrip::round_trip_with_context(text, &catalog, &en(), &catalog) } /// The last argument value of the first call action of a parsed program. @@ -47,9 +48,7 @@ fn context_pinned_ambiguous_none_resolves_via_canonical_signature() { // reparses to ChaseTimeReeval.NONE because the canonical chaseOverTime // signature pins argument 3 to the ChaseTimeReeval domain. let text = "variables { global: 0: g }\nrule (\"x\") { event { Ongoing - Global; } actions { Chase Global Variable Over Time(Global.g, 0, 30, None); } }"; - let program = - parser::parse_with_context(text, &catalog(), &Locale::new("en-US"), manifest_context()) - .expect("the pinned Chase None must resolve"); + let program = parse_with_catalog(text); let value = enum_value_of_first_action(&program, 0); assert!( matches!(value, wir::Value::Enum { value_type, value } @@ -61,13 +60,11 @@ fn context_pinned_ambiguous_none_resolves_via_canonical_signature() { #[test] fn context_pinned_ambiguous_none_resolves_for_set_invisible() { // #111: `Set Invisible(Event Player, None)` reparses to Invis.NONE. The - // manifest's setInvisibility is a member action, so Workshop text places + // the catalog's setInvisibility is a member action, so Workshop text places // the receiver as argument 0 and the signature-pinned parameter at // argument 1. let text = "rule (\"x\") { event { Ongoing - Each Player; } actions { Set Invisible(Event Player, None); } }"; - let program = - parser::parse_with_context(text, &catalog(), &Locale::new("en-US"), manifest_context()) - .expect("the pinned Invis None must resolve"); + let program = parse_with_catalog(text); let value = enum_value_of_first_action(&program, 0); assert!( matches!(value, wir::Value::Enum { value_type, value } @@ -83,9 +80,9 @@ fn wrong_domain_context_keeps_the_ambiguity_rejected() { // no `None` member), so the bare `None` stays ambiguous — no guessing, // no arbitrary precedence. let text = "rule (\"x\") { event { Ongoing - Global; } actions { Wait(0.016, None); } }"; - let error = - parser::parse_with_context(text, &catalog(), &Locale::new("en-US"), manifest_context()) - .expect_err("a non-matching expected domain must keep the ambiguity"); + let catalog = catalog(); + let error = parser::parse_with_context(text, &catalog, &en(), &catalog) + .expect_err("a non-matching expected domain must keep the ambiguity"); assert!( matches!(error, workshop_rs::WorkshopError::Unsupported { .. }), "expected a structured ambiguity: {error}" @@ -94,28 +91,20 @@ fn wrong_domain_context_keeps_the_ambiguity_rejected() { } #[test] -fn expected_domain_resolution_tracks_the_manifest_declared_domains() { - // Behavioral check that resolution consumes the #109 manifest as the - // single source of expected domains: the adapter answers exactly the - // manifest's declared parameter domains, including the receiver-offset - // rule for member-kind functions. - let manifest = wright_opy::manifest::Manifest::builtin().expect("builtin manifest"); - use wright_core::signatures::ExpectedDomain; +fn expected_domain_resolution_comes_from_the_canonical_catalog() { + let catalog = catalog(); // chaseOverTime: params [variable, destination, duration, reevaluation]. assert_eq!( - manifest.expected_domain("chaseOverTime", 3), + catalog.expected_domain("chaseOverTime", 3), Some("ChaseTimeReeval") ); - assert_eq!(manifest.expected_domain("chaseOverTime", 2), None); + assert_eq!(catalog.expected_domain("chaseOverTime", 2), None); // setInvisibility: member action; Workshop arg 1 is the pinned param. - assert_eq!(manifest.expected_domain("setInvisibility", 0), None); - assert_eq!( - manifest.expected_domain("setInvisibility", 1), - Some("Invis") - ); + assert_eq!(catalog.expected_domain("setInvisibility", 0), None); + assert_eq!(catalog.expected_domain("setInvisibility", 1), Some("Invis")); // Unknown catalog ids and out-of-range indexes answer None. - assert_eq!(manifest.expected_domain("noSuchAction", 0), None); - assert_eq!(manifest.expected_domain("chaseOverTime", 4), None); + assert_eq!(catalog.expected_domain("noSuchAction", 0), None); + assert_eq!(catalog.expected_domain("chaseOverTime", 4), None); } #[test] @@ -125,7 +114,7 @@ fn emitter_chase_none_round_trips_through_the_shipped_path() { // to ChaseTimeReeval.NONE through the shipped parse+emit path, and the // round-tripped WIR is equivalent to the input WIR. let text = "variables { global: 0: g }\nrule (\"chase\") { event { Ongoing - Global; } actions { Chase Global Variable Over Time(Global.g, 0, 30, None); } }"; - let record = roundtrip::round_trip_with_context(text, &catalog(), &en(), manifest_context()); + let record = round_trip_with_catalog(text); assert!( record.error.is_none(), "the pinned Chase None must round-trip: {:?}", @@ -139,7 +128,7 @@ fn emitter_set_invisible_none_round_trips_through_the_shipped_path() { // #111: `Set Invisible(Event Player, None)` reparses to Invis.NONE via // the member-function receiver offset and round-trips to equivalent WIR. let text = "rule (\"inv\") { event { Ongoing - Each Player; } actions { Set Invisible(Event Player, None); } }"; - let record = roundtrip::round_trip_with_context(text, &catalog(), &en(), manifest_context()); + let record = round_trip_with_catalog(text); assert!( record.error.is_none(), "the pinned Invis None must round-trip: {:?}", @@ -152,10 +141,10 @@ fn emitter_set_invisible_none_round_trips_through_the_shipped_path() { fn emitter_chase_at_rate_none_round_trips_through_the_shipped_path() { // #110: the chase rate form emits `Chase Global Variable At Rate(..., // None)`; the catalog id `chaseAtRate` selects the `ChaseRateReeval` - // domain through the manifest's contextual-dispatch data, so the bare + // domain through the catalog's contextual-dispatch data, so the bare // `None` reparses and round-trips to equivalent WIR. let text = "variables { global: 0: g }\nrule (\"chase\") { event { Ongoing - Global; } actions { Chase Global Variable At Rate(Global.g, 10, 2, None); } }"; - let record = roundtrip::round_trip_with_context(text, &catalog(), &en(), manifest_context()); + let record = round_trip_with_catalog(text); assert!( record.error.is_none(), "the pinned ChaseRateReeval None must round-trip: {:?}", @@ -164,7 +153,7 @@ fn emitter_chase_at_rate_none_round_trips_through_the_shipped_path() { assert!(record.parse_ok && record.emit_ok && record.reparse_ok && record.equivalent); // The player form follows the same path through its own catalog id. let text = "variables { player: 0: P }\nrule (\"chase\") { event { Ongoing - Each Player; } actions { Chase Player Variable At Rate(Event Player, P, 0, 1, None); } }"; - let record = roundtrip::round_trip_with_context(text, &catalog(), &en(), manifest_context()); + let record = round_trip_with_catalog(text); assert!( record.error.is_none(), "the pinned player ChaseRateReeval None must round-trip: {:?}", @@ -178,7 +167,7 @@ fn chase_keyword_fixture_round_trips_through_the_shipped_path() { // The `synthetic/chase-keywords` surface (rate/duration forms, global // and player variables, keyword-bound wait/vect/len/print/ // getPlayersInRadius/setStatusEffect) compiles through the native OPY - // frontend, emits through the catalog, reparses with the manifest + // frontend, emits through the catalog, reparses with the catalog // signature context, and re-emits to a fixed point (#110). The oracle // text itself is not the input: the reference emits bare variable names // where the native Workshop parser's canonical spelling is `Global.g` @@ -195,11 +184,9 @@ fn chase_keyword_fixture_round_trips_through_the_shipped_path() { let wir = wright_ir::lower::lower(&model).expect("the fixture lowers to WIR"); let emitted = workshop_rs::emitter::emit(&wir, &catalog(), &en()).expect("the fixture emits"); // The emission includes Debug/Print HUD text (canonical catalog layout) - // and chase `None` members, so both the manifest and the catalog supply + // and chase `None` members, so the catalog supplies // the expected enum domains. - let catalog = catalog(); - let context = wright_core::signatures::ChainedExpectedDomain::new(manifest_context(), &catalog); - let record = roundtrip::round_trip_with_context(&emitted, &catalog, &en(), &context); + let record = round_trip_with_catalog(&emitted); assert!( record.error.is_none(), "the chase-keywords emission must round-trip: {:?}", @@ -226,14 +213,14 @@ fn context_chase_none_emission_is_a_fixed_point() { // emit again: the text is a fixed point. let text = "variables { global: 0: g }\nrule (\"chase\") { event { Ongoing - Global; } actions { Chase Global Variable Over Time(Global.g, 0, 30, None); } }"; let catalog = catalog(); - let first = parser::parse_with_context(text, &catalog, &en(), manifest_context()) + let first = parser::parse_with_context(text, &catalog, &en(), &catalog) .expect("pinned Chase None parses"); let emitted = workshop_rs::emitter::emit(&first, &catalog, &en()).expect("emits"); assert!( emitted.contains("Chase Global Variable Over Time(Global.g, 0, 30, None)"), "emission preserves the bare None spelling:\n{emitted}" ); - let reparsed = parser::parse_with_context(&emitted, &catalog, &en(), manifest_context()) + let reparsed = parser::parse_with_context(&emitted, &catalog, &en(), &catalog) .expect("emitted text reparses with context"); let reemitted = workshop_rs::emitter::emit(&reparsed, &catalog, &en()).expect("re-emits"); assert_eq!(emitted, reemitted, "emission must be a fixed point"); diff --git a/crates/wright-opy/src/reconstruct.rs b/crates/wright-opy/src/reconstruct.rs index d9ff070..d37b7f6 100644 --- a/crates/wright-opy/src/reconstruct.rs +++ b/crates/wright-opy/src/reconstruct.rs @@ -1464,6 +1464,13 @@ impl<'a> Emitter<'a> { } } Value::String(value) => self.emit_string_literal(value), + Value::LocalizedString(value) => { + self.issue( + "unsupported-localized-string", + format!("localized Workshop preset string '{value}' has no OPY source representation"), + node.span, + ); + } Value::Bool(value) => { self.out.push_str(if *value { "true" } else { "false" }); } diff --git a/crates/wright-ostw/src/reconstruct.rs b/crates/wright-ostw/src/reconstruct.rs index 56e4c4e..3070bf4 100644 --- a/crates/wright-ostw/src/reconstruct.rs +++ b/crates/wright-ostw/src/reconstruct.rs @@ -675,6 +675,14 @@ impl<'a> Classifier<'a> { } } Value::String(_) | Value::Bool(_) | Value::Null | Value::EventPlayer => {} + Value::LocalizedString(value) => { + self.error(ReconstructError::at( + "reconstruct-unsupported-localized-string", + format!("localized-string:{value}"), + "localized Workshop preset strings have no OSTW source representation", + node.span, + )); + } Value::Array(elements) => { for element in elements { self.check_value(*element); @@ -1075,6 +1083,7 @@ impl<'a> Emitter<'a> { match &node.value { Value::Number { text, .. } => text.clone(), Value::String(value) => format!("\"{}\"", escape_string(value)), + Value::LocalizedString(_) => unreachable!("classified"), Value::Bool(true) => "true".to_string(), Value::Bool(false) => "false".to_string(), Value::Null => "null".to_string(), diff --git a/crates/wright-ostw/tests/differential.rs b/crates/wright-ostw/tests/differential.rs index 9d37322..15352dc 100644 --- a/crates/wright-ostw/tests/differential.rs +++ b/crates/wright-ostw/tests/differential.rs @@ -1183,6 +1183,7 @@ fn value_kind(value: &Value) -> &'static str { match value { Value::Number { .. } => "number", Value::String(_) => "string", + Value::LocalizedString(_) => "localizedString", Value::Bool(_) => "bool", Value::Null => "null", Value::Array(_) => "array", diff --git a/crates/wright-ostw/tests/reconstruct.rs b/crates/wright-ostw/tests/reconstruct.rs index caee758..af90a64 100644 --- a/crates/wright-ostw/tests/reconstruct.rs +++ b/crates/wright-ostw/tests/reconstruct.rs @@ -997,6 +997,7 @@ fn value_kind(value: &Value) -> &'static str { match value { Value::Number { .. } => "number", Value::String(_) => "string", + Value::LocalizedString(_) => "localizedString", Value::Bool(_) => "bool", Value::Null => "null", Value::Array(_) => "array", From 22dc4241f35e639fdf3f6481c2c40e584bb9aa4c Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:32:13 +0800 Subject: [PATCH 2/5] build(deps): consume released workshop-rs 0.1.9 --- .github/workflows/ci.yml | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d06a9..eb96071 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,7 +153,7 @@ jobs: uses: actions/checkout@v7 with: repository: wrightkit/workshop-rs - ref: f2834ce09b83144070a5c8670695663be6f02daf # v0.1.8 + ref: 3d61bd4423924ca005d67f7c141a1866580f76fa # v0.1.9 path: workshop-rs-pinned - name: Install Rust toolchain diff --git a/Cargo.lock b/Cargo.lock index 2c8ea06..8bc6d60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1779,9 +1779,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "workshop-rs" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8b6c25a292fbb06742c158966a628de20aecc543e218ddfef03219c431aad4" +checksum = "b566f0887d7ba1c9aafe12bcb51575bec59a2f37985c6a1103dbdd86028a23c4" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2a4cf24..0d34219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/wrightkit/wright" # parser, emitter, detection, validation, and Workshop IR. This is the single # released reference for the cutover — workspace crates consume it via # `workshop-rs.workspace = true`. -workshop-rs = "=0.1.8" +workshop-rs = "=0.1.9" libc = "0.2" serde = "1" serde_json = "1" From f1b4efd78ddf2961a47d9218a693ca53d9774f90 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:02:12 +0800 Subject: [PATCH 3/5] test(workshop): name integration coverage by capability --- .github/workflows/ci.yml | 16 ++++----- ...rkshop_p0.rs => workshop_real_projects.rs} | 33 +++++++++++-------- ...cross_validate.rs => workshop_contract.rs} | 30 +++++++++-------- 3 files changed, 44 insertions(+), 35 deletions(-) rename crates/wright-cli/tests/{workshop_p0.rs => workshop_real_projects.rs} (87%) rename crates/wright-driver/tests/{p0_cross_validate.rs => workshop_contract.rs} (77%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb96071..21c443c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,13 +135,13 @@ jobs: run: cargo test --locked --workspace --all-targets --all-features # ------------------------------------------------------------------------- - # [1a] RAW WORKSHOP PRODUCT DOGFOOD (Wright's released Workshop consumer) + # [1a] WORKSHOP REAL-PROJECT INTEGRATION (Wright's released Workshop consumer) # Runs the owner-pinned real-project inputs through the actual `wright` # check/lint commands. The source snapshots and residual expectations remain # owned by the released workshop-rs corpus. # ------------------------------------------------------------------------- workshop-integration: - name: Raw Workshop product dogfood + name: Workshop real-project integration needs: [paths, rust-quality] if: needs.paths.outputs.rust_core == 'true' runs-on: ubuntu-latest @@ -149,7 +149,7 @@ jobs: - name: Check out Wright uses: actions/checkout@v7 - - name: Check out released workshop-rs P0 corpus + - name: Check out released workshop-rs corpus uses: actions/checkout@v7 with: repository: wrightkit/workshop-rs @@ -174,13 +174,13 @@ jobs: - name: Run owner-contract cross-validation env: - WRIGHTKIT_P0_ARTIFACT_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects - run: cargo test --locked -p wright-driver --test p0_cross_validate -- --ignored + WRIGHTKIT_WORKSHOP_CORPUS_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects + run: cargo test --locked -p wright-driver --test workshop_contract -- --ignored - - name: Run Wright check and lint dogfood + - name: Run Wright check and lint on real projects env: - WRIGHTKIT_P0_ARTIFACT_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects - run: cargo test --locked -p wright-cli --test workshop_p0 -- --ignored --nocapture + WRIGHTKIT_WORKSHOP_CORPUS_DIR: ${{ github.workspace }}/workshop-rs-pinned/crates/workshop-rs/tests/fixtures/real-projects + run: cargo test --locked -p wright-cli --test workshop_real_projects -- --ignored --nocapture # ------------------------------------------------------------------------- # [2] OPY INTEGRATION (Wright's consumer contract with OverPy/OPY) diff --git a/crates/wright-cli/tests/workshop_p0.rs b/crates/wright-cli/tests/workshop_real_projects.rs similarity index 87% rename from crates/wright-cli/tests/workshop_p0.rs rename to crates/wright-cli/tests/workshop_real_projects.rs index 302b4d4..bb95b98 100644 --- a/crates/wright-cli/tests/workshop_p0.rs +++ b/crates/wright-cli/tests/workshop_real_projects.rs @@ -1,4 +1,4 @@ -//! Product dogfood for the pinned raw Workshop corpus (#191). +//! Real-project integration coverage for the pinned Workshop corpus. //! //! The source artifacts are owned by `workshop-rs`; this test consumes the //! owner-provided expectation contract and invokes the actual `wright` binary @@ -8,7 +8,9 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Command; -use workshop_rs::p0::{P0_EXPECTATION, P0ResidualExpectation}; +use workshop_rs::p0::{ + P0_EXPECTATION as WORKSHOP_EXPECTATION, P0ResidualExpectation as WorkshopResidualExpectation, +}; use wright_driver::workshop_provider::{diagnostic_code, status_for_classification}; fn wright() -> &'static str { @@ -16,18 +18,18 @@ fn wright() -> &'static str { } #[test] -#[ignore = "requires the released workshop-rs P0 fixture checkout"] -fn pinned_real_projects_run_check_and_lint_through_wright() { - let artifact_root = std::env::var_os("WRIGHTKIT_P0_ARTIFACT_DIR") +#[ignore = "requires the released workshop-rs Workshop corpus checkout"] +fn real_projects_run_check_and_lint_through_wright() { + let artifact_root = std::env::var_os("WRIGHTKIT_WORKSHOP_CORPUS_DIR") .map(PathBuf::from) - .unwrap_or_else(|| panic!("WRIGHTKIT_P0_ARTIFACT_DIR must be set for this test")); + .unwrap_or_else(|| panic!("WRIGHTKIT_WORKSHOP_CORPUS_DIR must be set for this test")); assert!( artifact_root.is_absolute() && artifact_root.is_dir(), - "WRIGHTKIT_P0_ARTIFACT_DIR must be an absolute directory: {}", + "WRIGHTKIT_WORKSHOP_CORPUS_DIR must be an absolute directory: {}", artifact_root.display() ); - for case in P0_EXPECTATION.cases { + for case in WORKSHOP_EXPECTATION.cases { let path = find_artifact_by_hash(&artifact_root, case.source_sha256, case.id); let (check, check_success) = run_json("check", &path); let (lint, lint_success) = run_json("lint", &path); @@ -138,7 +140,7 @@ fn pinned_real_projects_run_check_and_lint_through_wright() { }) .collect::>(); println!( - "WORKSHOP_P0 {}", + "WORKSHOP_CORPUS {}", serde_json::json!({ "corpus": workshop_rs::p0::P0_CORPUS_ID, "case": case.id, @@ -175,7 +177,7 @@ fn run_json(command: &str, path: &Path) -> (serde_json::Value, bool) { } } -fn expected_diagnostic(residual: &P0ResidualExpectation) -> (String, String) { +fn expected_diagnostic(residual: &WorkshopResidualExpectation) -> (String, String) { ( diagnostic_code(residual_kind_code(residual.kind), residual.identity), serde_json::to_value(status_for_classification(residual.classification)) @@ -210,18 +212,21 @@ fn find_artifact_by_hash(root: &Path, expected_hash: &str, case_id: &str) -> Pat fn collect_artifact_matches(root: &Path, expected_hash: &str, matches: &mut Vec) { for entry in std::fs::read_dir(root).unwrap_or_else(|error| { panic!( - "cannot read P0 artifact directory {}: {error}", + "cannot read Workshop corpus directory {}: {error}", root.display() ) }) { - let entry = entry.expect("read P0 artifact directory entry"); + let entry = entry.expect("read Workshop corpus directory entry"); let path = entry.path(); - let file_type = entry.file_type().expect("inspect P0 artifact entry"); + let file_type = entry.file_type().expect("inspect Workshop corpus entry"); if file_type.is_dir() { collect_artifact_matches(&path, expected_hash, matches); } else if file_type.is_file() { let bytes = std::fs::read(&path).unwrap_or_else(|error| { - panic!("cannot read P0 artifact {}: {error}", path.display()) + panic!( + "cannot read Workshop corpus artifact {}: {error}", + path.display() + ) }); if wright_driver::sha256_hex(&bytes) == expected_hash { matches.push(path); diff --git a/crates/wright-driver/tests/p0_cross_validate.rs b/crates/wright-driver/tests/workshop_contract.rs similarity index 77% rename from crates/wright-driver/tests/p0_cross_validate.rs rename to crates/wright-driver/tests/workshop_contract.rs index d9fa609..20ec427 100644 --- a/crates/wright-driver/tests/p0_cross_validate.rs +++ b/crates/wright-driver/tests/workshop_contract.rs @@ -2,25 +2,25 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; -use workshop_rs::p0::P0_EXPECTATION; +use workshop_rs::p0::P0_EXPECTATION as WORKSHOP_EXPECTATION; use workshop_rs::semantic::{IncompletenessKind, ResidualClassification}; use wright_core::provider::{LanguageProvider, Status}; use wright_driver::{WorkshopProvider, workshop_provider}; #[test] -#[ignore = "requires the pinned external P0 artifact directory"] -fn provider_matches_workshop_rs_p0_expectations() { - let artifact_root = std::env::var_os("WRIGHTKIT_P0_ARTIFACT_DIR") +#[ignore = "requires the pinned external Workshop corpus directory"] +fn provider_matches_workshop_contract() { + let artifact_root = std::env::var_os("WRIGHTKIT_WORKSHOP_CORPUS_DIR") .map(PathBuf::from) - .unwrap_or_else(|| panic!("WRIGHTKIT_P0_ARTIFACT_DIR must be set for this test")); + .unwrap_or_else(|| panic!("WRIGHTKIT_WORKSHOP_CORPUS_DIR must be set for this test")); assert!( artifact_root.is_absolute() && artifact_root.is_dir(), - "WRIGHTKIT_P0_ARTIFACT_DIR must be an absolute directory: {}", + "WRIGHTKIT_WORKSHOP_CORPUS_DIR must be an absolute directory: {}", artifact_root.display() ); let provider = WorkshopProvider::new().expect("provider initializes"); - for case in P0_EXPECTATION.cases { + for case in WORKSHOP_EXPECTATION.cases { let path = find_artifact_by_hash(&artifact_root, case.source_sha256, case.id); let bytes = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); @@ -30,7 +30,7 @@ fn provider_matches_workshop_rs_p0_expectations() { "{} source hash", case.id ); - let source = String::from_utf8(bytes).expect("P0 source is UTF-8"); + let source = String::from_utf8(bytes).expect("Workshop source is UTF-8"); let diagnostics = provider .check(&source, &path) .unwrap_or_else(|error| panic!("{} provider failure: {error}", case.id)); @@ -73,14 +73,14 @@ fn find_artifact_by_hash(root: &Path, expected_hash: &str, case_id: &str) -> Pat fn collect_artifact_matches(root: &Path, expected_hash: &str, matches: &mut Vec) { let entries = std::fs::read_dir(root).unwrap_or_else(|error| { panic!( - "cannot read P0 artifact directory {}: {error}", + "cannot read Workshop corpus directory {}: {error}", root.display() ) }); for entry in entries { - let entry = entry.expect("read P0 artifact directory entry"); + let entry = entry.expect("read Workshop corpus directory entry"); let path = entry.path(); - let file_type = entry.file_type().expect("inspect P0 artifact entry"); + let file_type = entry.file_type().expect("inspect Workshop corpus entry"); if file_type.is_dir() { collect_artifact_matches(&path, expected_hash, matches); continue; @@ -88,8 +88,12 @@ fn collect_artifact_matches(root: &Path, expected_hash: &str, matches: &mut Vec< if !file_type.is_file() { continue; } - let bytes = std::fs::read(&path) - .unwrap_or_else(|error| panic!("cannot read P0 artifact {}: {error}", path.display())); + let bytes = std::fs::read(&path).unwrap_or_else(|error| { + panic!( + "cannot read Workshop corpus artifact {}: {error}", + path.display() + ) + }); if format!("{:x}", Sha256::digest(&bytes)) == expected_hash { matches.push(path); } From 1ef4526791ab2eea3f04236e352481363c976b86 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:10:32 +0800 Subject: [PATCH 4/5] test(workshop): remove brittle integration assumptions --- Cargo.lock | 1 + crates/wright-cli/Cargo.toml | 1 + .../tests/workshop_real_projects.rs | 24 ++++++++++++++++--- ...ntext.rs => workshop_signature_context.rs} | 10 ++++---- 4 files changed, 28 insertions(+), 8 deletions(-) rename crates/wright-driver/tests/{manifest_context.rs => workshop_signature_context.rs} (97%) diff --git a/Cargo.lock b/Cargo.lock index 8bc6d60..1e3d221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1825,6 +1825,7 @@ dependencies = [ "tar", "ureq", "workshop-rs", + "wright-analyzer", "wright-driver", "wright-transform", ] diff --git a/crates/wright-cli/Cargo.toml b/crates/wright-cli/Cargo.toml index ed18bf8..ffeb10f 100644 --- a/crates/wright-cli/Cargo.toml +++ b/crates/wright-cli/Cargo.toml @@ -28,4 +28,5 @@ wright-transform.workspace = true [dev-dependencies] insta = { version = "1", features = ["json"] } jsonschema = "0.18" +wright-analyzer.workspace = true workshop-rs.workspace = true diff --git a/crates/wright-cli/tests/workshop_real_projects.rs b/crates/wright-cli/tests/workshop_real_projects.rs index bb95b98..80d62d9 100644 --- a/crates/wright-cli/tests/workshop_real_projects.rs +++ b/crates/wright-cli/tests/workshop_real_projects.rs @@ -11,6 +11,7 @@ use std::process::Command; use workshop_rs::p0::{ P0_EXPECTATION as WORKSHOP_EXPECTATION, P0ResidualExpectation as WorkshopResidualExpectation, }; +use wright_analyzer::registry::LintRegistry; use wright_driver::workshop_provider::{diagnostic_code, status_for_classification}; fn wright() -> &'static str { @@ -101,10 +102,27 @@ fn real_projects_run_check_and_lint_through_wright() { "{} lint must reach the canonical semantic program", case.id ); + let lint_rules = lint["result"]["rules"] + .as_array() + .expect("lint rules array"); + let expected_rule_ids = LintRegistry::default() + .rules() + .map(|rule| rule.id) + .collect::>(); + let actual_rule_ids = lint_rules + .iter() + .map(|rule| rule["id"].as_str().expect("lint rule id")) + .collect::>(); assert_eq!( - lint["result"]["rules"].as_array().map(Vec::len), - Some(5), - "{} lint must execute every default rule", + actual_rule_ids, expected_rule_ids, + "{} lint must report the authoritative default rule registry", + case.id + ); + assert!( + lint_rules + .iter() + .all(|rule| rule["enabled"].as_bool() == Some(true)), + "{} lint must enable every default rule", case.id ); diff --git a/crates/wright-driver/tests/manifest_context.rs b/crates/wright-driver/tests/workshop_signature_context.rs similarity index 97% rename from crates/wright-driver/tests/manifest_context.rs rename to crates/wright-driver/tests/workshop_signature_context.rs index f977765..6780add 100644 --- a/crates/wright-driver/tests/manifest_context.rs +++ b/crates/wright-driver/tests/workshop_signature_context.rs @@ -43,7 +43,7 @@ fn enum_value_of_first_action(program: &wir::Program, action_index: usize) -> &w } #[test] -fn context_pinned_ambiguous_none_resolves_via_canonical_signature() { +fn catalog_signature_resolves_ambiguous_none_for_chase() { // #111: emitter-produced `Chase Global Variable Over Time(..., None)` // reparses to ChaseTimeReeval.NONE because the canonical chaseOverTime // signature pins argument 3 to the ChaseTimeReeval domain. @@ -58,7 +58,7 @@ fn context_pinned_ambiguous_none_resolves_via_canonical_signature() { } #[test] -fn context_pinned_ambiguous_none_resolves_for_set_invisible() { +fn catalog_signature_resolves_ambiguous_none_for_set_invisible() { // #111: `Set Invisible(Event Player, None)` reparses to Invis.NONE. The // the catalog's setInvisibility is a member action, so Workshop text places // the receiver as argument 0 and the signature-pinned parameter at @@ -74,7 +74,7 @@ fn context_pinned_ambiguous_none_resolves_for_set_invisible() { } #[test] -fn wrong_domain_context_keeps_the_ambiguity_rejected() { +fn mismatched_catalog_signature_keeps_ambiguity_rejected() { // A signature pinning a *different* domain than the ambiguous member's // candidates must not resolve it: `Wait(...)` expects `Wait` (which has // no `None` member), so the bare `None` stays ambiguous — no guessing, @@ -196,7 +196,7 @@ fn chase_keyword_fixture_round_trips_through_the_shipped_path() { } #[test] -fn context_free_chase_none_stays_a_documented_exception() { +fn context_free_parser_preserves_ambiguous_none_boundary() { // Without a signature pin the ambiguity stays rejected: the same input // through the plain (context-free) round-trip fails at parse, keeping the // pre-#111 boundary deterministic. @@ -208,7 +208,7 @@ fn context_free_chase_none_stays_a_documented_exception() { } #[test] -fn context_chase_none_emission_is_a_fixed_point() { +fn catalog_signature_emission_is_a_fixed_point() { // Parse the emitted form with context, emit, reparse with context, and // emit again: the text is a fixed point. let text = "variables { global: 0: g }\nrule (\"chase\") { event { Ongoing - Global; } actions { Chase Global Variable Over Time(Global.g, 0, 30, None); } }"; From ce59c50cca95d5ae60ce02d59d4b899dfee8a67d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:35:41 +0800 Subject: [PATCH 5/5] refactor: complete source-language ownership cutover Delegate OPY and OSTW source semantics to their owner repositories, keep Wright adapters narrow, remove duplicate parser and lowering implementations, and migrate shared consumers and documentation. Fixes #227 Refs #155 Refs #182 --- AGENTS.md | 12 +- Cargo.lock | 294 +- Cargo.toml | 3 + crates/wright-driver/src/edit.rs | 13 +- crates/wright-driver/src/session.rs | 30 +- crates/wright-driver/tests/convert.rs | 66 +- crates/wright-language/src/service.rs | 53 +- crates/wright-opy/Cargo.toml | 4 +- crates/wright-opy/src/cst.rs | 289 -- crates/wright-opy/src/diag.rs | 75 - crates/wright-opy/src/lexer.rs | 503 ---- crates/wright-opy/src/lib.rs | 430 ++- crates/wright-opy/src/lower.rs | 2280 --------------- .../src/manifest/data/manifest.json | 823 ------ crates/wright-opy/src/manifest/mod.rs | 935 ------ .../probes/action-in-value-position.opy | 4 - .../src/manifest/probes/aliases.opy | 5 - .../src/manifest/probes/builtin-enums.opy | 51 - .../manifest/probes/catalog-only-names.opy | 5 - .../probes/chase-arg3-keyword-required.opy | 5 - .../probes/chase-duplicate-keyword.opy | 5 - .../manifest/probes/chase-keyword-binding.opy | 13 - .../src/manifest/probes/chase-keywords.opy | 9 - .../probes/chase-missing-argument.opy | 5 - .../probes/chase-over-time-defaults.opy | 4 - .../probes/chase-over-time-variable.opy | 5 - .../src/manifest/probes/chase-over-time.opy | 4 - .../probes/chase-positional-after-keyword.opy | 5 - .../manifest/probes/chase-reeval-context.opy | 6 - .../manifest/probes/chase-reeval-outside.opy | 5 - .../probes/chase-reeval-wrong-domain.opy | 5 - .../manifest/probes/chase-unknown-keyword.opy | 5 - .../probes/chase-variable-first-arg.opy | 5 - .../src/manifest/probes/enum-arg-non-enum.opy | 4 - .../src/manifest/probes/enum-arg-variable.opy | 4 - .../probes/enum-domain-mismatch-1.opy | 4 - .../probes/enum-domain-mismatch-2.opy | 4 - .../manifest/probes/enum-gated-members.opy | 7 - .../src/manifest/probes/generic-builtins.opy | 16 - .../probes/generic-member-only-action.opy | 4 - .../manifest/probes/get-players-in-radius.opy | 6 - .../manifest/probes/invalid-arity-member.opy | 4 - .../manifest/probes/invalid-arity-too-few.opy | 4 - .../manifest/probes/invalid-arity-wait.opy | 4 - .../probes/invalid-receiver-append.opy | 4 - .../probes/invalid-receiver-format.opy | 4 - .../manifest/probes/is-game-in-progress.opy | 5 - .../probes/keyword-arguments-unsupported.opy | 6 - .../src/manifest/probes/member-aliases.opy | 6 - .../member-value-in-action-position.opy | 4 - .../src/manifest/probes/probes.json | 313 -- .../src/manifest/probes/range-for-header.opy | 5 - .../src/manifest/probes/range-standalone.opy | 5 - .../src/manifest/probes/receiver-calls.opy | 19 - .../src/manifest/probes/unknown-enum.opy | 4 - .../src/manifest/probes/unknown-function.opy | 4 - .../src/manifest/probes/unknown-member.opy | 4 - .../src/manifest/probes/unknown-value.opy | 4 - .../probes/value-in-action-position.opy | 4 - .../manifest/probes/wait-keyword-names.opy | 5 - crates/wright-opy/src/parser.rs | 1386 --------- crates/wright-opy/src/preprocess.rs | 728 ----- crates/wright-opy/src/reconstruct.rs | 2576 ----------------- crates/wright-opy/src/settings.rs | 841 ------ crates/wright-opy/tests/differential.rs | 210 -- crates/wright-opy/tests/reconstruct.rs | 921 ------ crates/wright-ostw/Cargo.toml | 3 +- crates/wright-ostw/src/cst.rs | 431 --- crates/wright-ostw/src/diag.rs | 51 - crates/wright-ostw/src/lexer.rs | 376 --- crates/wright-ostw/src/lib.rs | 524 +++- crates/wright-ostw/src/parser.rs | 1174 -------- crates/wright-ostw/src/project.rs | 459 --- crates/wright-ostw/src/reconstruct.rs | 1276 -------- crates/wright-ostw/src/semantic.rs | 1625 ----------- crates/wright-ostw/src/signature.rs | 531 ---- crates/wright-ostw/tests/differential.rs | 1291 --------- crates/wright-ostw/tests/parse.rs | 721 ----- crates/wright-ostw/tests/reconstruct.rs | 2520 ---------------- crates/wright-ostw/tests/semantic.rs | 289 -- docs/README.md | 4 +- docs/architecture.md | 18 +- docs/cli.md | 40 +- docs/compatibility.md | 12 +- docs/compatibility/upstream-references.md | 12 +- docs/embedding.md | 16 +- docs/hir/opy-hir-v1.md | 4 +- docs/language-services.md | 26 +- docs/opy/compat-manifest-spec.md | 12 +- docs/opy/support-matrix.md | 12 +- docs/ostw/support-matrix.md | 8 +- 91 files changed, 1224 insertions(+), 23286 deletions(-) delete mode 100644 crates/wright-opy/src/cst.rs delete mode 100644 crates/wright-opy/src/diag.rs delete mode 100644 crates/wright-opy/src/lexer.rs delete mode 100644 crates/wright-opy/src/lower.rs delete mode 100644 crates/wright-opy/src/manifest/data/manifest.json delete mode 100644 crates/wright-opy/src/manifest/mod.rs delete mode 100644 crates/wright-opy/src/manifest/probes/action-in-value-position.opy delete mode 100644 crates/wright-opy/src/manifest/probes/aliases.opy delete mode 100644 crates/wright-opy/src/manifest/probes/builtin-enums.opy delete mode 100644 crates/wright-opy/src/manifest/probes/catalog-only-names.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-arg3-keyword-required.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-duplicate-keyword.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-keyword-binding.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-keywords.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-missing-argument.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-over-time-defaults.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-over-time-variable.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-over-time.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-positional-after-keyword.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-reeval-context.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-reeval-outside.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-reeval-wrong-domain.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-unknown-keyword.opy delete mode 100644 crates/wright-opy/src/manifest/probes/chase-variable-first-arg.opy delete mode 100644 crates/wright-opy/src/manifest/probes/enum-arg-non-enum.opy delete mode 100644 crates/wright-opy/src/manifest/probes/enum-arg-variable.opy delete mode 100644 crates/wright-opy/src/manifest/probes/enum-domain-mismatch-1.opy delete mode 100644 crates/wright-opy/src/manifest/probes/enum-domain-mismatch-2.opy delete mode 100644 crates/wright-opy/src/manifest/probes/enum-gated-members.opy delete mode 100644 crates/wright-opy/src/manifest/probes/generic-builtins.opy delete mode 100644 crates/wright-opy/src/manifest/probes/generic-member-only-action.opy delete mode 100644 crates/wright-opy/src/manifest/probes/get-players-in-radius.opy delete mode 100644 crates/wright-opy/src/manifest/probes/invalid-arity-member.opy delete mode 100644 crates/wright-opy/src/manifest/probes/invalid-arity-too-few.opy delete mode 100644 crates/wright-opy/src/manifest/probes/invalid-arity-wait.opy delete mode 100644 crates/wright-opy/src/manifest/probes/invalid-receiver-append.opy delete mode 100644 crates/wright-opy/src/manifest/probes/invalid-receiver-format.opy delete mode 100644 crates/wright-opy/src/manifest/probes/is-game-in-progress.opy delete mode 100644 crates/wright-opy/src/manifest/probes/keyword-arguments-unsupported.opy delete mode 100644 crates/wright-opy/src/manifest/probes/member-aliases.opy delete mode 100644 crates/wright-opy/src/manifest/probes/member-value-in-action-position.opy delete mode 100644 crates/wright-opy/src/manifest/probes/probes.json delete mode 100644 crates/wright-opy/src/manifest/probes/range-for-header.opy delete mode 100644 crates/wright-opy/src/manifest/probes/range-standalone.opy delete mode 100644 crates/wright-opy/src/manifest/probes/receiver-calls.opy delete mode 100644 crates/wright-opy/src/manifest/probes/unknown-enum.opy delete mode 100644 crates/wright-opy/src/manifest/probes/unknown-function.opy delete mode 100644 crates/wright-opy/src/manifest/probes/unknown-member.opy delete mode 100644 crates/wright-opy/src/manifest/probes/unknown-value.opy delete mode 100644 crates/wright-opy/src/manifest/probes/value-in-action-position.opy delete mode 100644 crates/wright-opy/src/manifest/probes/wait-keyword-names.opy delete mode 100644 crates/wright-opy/src/parser.rs delete mode 100644 crates/wright-opy/src/preprocess.rs delete mode 100644 crates/wright-opy/src/reconstruct.rs delete mode 100644 crates/wright-opy/src/settings.rs delete mode 100644 crates/wright-opy/tests/differential.rs delete mode 100644 crates/wright-opy/tests/reconstruct.rs delete mode 100644 crates/wright-ostw/src/cst.rs delete mode 100644 crates/wright-ostw/src/diag.rs delete mode 100644 crates/wright-ostw/src/lexer.rs delete mode 100644 crates/wright-ostw/src/parser.rs delete mode 100644 crates/wright-ostw/src/project.rs delete mode 100644 crates/wright-ostw/src/reconstruct.rs delete mode 100644 crates/wright-ostw/src/semantic.rs delete mode 100644 crates/wright-ostw/src/signature.rs delete mode 100644 crates/wright-ostw/tests/differential.rs delete mode 100644 crates/wright-ostw/tests/parse.rs delete mode 100644 crates/wright-ostw/tests/reconstruct.rs delete mode 100644 crates/wright-ostw/tests/semantic.rs diff --git a/AGENTS.md b/AGENTS.md index 7c6d54c..dc51d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,15 +28,9 @@ Wright is not the durable owner of those language implementations. refactoring, agent/embedding APIs, CI presentation, editor-neutral language services, LSP, and integration adapters. -Terminology: - -- **frontend** is an internal stage inside a language implementation; do not use - it as shorthand for the product identity of `opy-rs` or `del-rs`; -- **provider** is an integration role/process exposed through LPP or another - reviewed boundary; it does not make a language implementation subordinate to - Wright; -- Wright may integrate through native Rust APIs and/or LPP depending on the - product boundary, but must not pull language ownership back into this repo. +Wright may integrate through narrow Rust adapters and/or LPP depending on the +product boundary. The adapters translate owner contracts; they do not define +language syntax, semantics, HIR, compatibility data, or lowering policy. See [`docs/adr/0010-independent-implementations-and-wright-integration.md`](docs/adr/0010-independent-implementations-and-wright-integration.md). diff --git a/Cargo.lock b/Cargo.lock index 1e3d221..d1563cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -166,7 +186,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", ] [[package]] @@ -175,6 +206,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.6" @@ -241,6 +283,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "copy_dir" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3" +dependencies = [ + "walkdir", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -269,6 +320,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "del-rs" +version = "0.1.0" +source = "git+https://github.com/wrightkit/del-rs.git?rev=22e42dccb039feea4fda5fce9a31ac0da90f3dea#22e42dccb039feea4fda5fce9a31ac0da90f3dea" +dependencies = [ + "clap", + "clap_complete", + "serde", + "serde_json", + "toml", + "workshop-rs", +] + [[package]] name = "deranged" version = "0.5.8" @@ -299,12 +363,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -481,6 +557,18 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -671,6 +759,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "insta" version = "1.48.0" @@ -702,7 +800,16 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1" dependencies = [ - "nom", + "nom 8.0.0", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", ] [[package]] @@ -711,6 +818,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -764,6 +881,27 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libquickjs-ng-sys" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868cb21bc05a07b59c56fc46a5d2714d22ca460c65048ca68c905b52d73b2d92" +dependencies = [ + "bindgen", + "cc", + "copy_dir", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -810,6 +948,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -831,6 +975,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -936,6 +1090,35 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opy-compiler" +version = "0.2.14" +source = "git+https://github.com/wrightkit/opy-rs.git?rev=e319735e93344302e1b2d827d9809683997723a7#e319735e93344302e1b2d827d9809683997723a7" +dependencies = [ + "opy-rs", + "serde_json", + "workshop-rs", +] + +[[package]] +name = "opy-macro-js" +version = "0.2.14" +source = "git+https://github.com/wrightkit/opy-rs.git?rev=e319735e93344302e1b2d827d9809683997723a7#e319735e93344302e1b2d827d9809683997723a7" +dependencies = [ + "libquickjs-ng-sys", + "serde_json", +] + +[[package]] +name = "opy-rs" +version = "0.2.14" +source = "git+https://github.com/wrightkit/opy-rs.git?rev=e319735e93344302e1b2d827d9809683997723a7#e319735e93344302e1b2d827d9809683997723a7" +dependencies = [ + "opy-macro-js", + "serde", + "serde_json", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -996,6 +1179,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1112,6 +1305,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -1172,6 +1371,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1232,6 +1440,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1255,6 +1472,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -1432,6 +1655,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -1576,6 +1838,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1683,6 +1955,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1771,6 +2052,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -1911,6 +2198,8 @@ dependencies = [ name = "wright-opy" version = "0.2.14" dependencies = [ + "opy-compiler", + "opy-rs", "serde", "serde_json", "sha2", @@ -1923,6 +2212,7 @@ dependencies = [ name = "wright-ostw" version = "0.2.14" dependencies = [ + "del-rs", "serde_json", "sha2", "workshop-rs", diff --git a/Cargo.toml b/Cargo.toml index 0d34219..1344121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ repository = "https://github.com/wrightkit/wright" # released reference for the cutover — workspace crates consume it via # `workshop-rs.workspace = true`. workshop-rs = "=0.1.9" +opy-rs = { git = "https://github.com/wrightkit/opy-rs.git", rev = "e319735e93344302e1b2d827d9809683997723a7" } +opy-compiler = { git = "https://github.com/wrightkit/opy-rs.git", rev = "e319735e93344302e1b2d827d9809683997723a7" } +del-rs = { git = "https://github.com/wrightkit/del-rs.git", rev = "22e42dccb039feea4fda5fce9a31ac0da90f3dea" } libc = "0.2" serde = "1" serde_json = "1" diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index e167dd1..d6e39b7 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -840,23 +840,12 @@ fn compile_project( if has_error(&diagnostics) { return Err(diagnostics); } - let Some(hir) = semantic.hir else { + let Some(program) = semantic.wir else { // The frontend outcome carries no reachable semantic HIR and // no diagnostics: the session path treats this as an empty // program (check succeeds), so rename finds no symbols. return Ok(workshop_rs::wir::Program::default()); }; - let program = match wright_ir::lower::lower(&hir) { - Ok(program) => program, - Err(error) => { - return Err(vec![session::ir_diag( - "lower-error", - crate::diag::Stage::Lowering, - error, - resolved, - )]); - } - }; if let Err(error) = program.validate() { return Err(vec![session::ir_diag( "validation-error", diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index d7d74f1..6bb302b 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -197,10 +197,7 @@ impl CompilerSession { if let Some(error) = &outcome.error { return Err(ostw_diag(error.clone(), &outcome, resolved)); } - let program = match &semantic.hir { - Some(hir) => self.load_ir_model(hir, resolved)?, - None => wir::Program::default(), - }; + let program = semantic.wir.clone().unwrap_or_default(); let loaded = Loaded { program: Arc::new(program), ostw: Some(Arc::new(outcome)), @@ -212,27 +209,6 @@ impl CompilerSession { Ok(loaded) } - /// Ingest an already-resolved internal HIR model (e.g. the #118 semantic - /// HIR of an OSTW project) through the shared validate→lower→validate - /// path shared with the protocol/OPY frontends. - fn load_ir_model( - &mut self, - model: &wright_ir::hir::Program, - resolved: &ResolvedInput, - ) -> Result { - self.progress(ProgressEvent::new(ProgressPhase::Validation)); - model - .validate() - .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; - self.progress(ProgressEvent::new(ProgressPhase::Lowering)); - let program = wright_ir::lower::lower(model) - .map_err(|error| ir_diag("lower-error", Stage::Lowering, error, resolved))?; - program - .validate() - .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; - Ok(program) - } - /// The diagnostics accumulated by the last workflow run. pub fn diagnostics(&self) -> &[Diagnostic] { &self.diagnostics @@ -1071,7 +1047,7 @@ fn workshop_diag(error: workshop_rs::WorkshopError, resolved: &ResolvedInput) -> /// an included file names that file; file 0 (the main file) carries the /// resolved display path by construction (#83). pub(crate) fn opy_diag( - error: wright_opy::FrontendError, + error: wright_opy::OpyError, files: &[wright_opy::preprocess::FileRecord], resolved: &ResolvedInput, ) -> Diagnostic { @@ -1106,7 +1082,7 @@ pub(crate) fn opy_diag( /// Span paths resolve through the OSTW project registry, so a failure inside /// an imported file names that file with its project-relative path. pub(crate) fn ostw_diag( - error: wright_ostw::FrontendError, + error: wright_ostw::SourceError, outcome: &wright_ostw::OstwOutcome, resolved: &ResolvedInput, ) -> Diagnostic { diff --git a/crates/wright-driver/tests/convert.rs b/crates/wright-driver/tests/convert.rs index 72bc41a..c5b6d90 100644 --- a/crates/wright-driver/tests/convert.rs +++ b/crates/wright-driver/tests/convert.rs @@ -61,7 +61,7 @@ fn sha256(input: &str) -> String { fn parse(catalog: &workshop_rs::catalog::Catalog, text: &str) -> wir::Program { let manifest = wright_opy::manifest::Manifest::builtin().expect("the OPY manifest is embedded and valid"); - let context = wright_core::signatures::ChainedExpectedDomain::new(manifest, catalog); + let context = wright_core::signatures::ChainedExpectedDomain::new(&manifest, catalog); let program = workshop_rs::parser::parse_with_context( text, catalog, @@ -99,9 +99,9 @@ fn convert(text: &str, target: ConvertTarget) -> (wright_driver::Envelope wright_ir::hir::Program { +/// Load the reconstructed OSTW through the owner-backed adapter in a +/// generated project root (`ds.toml` + `main.ostw`). Returns canonical WIR. +fn compile_reconstructed_ostw(ostw_text: &str, test_name: &str) -> wir::Program { let root = std::env::temp_dir().join(format!("wright-convert-ostw-{test_name}")); std::fs::create_dir_all(&root).expect("create project root"); std::fs::write(root.join("ds.toml"), "entry_point=\"main.ostw\"\n").expect("write ds.toml"); @@ -124,7 +124,7 @@ fn compile_reconstructed_ostw(ostw_text: &str, test_name: &str) -> wright_ir::hi "reconstructed OSTW must resolve cleanly: {:?}", semantic.diagnostics ); - semantic.hir.expect("HIR produced") + semantic.wir.expect("WIR produced") } /// Emit Workshop text for a WIR program through the shared emitter. @@ -522,8 +522,49 @@ fn vector_idioms(program: &mut wir::Program) { } } +/// The Workshop parser preserves numeric `0`/`1` spellings for boolean action +/// arguments while the owner-backed source path lowers them as typed booleans. +/// Normalize that representation difference before applying the #119 rules. +fn normalize_boolean_argument_spellings( + program: &mut wir::Program, + catalog: &workshop_rs::catalog::Catalog, +) { + let mut rewrites = Vec::new(); + for action in program.actions.iter() { + let workshop_rs::wir::Action::Call { name, args, .. } = action else { + continue; + }; + let Some(entry) = catalog.entry(workshop_rs::catalog::Kind::Action, name) else { + continue; + }; + for (index, arg) in args.iter().enumerate() { + if entry.param_types.get(index).and_then(Option::as_deref) != Some("Boolean") { + continue; + } + match program.values.get(*arg).map(|node| &node.value) { + Some(workshop_rs::wir::Value::Bool(value)) => rewrites.push((*arg, *value)), + Some(workshop_rs::wir::Value::Number { value, .. }) if *value == 0.0 => { + rewrites.push((*arg, false)); + } + Some(workshop_rs::wir::Value::Number { value, .. }) if *value == 1.0 => { + rewrites.push((*arg, true)); + } + _ => {} + } + } + } + for (id, value) in rewrites { + program + .values + .get_mut(id) + .expect("boolean argument in range") + .value = workshop_rs::wir::Value::Bool(value); + } +} + /// The declared #119 normalization, applied identically to both sides. -fn normalize(program: &mut wir::Program) { +fn normalize(program: &mut wir::Program, catalog: &workshop_rs::catalog::Catalog) { + normalize_boolean_argument_spellings(program, catalog); fold(program); inline_write_once_player_vars(program); fold(program); @@ -629,14 +670,7 @@ fn ostw_round_trip( assert_eq!(envelope.result.target, ConvertTarget::Ostw); // Reload through the native OSTW frontend in a generated project root. - let hir = compile_reconstructed_ostw(&ostw, fixture); - let reconstructed = match wright_ir::lower::lower(&hir) { - Ok(program) => program, - Err(error) => { - failures.push(format!("{fixture}: re-lowering failed: {error}")); - return serde_json::json!({ "status": "lower-failed" }); - } - }; + let reconstructed = compile_reconstructed_ostw(&ostw, fixture); reconstructed.validate().expect("lowered program validates"); // WIR → Workshop text through the shared emitter, then the round-trip @@ -667,8 +701,8 @@ fn ostw_round_trip( // Semantic equivalence under the declared #119 normalization. let mut actual = reparsed; let mut reference = original; - normalize(&mut actual); - normalize(&mut reference); + normalize(&mut actual, catalog); + normalize(&mut reference, catalog); let equivalent = workshop_rs::roundtrip::equivalent(&actual, &reference); if !equivalent { failures.push(format!( diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 0d1c39b..28387bb 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -118,7 +118,7 @@ pub struct Analysis { pub program: wir::Program, pub index: Option, pub findings: Vec, - pub parse_errors: Vec, + pub parse_errors: Vec, /// The frontend file registry, retained even when parsing/lowering fails /// so diagnostic spans can be mapped to their actual source. pub files: Vec, @@ -190,9 +190,16 @@ impl LanguageService { /// semantic index then come from the same shared code OPY/Workshop use. fn analyze_ostw(&self, document: &Document) -> Analysis { let relative = crate::document::uri_to_path(&document.uri).and_then(|path| { - path.strip_prefix(&self.root) + let relative = path + .strip_prefix(&self.root) .ok() - .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .map(PathBuf::from) + .or_else(|| { + let root = self.root.canonicalize().ok()?; + let path = path.canonicalize().ok()?; + path.strip_prefix(root).ok().map(PathBuf::from) + })?; + Some(relative.to_string_lossy().replace('\\', "/")) }); let (outcome, semantic) = wright_ostw::compile_with_semantics(&document.text, relative.as_deref(), &self.root); @@ -210,10 +217,10 @@ impl LanguageService { .collect() }) .unwrap_or_default(); - let mut parse_errors: Vec = + let mut parse_errors: Vec = outcome.diagnostics.iter().map(ostw_error_to_opy).collect(); parse_errors.extend(semantic.diagnostics.iter().map(ostw_error_to_opy)); - let Some(hir) = semantic.hir else { + let Some(program) = semantic.wir else { return Analysis { program: wir::Program::default(), index: None, @@ -223,12 +230,8 @@ impl LanguageService { }; }; let mut findings = Vec::new(); - let mut program = wir::Program::default(); - if let Ok(lowered) = wright_ir::lower::lower(&hir) { - if lowered.validate().is_ok() { - program = lowered; - findings = analysis::analyze(&program); - } + if program.validate().is_ok() { + findings = analysis::analyze(&program); } let index = SemanticIndex::build(&program).ok(); Analysis { @@ -637,7 +640,7 @@ impl LanguageService { root_sources.insert(canonical, self.source_text(&identity, root_document)); } let config = wright_driver::SessionConfig { - input: wright_driver::InputSpec::Path(root_path), + input: wright_driver::InputSpec::Path(root_path.clone()), // The driver detects the original project kind from the root // document extension (OPY or OSTW), so validation runs through // the correct native frontend. @@ -867,15 +870,19 @@ impl LanguageService { }; let analysis = self.analyze(document); let target = PathBuf::from(source); - analysis.files.iter().any(|file| { - let include_path = PathBuf::from(&file.path); - let resolved = if include_path.is_absolute() { - include_path - } else { - self.root.join(include_path) - }; - resolved == target - }) + analysis + .files + .iter() + .filter(|file| file.id != 0) + .any(|file| { + let include_path = PathBuf::from(&file.path); + let resolved = if include_path.is_absolute() { + include_path + } else { + self.root.join(include_path) + }; + resolved == target + }) } /// Semantic tokens for a document, classified by the native lexer. @@ -1019,8 +1026,8 @@ fn is_ostw_document(uri: &str) -> bool { /// Map an OSTW frontend error into the shared language-service error shape /// (same code/message/span contract; the registry ids are project ids). -fn ostw_error_to_opy(error: &wright_ostw::FrontendError) -> wright_opy::FrontendError { - wright_opy::FrontendError { +fn ostw_error_to_opy(error: &wright_ostw::SourceError) -> wright_opy::OpyError { + wright_opy::OpyError { code: error.code.clone(), message: error.message.clone(), span: error.span.map(opy_span), diff --git a/crates/wright-opy/Cargo.toml b/crates/wright-opy/Cargo.toml index f30b19f..379b036 100644 --- a/crates/wright-opy/Cargo.toml +++ b/crates/wright-opy/Cargo.toml @@ -4,12 +4,14 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "Wright's native .opy frontend: lexer, CST/parser, preprocessing, semantic resolution, and HIR lowering." +description = "Wright's narrow adapter for the owner-side opy-rs implementation." [lints] workspace = true [dependencies] +opy-rs.workspace = true +opy-compiler.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true wright-core.workspace = true diff --git a/crates/wright-opy/src/cst.rs b/crates/wright-opy/src/cst.rs deleted file mode 100644 index 15db6ff..0000000 --- a/crates/wright-opy/src/cst.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! The frontend's concrete syntax tree (CST). -//! -//! Source-preserving syntax structure with spans on every node, produced by -//! [`crate::parser`] and consumed by [`crate::lower`] (and, in later -//! milestones, language services). Nodes are deliberately close to the Opy -//! HIR contract so lowering stays a small, reviewable mapping; unresolved -//! names and member accesses remain explicit until semantic resolution. - -use crate::diag::Span; - -/// A parsed program: declarations and rule/subroutine entries. -#[derive(Debug, Clone)] -pub struct Program { - pub declarations: Vec, - pub rules: Vec, - /// The parsed top-of-file `settings { ... }` block, when present (#86). - pub settings: Option, -} - -/// A parsed `settings { ... }` block (JSONC, #86). -#[derive(Debug, Clone)] -pub struct Settings { - pub span: Span, - pub children: Vec, -} - -/// One member of a settings group. -#[derive(Debug, Clone)] -pub enum SettingsNode { - Group { - name: String, - children: Vec, - span: Span, - }, - Number { - name: String, - value: f64, - span: Span, - }, - Bool { - name: String, - value: bool, - span: Span, - }, - String { - name: String, - value: String, - span: Span, - }, - List { - name: String, - elements: Vec, - span: Span, - }, -} - -/// One element of a settings list. -#[derive(Debug, Clone)] -pub struct SettingsListElement { - pub value: String, - pub span: Span, -} - -/// A program-scope declaration. -#[derive(Debug, Clone)] -pub enum Decl { - GlobalVariable { - name: String, - /// An explicit Workshop index (`globalvar x 100`), when given. - index: Option, - span: Span, - /// The exact span of the declared identifier token. - name_span: Span, - initializer: Option, - }, - PlayerVariable { - name: String, - index: Option, - span: Span, - /// The exact span of the declared identifier token. - name_span: Span, - initializer: Option, - }, - Subroutine { - name: String, - span: Span, - /// The exact span of the declared identifier token. - name_span: Span, - }, - /// A user-defined `enum`; members fold to numeric constants. - Enum { - name: String, - members: Vec<(String, Span)>, - span: Span, - }, - /// A `macro` declaration with parameterized statement body. - Macro { - name: String, - args: Vec, - body: Vec, - span: Span, - }, -} - -/// A rule or a subroutine definition. -#[derive(Debug, Clone)] -pub enum RuleEntry { - Rule(Rule), - SubroutineDef { - name: String, - span: Span, - /// The exact span of the defined identifier token in `def name():`. - name_span: Span, - body: Vec, - }, -} - -/// A rule with its event, conditions, and actions. -#[derive(Debug, Clone)] -pub struct Rule { - pub name: String, - pub span: Span, - /// The exact span of the rule name inside its string literal. - pub name_span: Span, - pub disabled: bool, - pub event: Event, - pub conditions: Vec, - pub actions: Vec, -} - -/// A rule event or an `@Event` directive. -#[derive(Debug, Clone)] -pub struct Event { - pub name: String, - pub args: Vec, - pub span: Span, -} - -/// A statement. -#[derive(Debug, Clone)] -pub enum Stmt { - Expr { - expr: Expr, - span: Span, - }, - Assign { - target: Expr, - value: Expr, - span: Span, - }, - If { - branches: Vec, - r#else: Option>, - span: Span, - }, - For { - variable: Expr, - iterable: Expr, - body: Vec, - span: Span, - }, - While { - condition: Expr, - body: Vec, - span: Span, - }, - Pass { - span: Span, - }, -} - -/// One condition/body pair of an `if`. -#[derive(Debug, Clone)] -pub struct IfBranch { - pub condition: Expr, - pub body: Vec, -} - -/// One call argument: either positional (`expr`) or keyword (`name = expr`, -/// issue #110). Keyword arguments keep the name token's exact span so binding -/// diagnostics are source-located on the name (unknown/duplicate keyword) or -/// the value (enum-domain, arity of the value expression) as appropriate. -#[derive(Debug, Clone)] -pub struct CallArg { - /// The keyword name and its exact span, when this is a `name = expr` - /// argument. - pub keyword: Option<(String, Span)>, - /// The argument's value expression. - pub value: Expr, -} - -/// An expression. -#[derive(Debug, Clone)] -pub enum Expr { - Number { - value: f64, - text: String, - span: Span, - }, - String { - value: String, - span: Span, - }, - Bool { - value: bool, - span: Span, - }, - Null { - span: Span, - }, - Array { - elements: Vec, - span: Span, - }, - /// A plain function call. - Call { - name: String, - args: Vec, - span: Span, - }, - /// A call on a receiver (`x.f(...)`). - ReceiverCall { - receiver: Box, - name: String, - args: Vec, - span: Span, - }, - /// An unresolved identifier (resolved during lowering). - Name { - name: String, - span: Span, - }, - /// A member access `x.y` (resolved during lowering). - Member { - receiver: Box, - member: String, - span: Span, - }, - Index { - array: Box, - index: Box, - span: Span, - }, - Binary { - op: String, - left: Box, - right: Box, - span: Span, - }, - Unary { - op: String, - operand: Box, - span: Span, - }, -} - -impl Expr { - /// The source span of this expression. - pub fn span(&self) -> Span { - match self { - Expr::Number { span, .. } - | Expr::String { span, .. } - | Expr::Bool { span, .. } - | Expr::Null { span } - | Expr::Array { span, .. } - | Expr::Call { span, .. } - | Expr::ReceiverCall { span, .. } - | Expr::Name { span, .. } - | Expr::Member { span, .. } - | Expr::Index { span, .. } - | Expr::Binary { span, .. } - | Expr::Unary { span, .. } => *span, - } - } -} - -impl CallArg { - /// The source span of this argument: the keyword name when keyword, the - /// value expression otherwise. - pub fn span(&self) -> Span { - match &self.keyword { - Some((_, name_span)) => { - let end = self.value.span().end; - Span::new(name_span.file, name_span.start, end) - } - None => self.value.span(), - } - } -} diff --git a/crates/wright-opy/src/diag.rs b/crates/wright-opy/src/diag.rs deleted file mode 100644 index 867e11e..0000000 --- a/crates/wright-opy/src/diag.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Frontend diagnostics: structured, source-located failures. -//! -//! Every frontend failure is a [`FrontendError`] with a stable `code`, a -//! human message, and an optional source span. The driver maps these into the -//! shared `wright-result/v1` diagnostic contract; wording is not part of the -//! machine contract. - -/// A structured frontend error. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrontendError { - /// A stable machine-readable code, e.g. `parse-error`. - pub code: String, - /// Human-readable message (not part of the machine contract). - pub message: String, - /// The offending source region, when known. - pub span: Option, -} - -/// A source span in the frontend's file registry. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Span { - pub file: u32, - pub start: Position, - pub end: Position, -} - -/// A 1-based line/column position. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Position { - pub line: u32, - pub col: u32, -} - -impl Position { - pub const fn new(line: u32, col: u32) -> Position { - Position { line, col } - } -} - -impl Span { - pub fn new(file: u32, start: Position, end: Position) -> Span { - Span { file, start, end } - } -} - -/// A crate-wide result alias. -pub type FrontendResult = Result; - -impl FrontendError { - /// An error without a source span. - pub fn new(code: impl Into, message: impl Into) -> FrontendError { - FrontendError { - code: code.into(), - message: message.into(), - span: None, - } - } - - /// An error at a source position. - pub fn at(code: impl Into, message: impl Into, span: Span) -> FrontendError { - FrontendError { - code: code.into(), - message: message.into(), - span: Some(span), - } - } -} - -impl std::fmt::Display for FrontendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for FrontendError {} diff --git a/crates/wright-opy/src/lexer.rs b/crates/wright-opy/src/lexer.rs deleted file mode 100644 index e693f70..0000000 --- a/crates/wright-opy/src/lexer.rs +++ /dev/null @@ -1,503 +0,0 @@ -//! The native `.opy` lexer. -//! -//! Produces a flat token stream (newlines and indentation included) from one -//! source file. Comments (`#`, `/* */`) are skipped; `#!` directives are -//! captured as a single directive token for the preprocessor. Positions are -//! 1-based line/column, matching the Opy HIR protocol. - -use crate::diag::{FrontendError, FrontendResult, Position, Span}; - -/// The kind of a token. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TokenKind { - /// An identifier or keyword (keywords are resolved by the parser). - Ident, - /// A numeric literal (`text` holds the source spelling). - Number, - /// A string literal (`text` holds the unescaped value). - String, - /// A `#!` directive line (`text` holds everything after `#!`). - Directive, - /// `@Event` / `@Condition` / other `@` directives. - At, - Newline, - /// Indentation change: the column of the current line. - Indent(u32), - /// End of file. - Eof, - // Punctuation and operators. - LParen, - RParen, - LBracket, - RBracket, - Comma, - Colon, - Dot, - Assign, - Plus, - Minus, - Star, - Slash, - DoubleSlash, - Percent, - DoubleStar, - PlusAssign, - MinusAssign, - StarAssign, - SlashAssign, - DoubleSlashAssign, - PercentAssign, - Eq, - Ne, - Lt, - Le, - Gt, - Ge, - /// A bare `!` that is not `!=` (error unless followed by `=`). - LexBang, -} - -/// One token with its source span and payload text. -#[derive(Debug, Clone, PartialEq)] -pub struct Token { - pub kind: TokenKind, - /// The source text of this token (numbers keep their spelling; strings - /// keep their unescaped value; identifiers keep their name). - pub text: String, - pub span: Span, -} - -impl Token { - fn new(kind: TokenKind, text: impl Into, span: Span) -> Token { - Token { - kind, - text: text.into(), - span, - } - } -} - -/// The lexer input: one file's text with its file id. -pub struct LexInput<'a> { - pub file_id: u32, - pub text: &'a str, -} - -/// Lex one source file into a token stream. -pub fn lex(input: LexInput<'_>) -> FrontendResult> { - Lexer::new(input.file_id, input.text).run() -} - -struct Lexer { - file_id: u32, - chars: Vec, - pos: usize, - line: u32, - col: u32, - tokens: Vec, -} - -impl Lexer { - fn new(file_id: u32, text: &str) -> Lexer { - Lexer { - file_id, - chars: text.chars().collect(), - pos: 0, - line: 1, - col: 1, - tokens: Vec::new(), - } - } - - fn run(mut self) -> FrontendResult> { - while self.pos < self.chars.len() { - let ch = self.chars[self.pos]; - match ch { - '\n' => { - self.tokens - .push(Token::new(TokenKind::Newline, "\n", self.here(1))); - self.advance(); - self.line += 1; - self.col = 1; - } - ' ' | '\t' | '\r' => { - self.advance(); - } - '#' => self.lex_hash()?, - '/' if self.peek(1) == Some('*') => self.skip_block_comment()?, - '"' | '\'' => self.lex_string(ch)?, - c if c.is_ascii_digit() => self.lex_number()?, - c if is_ident_start(c) => self.lex_ident(), - '(' => self.single(TokenKind::LParen), - ')' => self.single(TokenKind::RParen), - '[' => self.single(TokenKind::LBracket), - ']' => self.single(TokenKind::RBracket), - ',' => self.single(TokenKind::Comma), - ':' => self.single(TokenKind::Colon), - '.' => self.single(TokenKind::Dot), - '@' => self.single(TokenKind::At), - '=' => self.two(TokenKind::Assign, TokenKind::Eq, '='), - '+' => self.lex_two(TokenKind::Plus, TokenKind::PlusAssign, '='), - '-' => self.lex_two(TokenKind::Minus, TokenKind::MinusAssign, '='), - '*' => { - if self.peek(1) == Some('*') { - self.advance(); - self.single(TokenKind::DoubleStar) - } else { - self.lex_two(TokenKind::Star, TokenKind::StarAssign, '=') - } - } - '/' => { - if self.peek(1) == Some('/') { - self.advance(); - self.single(TokenKind::DoubleSlash) - } else { - self.lex_two(TokenKind::Slash, TokenKind::SlashAssign, '=') - } - } - '%' => self.lex_two(TokenKind::Percent, TokenKind::PercentAssign, '='), - '<' => self.two(TokenKind::Lt, TokenKind::Le, '='), - '>' => self.two(TokenKind::Gt, TokenKind::Ge, '='), - '!' => self.two(TokenKind::LexBang, TokenKind::Ne, '='), - other => { - return Err(FrontendError::at( - "lex-error", - format!("unexpected character '{other}'"), - self.here(1), - )); - } - } - } - let here = self.here(0); - self.tokens.push(Token::new(TokenKind::Eof, "", here)); - Ok(self.tokens) - } - - /// `#` starts a `#!` directive (captured as one token) or a comment. - fn lex_hash(&mut self) -> FrontendResult<()> { - if self.peek(1) == Some('!') { - let start = self.here(2); - self.advance(); - self.advance(); - let mut text = String::new(); - while self.pos < self.chars.len() && self.chars[self.pos] != '\n' { - text.push(self.chars[self.pos]); - self.advance(); - } - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::Directive, - text, - Span::new(self.file_id, start.start, end.start), - )); - } else { - while self.pos < self.chars.len() && self.chars[self.pos] != '\n' { - self.advance(); - } - } - Ok(()) - } - - fn skip_block_comment(&mut self) -> FrontendResult<()> { - let start = self.here(2); - self.advance(); - self.advance(); - while self.pos < self.chars.len() { - if self.chars[self.pos] == '*' && self.peek(1) == Some('/') { - self.advance(); - self.advance(); - return Ok(()); - } - if self.chars[self.pos] == '\n' { - self.advance(); - self.line += 1; - self.col = 1; - } else { - self.advance(); - } - } - Err(FrontendError::at( - "lex-error", - "unterminated block comment", - start, - )) - } - - fn lex_string(&mut self, quote: char) -> FrontendResult<()> { - let start = self.here(1); - self.advance(); - let mut value = String::new(); - while self.pos < self.chars.len() { - let ch = self.chars[self.pos]; - if ch == quote { - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::String, - value, - Span::new(self.file_id, start.start, end.start), - )); - return Ok(()); - } - if ch == '\\' { - self.advance(); - if self.pos >= self.chars.len() { - break; - } - let escaped = self.chars[self.pos]; - value.push(match escaped { - 'n' => '\n', - 't' => '\t', - 'r' => '\r', - '\\' => '\\', - '"' => '"', - '\'' => '\'', - other => other, - }); - self.advance(); - continue; - } - if ch == '\n' { - return Err(FrontendError::at( - "lex-error", - "unterminated string literal", - start, - )); - } - value.push(ch); - self.advance(); - } - Err(FrontendError::at( - "lex-error", - "unterminated string literal", - start, - )) - } - - fn lex_number(&mut self) -> FrontendResult<()> { - let start = self.here(1); - let mut text = String::new(); - while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { - text.push(self.chars[self.pos]); - self.advance(); - } - if self.pos < self.chars.len() - && self.chars[self.pos] == '.' - && self.peek(1).is_some_and(|c| c.is_ascii_digit()) - { - text.push('.'); - self.advance(); - while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { - text.push(self.chars[self.pos]); - self.advance(); - } - } - // Optional exponent (not exercised by the corpus, supported for - // completeness of the number surface). - if self.pos < self.chars.len() - && (self.chars[self.pos] == 'e' || self.chars[self.pos] == 'E') - { - let mut lookahead = self.pos + 1; - if lookahead < self.chars.len() - && (self.chars[lookahead] == '+' || self.chars[lookahead] == '-') - { - lookahead += 1; - } - if lookahead < self.chars.len() && self.chars[lookahead].is_ascii_digit() { - text.push('e'); - self.advance(); - if self.pos < self.chars.len() - && (self.chars[self.pos] == '+' || self.chars[self.pos] == '-') - { - text.push(self.chars[self.pos]); - self.advance(); - } - while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { - text.push(self.chars[self.pos]); - self.advance(); - } - } - } - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::Number, - text, - Span::new(self.file_id, start.start, end.start), - )); - Ok(()) - } - - fn lex_ident(&mut self) { - let start = self.here(1); - let mut text = String::new(); - while self.pos < self.chars.len() && is_ident_continue(self.chars[self.pos]) { - text.push(self.chars[self.pos]); - self.advance(); - } - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::Ident, - text, - Span::new(self.file_id, start.start, end.start), - )); - } - - fn single(&mut self, kind: TokenKind) { - let start = self.here(1); - let text = self.chars[self.pos].to_string(); - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - kind, - text, - Span::new(self.file_id, start.start, end.start), - )); - } - - /// Two-char operator where the second char may be `=`. - fn lex_two(&mut self, plain: TokenKind, assign: TokenKind, second: char) { - let start = self.here(1); - if self.peek(1) == Some(second) { - self.advance(); - let text = format!("{}{}", self.chars[self.pos - 1], second); - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - assign, - text, - Span::new(self.file_id, start.start, end.start), - )); - } else { - let text = self.chars[self.pos].to_string(); - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - plain, - text, - Span::new(self.file_id, start.start, end.start), - )); - } - } - - /// Two-char operator with a fixed second char (e.g. `==`, `<=`). - fn two(&mut self, plain: TokenKind, combined: TokenKind, second: char) { - let start = self.here(1); - let text = self.chars[self.pos].to_string(); - if self.peek(1) == Some(second) { - self.advance(); - let combined_text = format!("{}{}", text, second); - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - combined, - combined_text, - Span::new(self.file_id, start.start, end.start), - )); - } else { - self.advance(); - let end = self.here(0); - self.tokens.push(Token::new( - plain, - text, - Span::new(self.file_id, start.start, end.start), - )); - } - } - - fn here(&self, width: usize) -> Span { - Span::new( - self.file_id, - Position::new(self.line, self.col), - Position::new(self.line, self.col + width as u32), - ) - } - - fn peek(&self, offset: usize) -> Option { - self.chars.get(self.pos + offset).copied() - } - - fn advance(&mut self) { - self.pos += 1; - self.col += 1; - } -} - -fn is_ident_start(c: char) -> bool { - c.is_ascii_alphabetic() || c == '_' -} - -fn is_ident_continue(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' -} - -#[cfg(test)] -mod tests { - use super::*; - - fn lex_ok(text: &str) -> Vec { - lex(LexInput { file_id: 0, text }).unwrap() - } - - #[test] - fn lexes_basic_rule() { - let tokens = lex_ok("rule \"setup\":\n @Event global\n disableInspector()\n"); - let kinds: Vec = tokens.iter().map(|t| t.kind).collect(); - assert!(kinds.contains(&TokenKind::Ident)); - assert!(kinds.contains(&TokenKind::String)); - assert!(kinds.contains(&TokenKind::Colon)); - assert!(kinds.contains(&TokenKind::At)); - assert!(kinds.contains(&TokenKind::LParen)); - assert!(kinds.contains(&TokenKind::Eof)); - } - - #[test] - fn numbers_preserve_text() { - let tokens = lex_ok("1 2.5 0.016 100"); - let numbers: Vec<&str> = tokens - .iter() - .filter(|t| t.kind == TokenKind::Number) - .map(|t| t.text.as_str()) - .collect(); - assert_eq!(numbers, vec!["1", "2.5", "0.016", "100"]); - } - - #[test] - fn directives_and_comments() { - let tokens = lex_ok("#!define X 1\n# comment\nrule \"r\":\n"); - let directive = tokens - .iter() - .find(|t| t.kind == TokenKind::Directive) - .unwrap(); - assert_eq!(directive.text, "define X 1"); - assert!(!tokens.iter().any(|t| t.text == "comment")); - } - - #[test] - fn operators() { - let tokens = lex_ok("a += b == c <= d != e // f"); - let kinds: Vec = tokens.iter().map(|t| t.kind).collect(); - for expected in [ - TokenKind::PlusAssign, - TokenKind::Eq, - TokenKind::Le, - TokenKind::Ne, - TokenKind::DoubleSlash, - ] { - assert!( - kinds.contains(&expected), - "missing {expected:?} in {kinds:?}" - ); - } - } - - #[test] - fn unterminated_string_is_structured() { - let error = lex(LexInput { - file_id: 0, - text: "rule \"x\n", - }) - .unwrap_err(); - assert_eq!(error.code, "lex-error"); - assert!(error.span.is_some()); - } -} diff --git a/crates/wright-opy/src/lib.rs b/crates/wright-opy/src/lib.rs index 84e874f..9734884 100644 --- a/crates/wright-opy/src/lib.rs +++ b/crates/wright-opy/src/lib.rs @@ -1,142 +1,329 @@ -//! Wright's native `.opy` frontend. +//! Narrow Wright adapter for the owner-side `opy-rs` implementation. //! -//! Owns the source-language surface declared by the OPY support matrix -//! (`docs/opy/support-matrix.md`): a lexer, an indentation-aware -//! CST/parser with structured diagnostics and recovery, token-level -//! preprocessing (includes and `#!define` macros), semantic resolution, and -//! lowering into the existing Wright-owned Opy HIR contract -//! (`wright_core::hir::Program`). The frontend never depends on OverPy or -//! Node; the OverPy adapter remains a separate compatibility oracle. -//! -//! Pipeline: [`lexer::lex`] → [`preprocess::preprocess`] → -//! [`parser::parse`] → [`lower::lower`]. - -pub mod cst; -pub mod diag; -pub mod lexer; -pub mod lower; -pub mod manifest; -pub mod parser; -pub mod preprocess; -pub mod reconstruct; -pub mod settings; - -use std::path::Path; - -pub use diag::{FrontendError, FrontendResult}; -pub use lower::lower; -pub use parser::parse; -pub use preprocess::{preprocess, preprocess_with_overlay}; - -/// The frontend's supported protocol identity for generated HIR. -pub const FRONTEND_NAME: &str = "wright/opy-native"; -pub const FRONTEND_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Compile one `.opy` source end-to-end into the Opy HIR contract: -/// preprocess (includes/defines) → parse (CST) → lower (HIR). -/// -/// `main_path` is the file's display path recorded in the HIR file registry; -/// `root` is the include base. `compile` never requires Node or OverPy. +//! This crate owns no OPY parsing, semantic resolution, HIR, manifest, or +//! reconstruction rules. It preserves the historical Wright-facing boundary +//! while delegating those capabilities to `opy-rs` and `opy-compiler`. + +pub use opy_rs::{cst, diag, lexer, parser, preprocess, settings, support, tooling}; + +pub mod manifest { + pub use opy_rs::manifest::{CatalogLink, Function, FunctionKind, Param, ParamDefault}; + + use opy_rs::manifest::ManifestError; + + pub struct EnumDomain { + pub domain: String, + pub members: Vec, + } + + pub struct Manifest { + pub functions: &'static [Function], + inner: &'static opy_rs::manifest::Manifest, + } + + impl Manifest { + pub fn builtin() -> Result { + let inner = opy_rs::manifest::Manifest::builtin()?; + Ok(Self { + functions: &inner.functions, + inner, + }) + } + + pub fn enum_domain(&self, name: &str) -> Option { + if !self.inner.domain_identity(name) { + return None; + } + let catalog = workshop_rs::catalog::Catalog::builtin().ok()?; + let domain = catalog.enum_domain(name)?; + Some(EnumDomain { + domain: name.to_string(), + members: domain + .members + .iter() + .map(|member| member.member.clone()) + .collect(), + }) + } + } + + impl workshop_rs::signatures::ExpectedDomain for Manifest { + fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> { + self.functions + .iter() + .find(|function| function.catalog_id.as_deref() == Some(catalog_id)) + .and_then(|function| function.params.get(arg_index)) + .and_then(|param| param.domain.as_deref()) + } + } +} + +pub use diag::{OpyError, OpyResult}; + +fn validate_builtin_enum_members(program: &opy_rs::hir::Program) -> OpyResult<()> { + let catalog = workshop_rs::catalog::Catalog::builtin() + .map_err(|error| OpyError::new("catalog-load", error.to_string()))?; + for declaration in &program.declarations { + match declaration { + opy_rs::hir::Declaration::GlobalVariable { initializer, .. } + | opy_rs::hir::Declaration::PlayerVariable { initializer, .. } => { + if let Some(initializer) = initializer { + validate_expr(initializer, &catalog)?; + } + } + opy_rs::hir::Declaration::Constant { value, .. } => { + validate_expr(value, &catalog)?; + } + opy_rs::hir::Declaration::Macro { body, .. } => { + validate_stmts(body, &catalog)?; + } + opy_rs::hir::Declaration::Subroutine { .. } => {} + } + } + for rule in &program.rules { + match rule { + opy_rs::hir::RuleEntry::Rule(rule) => { + for argument in &rule.event.args { + validate_expr(argument, &catalog)?; + } + for condition in &rule.conditions { + validate_expr(condition, &catalog)?; + } + validate_stmts(&rule.actions, &catalog)?; + } + opy_rs::hir::RuleEntry::SubroutineDef { body, .. } => { + validate_stmts(body, &catalog)?; + } + } + } + Ok(()) +} + +fn validate_stmts( + statements: &[opy_rs::hir::Stmt], + catalog: &workshop_rs::catalog::Catalog, +) -> OpyResult<()> { + for statement in statements { + match statement { + opy_rs::hir::Stmt::Expr { expr, .. } => validate_expr(expr, catalog)?, + opy_rs::hir::Stmt::Assign { target, value, .. } => { + validate_expr(target, catalog)?; + validate_expr(value, catalog)?; + } + opy_rs::hir::Stmt::If { + branches, r#else, .. + } => { + for branch in branches { + validate_expr(&branch.condition, catalog)?; + validate_stmts(&branch.body, catalog)?; + } + if let Some(body) = r#else { + validate_stmts(body, catalog)?; + } + } + opy_rs::hir::Stmt::For { + variable, + iterable, + body, + .. + } => { + validate_expr(variable, catalog)?; + validate_expr(iterable, catalog)?; + validate_stmts(body, catalog)?; + } + opy_rs::hir::Stmt::While { + condition, body, .. + } + | opy_rs::hir::Stmt::DoWhile { + condition, body, .. + } => { + validate_expr(condition, catalog)?; + validate_stmts(body, catalog)?; + } + opy_rs::hir::Stmt::Switch { + value, + cases, + r#default, + .. + } => { + validate_expr(value, catalog)?; + for case in cases { + validate_expr(&case.value, catalog)?; + validate_stmts(&case.body, catalog)?; + } + if let Some(body) = r#default { + validate_stmts(body, catalog)?; + } + } + opy_rs::hir::Stmt::Break { .. } + | opy_rs::hir::Stmt::CallSubroutine { .. } + | opy_rs::hir::Stmt::Pass { .. } => {} + } + } + Ok(()) +} + +fn validate_expr( + expr: &opy_rs::hir::Expr, + catalog: &workshop_rs::catalog::Catalog, +) -> OpyResult<()> { + use opy_rs::hir::Expr; + + match expr { + Expr::Enum { + value_type, + value, + span, + } => { + if let Some(domain) = catalog.enum_domain(value_type) + && !domain.members.iter().any(|member| member.member == *value) + { + let message = format!("enum '{value_type}' has no member '{value}'"); + return match span { + Some(span) => Err(OpyError::at( + "unknown-enum-member", + message, + opy_rs::diag::Span::new( + span.file, + opy_rs::diag::Position::new(span.start.line, span.start.col), + opy_rs::diag::Position::new(span.end.line, span.end.col), + ), + )), + None => Err(OpyError::new("unknown-enum-member", message)), + }; + } + } + Expr::Array { elements, .. } => { + for element in elements { + validate_expr(element, catalog)?; + } + } + Expr::Dict { entries, .. } => { + for entry in entries { + validate_expr(&entry.key, catalog)?; + validate_expr(&entry.value, catalog)?; + } + } + Expr::Comprehension { + element, + iterable, + condition, + .. + } => { + validate_expr(element, catalog)?; + validate_expr(iterable, catalog)?; + if let Some(condition) = condition { + validate_expr(condition, catalog)?; + } + } + Expr::Lambda { body, .. } => validate_expr(body, catalog)?, + Expr::Vector { x, y, z, .. } => { + validate_expr(x, catalog)?; + validate_expr(y, catalog)?; + validate_expr(z, catalog)?; + } + Expr::PlayerVar { player, .. } => validate_expr(player, catalog)?, + Expr::Member { receiver, .. } => validate_expr(receiver, catalog)?, + Expr::Call { args, .. } | Expr::MacroCall { args, .. } => { + for argument in args { + validate_expr(argument, catalog)?; + } + } + Expr::ReceiverCall { receiver, args, .. } => { + validate_expr(receiver, catalog)?; + for argument in args { + validate_expr(argument, catalog)?; + } + } + Expr::Binary { left, right, .. } => { + validate_expr(left, catalog)?; + validate_expr(right, catalog)?; + } + Expr::Unary { operand, .. } => { + validate_expr(operand, catalog)?; + } + Expr::Index { array, index, .. } => { + validate_expr(array, catalog)?; + validate_expr(index, catalog)?; + } + Expr::Format { args, .. } => { + for argument in args { + validate_expr(argument, catalog)?; + } + } + Expr::Number { .. } + | Expr::String { .. } + | Expr::Bool { .. } + | Expr::Null { .. } + | Expr::StringModifier { .. } + | Expr::Local { .. } + | Expr::GlobalVar { .. } + | Expr::EventPlayer { .. } + | Expr::Constant { .. } + | Expr::MacroParam { .. } => {} + } + Ok(()) +} + +pub struct CompileOutcome { + pub hir: Option, + pub error: Option, + pub files: Vec, +} + pub fn compile( source: &str, main_path: &str, - root: &Path, -) -> FrontendResult { + root: &std::path::Path, +) -> OpyResult { compile_with_overlay(source, main_path, root, &std::collections::BTreeMap::new()) } -/// Compile with open-document overlays: includes resolve to overlay text -/// (keyed by the include string or the resolved canonical path) before the -/// filesystem, so unsaved editor buffers participate in include resolution. pub fn compile_with_overlay( source: &str, main_path: &str, - root: &Path, + root: &std::path::Path, overlay: &std::collections::BTreeMap, -) -> FrontendResult { +) -> OpyResult { let outcome = compile_with_overlay_outcome(source, main_path, root, overlay); - match outcome.hir { - Some(hir) => Ok(hir), - None => Err(outcome - .error - .expect("a failed compile outcome always carries an error")), - } + outcome + .hir + .ok_or_else(|| outcome.error.expect("failed compile outcome has an error")) } -/// The outcome of a compile with overlays. -/// -/// Unlike [`compile_with_overlay`], this retains the frontend file registry -/// even when parsing or lowering fails, so language tooling can map span file -/// ids to their actual source identities without building a diagnostics-only -/// project model. -pub struct CompileOutcome { - pub hir: Option, - pub error: Option, - pub files: Vec, -} - -/// Compile with open-document overlays while retaining the frontend file -/// registry on parse/lower failure. pub fn compile_with_overlay_outcome( source: &str, main_path: &str, - root: &Path, + root: &std::path::Path, overlay: &std::collections::BTreeMap, ) -> CompileOutcome { - let preprocess::PreprocessOutcome { result, files } = - preprocess::preprocess_with_overlay_outcome(source, main_path, root, overlay); - let preprocessed = match result { - Ok((preprocessed, _)) => preprocessed, - Err(error) => { - return CompileOutcome { - hir: None, - error: Some(error), - files, - }; - } + let owner = opy_rs::compile_with_overlay_outcome(source, main_path, root, overlay); + let files = owner.files; + let Some(hir) = owner.hir else { + return CompileOutcome { + hir: None, + error: owner.error, + files, + }; }; - let parsed = parse(&preprocessed.tokens); - if let Some(error) = parsed.errors.first() { + if let Err(error) = validate_builtin_enum_members(&hir) { return CompileOutcome { hir: None, - error: Some(error.clone()), + error: Some(error), files, }; } - let mut program = parsed - .program - .expect("program present when errors are empty"); - // Parse the extracted settings block into the CST; errors flow through - // the same error path (registry retained for span mapping, #86). - if let Some(block) = &preprocessed.settings { - match settings::parse_block(block) { - Ok(parsed_settings) => program.settings = Some(parsed_settings), - Err(error) => { - return CompileOutcome { - hir: None, - error: Some(error), - files, - }; - } + let value = match serde_json::to_value(hir) { + Ok(value) => value, + Err(error) => { + return CompileOutcome { + hir: None, + error: Some(OpyError::new("hir-serialization", error.to_string())), + files, + }; } - } - let defines = preprocessed - .defines - .iter() - .map(|define| wright_core::hir::types::Define { - name: define.name.clone(), - is_function: define.is_function, - span: define.span.map(Into::into), - }) - .collect(); - let hir_files = files - .iter() - .map(|file| wright_core::hir::types::SourceFile { - id: file.id, - path: file.path.clone(), - }) - .collect(); - match lower(&program, hir_files, defines) { + }; + match wright_core::hir::parse_value(value) { Ok(hir) => CompileOutcome { hir: Some(hir), error: None, @@ -144,8 +331,23 @@ pub fn compile_with_overlay_outcome( }, Err(error) => CompileOutcome { hir: None, - error: Some(error), + error: Some(match error.span() { + Some(span) => OpyError::at( + error.code(), + error.message(), + opy_rs::diag::Span::new( + span.file, + opy_rs::diag::Position::new(span.start.line, span.start.col), + opy_rs::diag::Position::new(span.end.line, span.end.col), + ), + ), + None => OpyError::new(error.code(), error.message()), + }), files, }, } } + +pub mod reconstruct { + pub use opy_compiler::reconstruct::{ReconstructError, ReconstructIssue, reconstruct}; +} diff --git a/crates/wright-opy/src/lower.rs b/crates/wright-opy/src/lower.rs deleted file mode 100644 index e428a75..0000000 --- a/crates/wright-opy/src/lower.rs +++ /dev/null @@ -1,2280 +0,0 @@ -//! Semantic resolution and HIR lowering (#45). -//! -//! Resolves the parsed CST into the existing Wright-owned Opy HIR contract -//! (`wright_core::hir::Program`): declarations and references resolve to -//! typed HIR nodes, custom enums fold to constants, `vect` becomes a vector, -//! `.format()` becomes a format node, `wait` default arguments are filled, -//! and subroutine calls become `CallSubroutine` statements. Semantic errors -//! (unknown identifiers, unknown enum members, invalid `vect` arity) are -//! structured and source-located. -//! -//! Builtin action/value/member identity, action/value position, signatures -//! and arity, receiver categories, parameter enum domains, and non-contextual -//! source aliases resolve through the OPY semantic compatibility manifest -//! ([`crate::manifest`], issue #109) before Workshop emission: unknown or -//! misplaced builtins fail here with structured, source-located diagnostics -//! instead of surfacing as emitter catalog misses. - -use std::collections::{HashMap, HashSet}; - -use wright_core::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, -}; - -use crate::cst::{self, CallArg, Decl, Expr, RuleEntry as CstRuleEntry, Stmt}; -use crate::diag::{FrontendError, FrontendResult, Span}; -use crate::manifest::{ - Function, FunctionContext, FunctionKind, Manifest, Param, ParamDefault, ReceiverCategory, -}; - -/// The protocol envelope this frontend produces. -const PROTOCOL_NAME: &str = "wright/opy-hir"; -const PROTOCOL_VERSION: &str = "1.1.0"; - -/// The call-position context of an expression being lowered; builtin -/// resolution checks action/value identity against this context. -#[derive(Clone, Copy, PartialEq, Eq)] -enum CallPosition { - /// A statement position (a bare expression statement). - Statement, - /// A value position (conditions, assignments, call arguments, …). - Value, - /// A `for ... in` iterable (only `range` is a valid builtin here). - ForIterable, -} - -/// The lowerer's symbol context, built from the CST declarations. -struct Lowerer { - globals: HashSet, - players: HashSet, - subroutines: HashSet, - macros: HashSet, - enums: HashMap>, - /// The authoritative builtin semantic table (issue #109). - manifest: &'static Manifest, - errors: Vec, -} - -/// Lower a parsed program into the Opy HIR contract. -pub fn lower( - program: &cst::Program, - files: Vec, - defines: Vec, -) -> FrontendResult { - let manifest = match Manifest::builtin() { - Ok(manifest) => manifest, - Err(error) => { - return Err(FrontendError::new( - "manifest-error", - format!("cannot load the OPY semantic compatibility manifest: {error}"), - )); - } - }; - let mut lowerer = Lowerer { - globals: HashSet::new(), - players: HashSet::new(), - subroutines: HashSet::new(), - macros: HashSet::new(), - enums: HashMap::new(), - manifest, - errors: Vec::new(), - }; - lowerer.collect_symbols(program); - - let mut declarations = Vec::new(); - for decl in &program.declarations { - match decl { - Decl::GlobalVariable { - name, - index, - span, - name_span, - initializer, - } => { - declarations.push(Declaration::GlobalVariable { - name: name.clone(), - index: *index, - span: Some(span.into()), - name_span: Some(name_span.into()), - initializer: lowerer.initializer(initializer.as_ref()), - }); - } - Decl::PlayerVariable { - name, - index, - span, - name_span, - initializer, - } => { - declarations.push(Declaration::PlayerVariable { - name: name.clone(), - index: *index, - span: Some(span.into()), - name_span: Some(name_span.into()), - initializer: lowerer.initializer(initializer.as_ref()), - }); - } - Decl::Subroutine { - name, - span, - name_span, - } => { - declarations.push(Declaration::Subroutine { - name: name.clone(), - index: None, - span: Some(span.into()), - name_span: Some(name_span.into()), - }); - } - Decl::Enum { .. } => { - // Custom enums fold to numeric constants at use sites and - // produce no HIR declaration (reference behavior). - } - Decl::Macro { - name, - args, - body, - span, - } => { - let lowered_body = lowerer.lower_macro_body(body, args); - declarations.push(Declaration::Macro { - name: name.clone(), - args: args.clone(), - span: Some(span.into()), - body: lowered_body, - }); - } - } - } - - let mut rules = Vec::new(); - for entry in &program.rules { - match entry { - CstRuleEntry::Rule(rule) => rules.push(RuleEntry::Rule(lowerer.lower_rule(rule))), - CstRuleEntry::SubroutineDef { - name, - span, - name_span, - body, - } => { - rules.push(RuleEntry::SubroutineDef { - kind: "subroutineDef".to_string(), - name: name.clone(), - span: Some(span.into()), - name_span: Some(name_span.into()), - body: lowerer.lower_block(body, &[]), - }); - } - } - } - - if !lowerer.errors.is_empty() { - return Err(lowerer.errors.swap_remove(0)); - } - - Ok(HirProgram { - protocol: Protocol { - name: PROTOCOL_NAME.to_string(), - version: PROTOCOL_VERSION.to_string(), - }, - generator: Generator { - name: crate::FRONTEND_NAME.to_string(), - version: crate::FRONTEND_VERSION.to_string(), - frontend: "wright-native".to_string(), - }, - files, - defines, - declarations, - rules, - settings: program.settings.as_ref().map(lower_settings), - }) -} - -/// Map a parsed CST settings block onto the protocol settings tree (#86). -fn lower_settings(settings: &cst::Settings) -> HirSettings { - HirSettings { - span: Some(settings.span.into()), - children: settings.children.iter().map(lower_settings_node).collect(), - } -} - -fn lower_settings_node(node: &cst::SettingsNode) -> HirSettingsNode { - match node { - cst::SettingsNode::Group { - name, - children, - span, - } => HirSettingsNode::Group { - name: name.clone(), - children: children.iter().map(lower_settings_node).collect(), - span: Some((*span).into()), - }, - cst::SettingsNode::Number { name, value, span } => HirSettingsNode::Number { - name: name.clone(), - value: *value, - span: Some((*span).into()), - }, - cst::SettingsNode::Bool { name, value, span } => HirSettingsNode::Bool { - name: name.clone(), - value: *value, - span: Some((*span).into()), - }, - cst::SettingsNode::String { name, value, span } => HirSettingsNode::String { - name: name.clone(), - value: value.clone(), - span: Some((*span).into()), - }, - cst::SettingsNode::List { - name, - elements, - span, - } => HirSettingsNode::List { - name: name.clone(), - elements: elements - .iter() - .map(|element| wright_core::hir::types::SettingsListElement { - value: element.value.clone(), - span: Some(element.span.into()), - }) - .collect(), - span: Some((*span).into()), - }, - } -} - -impl Lowerer { - fn collect_symbols(&mut self, program: &cst::Program) { - for decl in &program.declarations { - match decl { - Decl::GlobalVariable { name, .. } => { - self.globals.insert(name.clone()); - } - Decl::PlayerVariable { name, .. } => { - self.players.insert(name.clone()); - } - Decl::Subroutine { name, .. } => { - self.subroutines.insert(name.clone()); - } - Decl::Enum { name, members, .. } => { - self.enums.insert( - name.clone(), - members.iter().map(|(member, _)| member.clone()).collect(), - ); - } - Decl::Macro { name, .. } => { - self.macros.insert(name.clone()); - } - } - } - } - - /// A declaration initializer: integer-`0` literal initializers are - /// dropped (matching the reference adapter, which drops `h = 0` but - /// carries `j = 5` and `k = 0.0`); other initializers are kept. - fn initializer(&mut self, initializer: Option<&Expr>) -> Option> { - let initializer = initializer?; - let lowered = self.lower_expr(initializer, &[], CallPosition::Value); - match &lowered { - HirExpr::Number { text, .. } if text == "0" => None, - other => Some(Box::new(other.clone())), - } - } - - fn lower_rule(&mut self, rule: &cst::Rule) -> Rule { - let conditions = rule - .conditions - .iter() - .map(|condition| self.lower_expr(condition, &[], CallPosition::Value)) - .collect(); - let actions = self.lower_block(&rule.actions, &[]); - Rule { - name: rule.name.clone(), - span: Some(rule.span.into()), - name_span: Some(rule.name_span.into()), - disabled: rule.disabled, - event: Event { - name: rule.event.name.clone(), - args: rule - .event - .args - .iter() - .map(|arg| self.lower_expr(arg, &[], CallPosition::Value)) - .collect(), - span: Some(rule.event.span.into()), - }, - conditions, - actions, - } - } - - /// Lower a statement block; `macro_params` names resolve to `MacroParam`. - fn lower_block(&mut self, stmts: &[Stmt], macro_params: &[String]) -> Vec { - stmts - .iter() - .map(|stmt| self.lower_stmt(stmt, macro_params)) - .collect() - } - - fn lower_stmt(&mut self, stmt: &Stmt, macro_params: &[String]) -> HirStmt { - match stmt { - Stmt::Expr { expr, span } => { - // A bare call of a declared subroutine becomes - // `CallSubroutine` (reference behavior). - if let Expr::Call { name, args, .. } = expr { - if self.subroutines.contains(name) && args.is_empty() { - return HirStmt::CallSubroutine { - name: name.clone(), - span: Some(span.into()), - }; - } - } - // Statement-position builtin resolution (action/value - // identity, unknown names) happens inside `lower_expr`. - HirStmt::Expr { - expr: Box::new(self.lower_expr(expr, macro_params, CallPosition::Statement)), - span: Some(span.into()), - } - } - Stmt::Assign { - target, - value, - span, - } => HirStmt::Assign { - target: Box::new(self.lower_expr(target, macro_params, CallPosition::Value)), - value: Box::new(self.lower_expr(value, macro_params, CallPosition::Value)), - span: Some(span.into()), - }, - Stmt::If { - branches, - r#else, - span, - } => HirStmt::If { - branches: branches - .iter() - .map(|branch| IfBranch { - condition: Box::new(self.lower_expr( - &branch.condition, - macro_params, - CallPosition::Value, - )), - body: self.lower_block(&branch.body, macro_params), - }) - .collect(), - r#else: r#else - .as_ref() - .map(|body| self.lower_block(body, macro_params)), - span: Some(span.into()), - }, - Stmt::For { - variable, - iterable, - body, - span, - } => { - // The reference accepts only `range(...)` as a `for ... in` - // iterable; other iterables are an explicit frontend error - // (recovered by lowering in value position). - let iterable_position = if matches!(iterable, Expr::Call { name, .. } if name == "range") - { - CallPosition::ForIterable - } else { - self.error_at( - "invalid-iterable", - "for-loop iterable must be a range(...) call".to_string(), - iterable.span(), - ); - CallPosition::Value - }; - HirStmt::For { - variable: Box::new(self.lower_expr( - variable, - macro_params, - CallPosition::Value, - )), - iterable: Box::new(self.lower_expr(iterable, macro_params, iterable_position)), - body: self.lower_block(body, macro_params), - span: Some(span.into()), - } - } - Stmt::While { - condition, - body, - span, - } => HirStmt::While { - condition: Box::new(self.lower_expr(condition, macro_params, CallPosition::Value)), - body: self.lower_block(body, macro_params), - span: Some(span.into()), - }, - Stmt::Pass { span } => HirStmt::Pass { - span: Some(span.into()), - }, - } - } - - fn lower_macro_body(&mut self, body: &[Stmt], params: &[String]) -> Vec { - self.lower_block(body, params) - } - - fn lower_expr( - &mut self, - expr: &Expr, - macro_params: &[String], - position: CallPosition, - ) -> HirExpr { - match expr { - Expr::Number { value, text, span } => HirExpr::Number { - value: *value, - text: text.clone(), - span: Some(span.into()), - }, - Expr::String { value, span } => HirExpr::String { - value: value.clone(), - span: Some(span.into()), - }, - Expr::Bool { value, span } => HirExpr::Bool { - value: *value, - span: Some(span.into()), - }, - Expr::Null { span } => HirExpr::Null { - span: Some(span.into()), - }, - Expr::Array { elements, span } => HirExpr::Array { - elements: elements - .iter() - .map(|element| self.lower_expr(element, macro_params, CallPosition::Value)) - .collect(), - span: Some(span.into()), - }, - Expr::Name { name, span } => self.lower_name(name, *span, macro_params), - Expr::Member { - receiver, - member, - span, - } => self.lower_member(receiver, member, *span, macro_params), - Expr::Index { array, index, span } => HirExpr::Index { - array: Box::new(self.lower_expr(array, macro_params, CallPosition::Value)), - index: Box::new(self.lower_expr(index, macro_params, CallPosition::Value)), - span: Some(span.into()), - }, - Expr::Call { name, args, span } => { - self.lower_call(name, args, *span, macro_params, position) - } - Expr::ReceiverCall { - receiver, - name, - args, - span, - } => self.lower_receiver_call(receiver, name, args, *span, macro_params, position), - Expr::Binary { - op, - left, - right, - span, - } => HirExpr::Binary { - op: op.clone(), - left: Box::new(self.lower_expr(left, macro_params, CallPosition::Value)), - right: Box::new(self.lower_expr(right, macro_params, CallPosition::Value)), - span: Some(span.into()), - }, - Expr::Unary { op, operand, span } => HirExpr::Unary { - op: op.clone(), - operand: Box::new(self.lower_expr(operand, macro_params, CallPosition::Value)), - span: Some(span.into()), - }, - } - } - - fn lower_name(&mut self, name: &str, span: Span, macro_params: &[String]) -> HirExpr { - if macro_params.iter().any(|param| param == name) { - return HirExpr::MacroParam { - name: name.to_string(), - span: Some(span.into()), - }; - } - match name { - "eventPlayer" => HirExpr::EventPlayer { - span: Some(span.into()), - }, - _ if self.globals.contains(name) => HirExpr::GlobalVar { - name: name.to_string(), - span: Some(span.into()), - }, - _ if self.players.contains(name) => HirExpr::PlayerVar { - player: Box::new(HirExpr::EventPlayer { span: None }), - name: name.to_string(), - span: Some(span.into()), - }, - _ if self.enums.contains_key(name) => { - self.error_at( - "enum-type-without-member", - format!("enum type '{name}' must be used with a member (e.g. {name}.MEMBER)"), - span, - ); - HirExpr::Null { span: None } - } - // OverPy default variable names (A–Z, AA–…, DX): implicit global - // variables at fixed Workshop slots. The pinned reference accepts - // these without a `globalvar` declaration anywhere a variable may - // appear, including as a `for ... in range(...)` loop binder - // (#114). Custom enums take precedence over default-var names, - // matching the reference's identifier resolution order. - _ if default_var_index(name).is_some() => HirExpr::GlobalVar { - name: name.to_string(), - span: Some(span.into()), - }, - _ => { - self.error_at( - "unknown-identifier", - format!("unknown identifier '{name}'"), - span, - ); - HirExpr::Null { span: None } - } - } - } - - fn lower_member( - &mut self, - receiver: &Expr, - member: &str, - span: Span, - _macro_params: &[String], - ) -> HirExpr { - if let Expr::Name { name, .. } = receiver { - // Custom enum member: folds to its numeric constant. - if let Some(members) = self.enums.get(name) { - return match members.iter().position(|candidate| candidate == member) { - Some(index) => HirExpr::Number { - value: index as f64, - text: index.to_string(), - span: Some(span.into()), - }, - None => { - self.error_at( - "unknown-enum-member", - format!("enum '{name}' has no member '{member}'"), - span, - ); - HirExpr::Null { span: None } - } - }; - } - // Builtin Workshop enum: members resolve through the manifest's - // declared enum domains (reference-validated, #109). - if let Some(domain) = self.manifest.enum_domain(name) { - if domain.members.iter().any(|candidate| candidate == member) { - return HirExpr::Enum { - value_type: name.clone(), - value: member.to_string(), - span: Some(span.into()), - }; - } - self.error_at( - "unknown-enum-member", - format!("enum '{name}' has no member '{member}'"), - span, - ); - return HirExpr::Null { span: None }; - } - // Event-player member: a player-variable reference. - if name == "eventPlayer" { - return HirExpr::PlayerVar { - player: Box::new(HirExpr::EventPlayer { span: None }), - name: member.to_string(), - span: Some(span.into()), - }; - } - // A module member used without a call (`random.uniform` alone). - if name == "random" { - self.error_at( - "unsupported-member", - format!("module member '{name}.{member}' must be called"), - span, - ); - return HirExpr::Null { span: None }; - } - } - self.error_at( - "unsupported-member", - "unsupported member access on this expression".to_string(), - span, - ); - HirExpr::Null { span: None } - } - - fn lower_call( - &mut self, - name: &str, - args: &[cst::CallArg], - span: Span, - macro_params: &[String], - position: CallPosition, - ) -> HirExpr { - // Builtin identity and position checks run before the special forms - // so that a misplaced `wait`/`vect` still diagnoses its position. - if !self.macros.contains(name) && !self.subroutines.contains(name) { - match self.manifest.resolve_function(name) { - Some(entry) => self.check_call_position(name, entry, position, span), - None => { - let (code, message) = match position { - CallPosition::Statement => { - ("unknown-action", format!("unknown action '{name}'")) - } - CallPosition::Value => ("unknown-value", format!("unknown value '{name}'")), - CallPosition::ForIterable => ( - "invalid-iterable", - format!("for-loop iterable '{name}' must be a range(...) call"), - ), - }; - self.error_at(code, message, span); - } - } - } - match name { - "vect" => { - // `vect` goes through the generic argument binder so its - // keyword forms (`vect(x=1, y=2, z=3)`) bind like any other - // manifest signature; the result must fill exactly the three - // declared parameters (x, y, z). - let (bound, _) = match self.manifest.resolve_function(name) { - Some(entry) => self.bind_args(entry, args, macro_params), - None => (self.lower_arg_values(args, macro_params), None), - }; - if bound.len() < 3 { - self.error_at( - "vect-arity", - format!( - "vect() expects 3 arguments (x, y, z) but got {}", - args.len() - ), - span, - ); - return HirExpr::Null { span: None }; - } - HirExpr::Vector { - x: Box::new(bound[0].clone()), - y: Box::new(bound[1].clone()), - z: Box::new(bound[2].clone()), - span: Some(span.into()), - } - } - _ => { - if self.macros.contains(name) { - // A declared `macro` invocation is recorded as a macroCall - // (positional-only; keyword arguments are an explicit - // diagnostic). - for arg in args { - if let Some((keyword, span)) = &arg.keyword { - self.error_at( - "keyword-unsupported", - format!( - "macro '{name}' does not accept keyword \ - arguments ('{keyword}')" - ), - *span, - ); - } - } - return HirExpr::MacroCall { - name: name.to_string(), - args: self.lower_arg_values(args, macro_params), - span: Some(span.into()), - }; - } - match self.manifest.resolve_function(name) { - Some(entry) => { - // Declared subroutines with arguments stay generic - // calls; builtins get keyword binding, arity, and - // domain/default handling. - if self.subroutines.contains(name) { - return HirExpr::Call { - name: name.to_string(), - args: self.lower_arg_values(args, macro_params), - span: Some(span.into()), - }; - } - let (bound, selector) = self.bind_args(entry, args, macro_params); - self.check_enum_domains(entry, &bound); - let (call_name, bound) = - self.resolve_contextual_domain(entry, bound, selector.as_deref(), span); - HirExpr::Call { - name: call_name, - args: bound, - span: Some(span.into()), - } - } - None => HirExpr::Call { - name: name.to_string(), - args: self.lower_arg_values(args, macro_params), - span: Some(span.into()), - }, - } - } - } - } - - /// Lower call arguments to HIR values in source order (used for macro - /// calls and unresolved names; keyword values lose their name). - fn lower_arg_values(&mut self, args: &[cst::CallArg], macro_params: &[String]) -> Vec { - args.iter() - .map(|arg| self.lower_expr(&arg.value, macro_params, CallPosition::Value)) - .collect() - } - - /// Bind positional and keyword arguments against a manifest signature - /// (issue #110), producing lowered values in parameter order with - /// declared defaults filled. Diagnostics are structured and - /// source-located: `unknown-keyword`, `duplicate-argument`, - /// `keyword-required`, `positional-after-keyword`, `missing-argument`, - /// `keyword-unsupported`, `invalid-arity` (overflow), and - /// `invalid-argument` (variable-required parameters). - /// - /// The returned `selector` is the keyword spelling used to bind the - /// entry's contextual-domain selector parameter (the `chase` form's - /// `rate`/`duration`), when the entry declares one. - fn bind_args( - &mut self, - entry: &Function, - args: &[cst::CallArg], - macro_params: &[String], - ) -> (Vec, Option) { - let mut slots: Vec> = vec![None; entry.params.len()]; - let mut selector = None; - let mut has_keyword = false; - let mut binding_error = false; - let contextual = entry.contextual_domain.as_ref(); - - // Keyword spellings resolve through the declared parameter names - // (alternate spellings included) — generic binding, no per-spelling - // branches. - let mut by_spelling: HashMap<&str, usize> = HashMap::new(); - for (index, param) in entry.params.iter().enumerate() { - by_spelling.insert(param.name.as_str(), index); - for alternate in ¶m.alternate_names { - by_spelling.insert(alternate.as_str(), index); - } - } - - for (arg_index, arg) in args.iter().enumerate() { - match &arg.keyword { - Some((keyword, name_span)) => { - if !entry.keyword_args { - binding_error = true; - self.error_at( - "keyword-unsupported", - format!( - "function '{}' does not accept keyword arguments ('{keyword}')", - entry.id - ), - *name_span, - ); - continue; - } - has_keyword = true; - match by_spelling.get(keyword.as_str()) { - None => { - binding_error = true; - self.error_at( - "unknown-keyword", - format!( - "unknown keyword argument '{keyword}' for function '{}'", - entry.id - ), - *name_span, - ); - } - Some(&index) => { - let param = &entry.params[index]; - if param.positional_only { - binding_error = true; - self.error_at( - "unknown-keyword", - format!( - "parameter '{}' of '{}' cannot be bound by keyword", - param.name, entry.id - ), - *name_span, - ); - } else if slots[index].is_some() { - binding_error = true; - self.error_at( - "duplicate-argument", - format!( - "argument '{}' of function '{}' is defined twice", - keyword, entry.id - ), - *name_span, - ); - } else { - slots[index] = Some(self.lower_call_arg_value( - entry, - index, - arg, - macro_params, - )); - if contextual.is_some_and(|c| c.by == param.name) { - selector = Some(keyword.clone()); - } - } - } - } - } - None => { - // The reference's generic binder rejects positional - // arguments after keyword arguments; its special forms - // (the contextual-domain entries, e.g. `chase`) bind the - // trailing positionals by slot and skip the ordering - // rule. - if has_keyword && entry.contextual_domain.is_none() { - binding_error = true; - self.error_at( - "positional-after-keyword", - format!( - "cannot use positional arguments after keyword \ - arguments in call to '{}'", - entry.id - ), - arg.value.span(), - ); - } - // Positional arguments fill the slot at their argument - // index (keywords occupy their named slots), matching - // the reference binder. - let index = arg_index; - if index < entry.params.len() { - let param = &entry.params[index]; - if param.keyword_only { - binding_error = true; - self.error_at( - "keyword-required", - format!( - "argument {} of '{}' must be passed as a keyword \ - (name = value; accepted names: {})", - index + 1, - entry.id, - keyword_spellings(param).join(", ") - ), - arg.value.span(), - ); - } - if slots[index].is_none() { - slots[index] = - Some(self.lower_call_arg_value(entry, index, arg, macro_params)); - } - } else { - self.lower_expr(&arg.value, macro_params, CallPosition::Value); - } - } - } - } - - // Positional overflow: the declared arity bounds report the - // reference's "takes N arguments, received M" rejection. A binding - // error already reported (duplicate keyword, unknown keyword, …) - // suppresses the secondary arity noise, matching the reference's - // first-error behavior. - if !binding_error && args.len() > entry.params.len() { - self.check_arity(entry, args.len(), arg_span(args)); - } - - // Unbound parameters: declared defaults fill; required parameters - // without a default are the reference's missing-argument rejection; - // `optional` parameters stay omittable without an emitted expansion. - let mut bound: Vec = Vec::with_capacity(entry.params.len()); - for (index, param) in entry.params.iter().enumerate() { - match &slots[index] { - Some(value) => bound.push(value.clone()), - None => match ¶m.default { - Some(ParamDefault::EnumMember(member)) => { - let domain = param.domain.clone().unwrap_or_default(); - bound.push(HirExpr::Enum { - value_type: domain, - value: member.clone(), - span: None, - }); - } - Some(ParamDefault::Number(number)) => { - bound.push(HirExpr::Number { - value: *number, - text: format!("{number}"), - span: None, - }); - } - None if param.optional => { - // Omitted entirely (the reference's short forms keep - // the argument list short, e.g. `range(3)`). - } - None => { - self.error_at( - "missing-argument", - format!( - "missing argument '{}' for function '{}'", - param.name, entry.id - ), - arg_span(args), - ); - bound.push(HirExpr::Null { span: None }); - } - }, - } - } - - // Variable-required parameters (the chase family's first argument) - // must resolve to a variable reference. - for (index, param) in entry.params.iter().enumerate() { - if !param.variable { - continue; - } - if let Some(Some(value)) = slots.get(index) { - if !matches!(value, HirExpr::GlobalVar { .. } | HirExpr::PlayerVar { .. }) { - self.error_at( - "invalid-argument", - format!( - "argument {} of '{}' must be a variable (globalvar or \ - playervar)", - index + 1, - entry.id - ), - arg_span(args), - ); - } - } - } - - (bound, selector) - } - - /// Lower one call argument's value; the contextual-domain parameter (the - /// `chase` form's `ChaseReeval` member) is recorded as a pending enum - /// without validating the domain — it resolves only against the concrete - /// domain selected by the call's keyword selector (issue #110). Outside - /// that signature context `ChaseReeval` never resolves because it is not - /// a declared enum domain. - fn lower_call_arg_value( - &mut self, - entry: &Function, - param_index: usize, - arg: &cst::CallArg, - macro_params: &[String], - ) -> HirExpr { - if let Some(contextual) = &entry.contextual_domain { - let is_contextual = entry.params[param_index] - .domain - .as_deref() - .is_some_and(|domain| domain == contextual.domain); - if is_contextual { - if let Expr::Member { - receiver, - member, - span, - } = &arg.value - { - if let Expr::Name { name, .. } = receiver.as_ref() { - if name == &contextual.domain { - return HirExpr::Enum { - value_type: contextual.domain.clone(), - value: member.clone(), - span: Some((*span).into()), - }; - } - } - } - } - } - self.lower_expr(&arg.value, macro_params, CallPosition::Value) - } - - /// Resolve a contextual enum-domain parameter (the `chase` form's - /// `ChaseReeval` member, issue #110): the keyword spelling bound to the - /// selector parameter selects the concrete domain and the function the - /// call lowers to. Outside this signature context `ChaseReeval` never - /// resolves (it is not a declared enum domain). - fn resolve_contextual_domain( - &mut self, - entry: &Function, - mut bound: Vec, - selector: Option<&str>, - span: Span, - ) -> (String, Vec) { - let Some(contextual) = &entry.contextual_domain else { - return (entry.id.clone(), bound); - }; - let Some(contextual_param) = entry - .params - .iter() - .position(|param| param.domain.as_deref() == Some(contextual.domain.as_str())) - else { - return (entry.id.clone(), bound); - }; - let manifest = self.manifest; - let mut mismatch = |message: String| { - self.error_at("enum-domain-mismatch", message, span); - }; - let HirExpr::Enum { - value_type, - value, - span: value_span, - } = &bound[contextual_param] - else { - mismatch(format!( - "argument {} of '{}' expects an enum value of domain '{}'", - contextual_param + 1, - entry.id, - contextual.domain - )); - return (entry.id.clone(), bound); - }; - if value_type != &contextual.domain { - mismatch(format!( - "argument {} of '{}' expects enum domain '{}', found '{}'", - contextual_param + 1, - entry.id, - contextual.domain, - value_type - )); - return (entry.id.clone(), bound); - } - let Some(keyword) = selector else { - mismatch(format!( - "argument {} of '{}' cannot resolve the contextual domain '{}' \ - without a '{}' keyword selector", - contextual_param + 1, - entry.id, - contextual.domain, - contextual.by - )); - return (entry.id.clone(), bound); - }; - let Some(option) = contextual.options.get(keyword) else { - mismatch(format!( - "argument {} of '{}' uses the unknown selector keyword '{keyword}'", - contextual_param + 1, - entry.id - )); - return (entry.id.clone(), bound); - }; - let Some(declared) = manifest.enum_domain(&option.domain) else { - return (entry.id.clone(), bound); - }; - if !declared.members.contains(value) { - mismatch(format!( - "member '{value}' is not a member of enum domain '{}'", - option.domain - )); - return (entry.id.clone(), bound); - } - bound[contextual_param] = HirExpr::Enum { - value_type: option.domain.clone(), - value: value.clone(), - span: *value_span, - }; - (option.target.clone(), bound) - } - - fn lower_receiver_call( - &mut self, - receiver: &Expr, - name: &str, - args: &[cst::CallArg], - span: Span, - macro_params: &[String], - position: CallPosition, - ) -> HirExpr { - // `random.uniform(...)` etc. are dotted generic calls. - if let Expr::Name { name: root, .. } = receiver { - if root == "random" { - return self.lower_call( - &format!("random.{name}"), - args, - span, - macro_params, - position, - ); - } - } - // `.format` on a string literal is the format special form; it is - // also a declared member value (receiver category `String`), so - // position misuse diagnoses here. - if let Expr::String { value, .. } = receiver { - if name == "format" { - if args.iter().any(|arg| arg.keyword.is_some()) { - for arg in args { - if let Some((keyword, span)) = &arg.keyword { - self.error_at( - "keyword-unsupported", - format!( - "function 'format' does not accept keyword \ - arguments ('{keyword}')" - ), - *span, - ); - } - } - } - let lowered: Vec = self.lower_arg_values(args, macro_params); - if let Some(entry) = self.manifest.resolve_member("format") { - self.check_call_position("format", entry, position, span); - self.check_enum_domains(entry, &lowered); - } - return HirExpr::Format { - text: value.clone(), - args: lowered, - span: Some(span.into()), - }; - } - } - // Member calls resolve through the manifest (receiver category, - // explicit-argument signatures, keyword binding). - let (member_name, lowered) = match self.manifest.resolve_member(name) { - Some(entry) => { - self.check_call_position(name, entry, position, span); - if let Some(category) = entry.receiver { - self.check_receiver(receiver, category, entry, span); - } - let (bound, _) = self.bind_args(entry, args, macro_params); - self.check_enum_domains(entry, &bound); - (entry.id.clone(), bound) - } - None => { - self.error_at("unknown-member", format!("unknown member '{name}'"), span); - (name.to_string(), self.lower_arg_values(args, macro_params)) - } - }; - // `eventPlayer.member(...)` → receiver call on the event player. - if let Expr::Name { name: root, .. } = receiver { - if root == "eventPlayer" { - return HirExpr::ReceiverCall { - receiver: Box::new(HirExpr::EventPlayer { span: None }), - name: member_name, - args: lowered, - span: Some(span.into()), - }; - } - } - // Any other receiver: resolve it and keep the receiver call. - HirExpr::ReceiverCall { - receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)), - name: member_name, - args: lowered, - span: Some(span.into()), - } - } - - /// Check a builtin entry against its call position: action/value - /// identity and for-iterable context. - fn check_call_position( - &mut self, - name: &str, - entry: &Function, - position: CallPosition, - span: Span, - ) { - match position { - CallPosition::Statement => { - if entry.context == Some(FunctionContext::ForIterable) { - self.error_at( - "invalid-call-context", - format!("'{name}' is only valid as a for-loop iterable"), - span, - ); - } else if entry.kind.is_value() { - self.error_at( - "value-in-action-position", - format!("value function '{name}' cannot be used as an action"), - span, - ); - } - } - CallPosition::Value => { - if entry.kind.is_action() { - self.error_at( - "action-in-value-position", - format!("action function '{name}' cannot be used as a value"), - span, - ); - } else if entry.context == Some(FunctionContext::ForIterable) { - self.error_at( - "invalid-call-context", - format!("'{name}' is only valid as a for-loop iterable"), - span, - ); - } - } - CallPosition::ForIterable => { - if entry.context != Some(FunctionContext::ForIterable) { - self.error_at( - "invalid-iterable", - format!("for-loop iterable '{name}' must be a range(...) call"), - span, - ); - } - } - } - } - - /// Check a member call's receiver against its declared category. Only - /// the reference-enforced categories reject: `.append` requires an - /// assignable receiver and `.format` a string literal; player-oriented - /// members accept any receiver (the pinned reference does not type-check - /// them). - fn check_receiver( - &mut self, - receiver: &Expr, - category: ReceiverCategory, - entry: &Function, - span: Span, - ) { - let mismatch = match category { - ReceiverCategory::String => !matches!(receiver, Expr::String { .. }), - ReceiverCategory::Variable => !assignable_receiver(receiver), - ReceiverCategory::Player | ReceiverCategory::Any => false, - }; - if mismatch { - self.error_at( - "invalid-receiver", - format!( - "member '{}' requires {} as its receiver", - entry.id, - category.describe() - ), - span, - ); - } - } - - /// Check a builtin call's argument count against its declared arity. - fn check_arity(&mut self, entry: &Function, got: usize, span: Span) { - let (min, max) = entry.arity_bounds(); - let valid = got >= min && max.is_none_or(|max| got <= max); - if !valid { - let expects = match max { - Some(max) if min == max => format!("exactly {min}"), - Some(max) => format!("{min} to {max}"), - None => format!("at least {min}"), - }; - let role = match entry.kind { - FunctionKind::Action => "action", - FunctionKind::Value => "value", - FunctionKind::MemberAction => "member action", - FunctionKind::MemberValue => "member value", - }; - self.error_at( - "invalid-arity", - format!( - "{role} '{}' expects {expects} arguments but got {got}", - entry.id - ), - span, - ); - } - } - - /// Check each bound argument that has a declared enum domain: the pinned - /// reference requires an enum member of that domain (variables and other - /// values are rejected), so any mismatch is a structured diagnostic at - /// the argument's span. Contextual domains (the `chase` form) resolve - /// separately and are skipped here. - fn check_enum_domains(&mut self, entry: &Function, bound: &[HirExpr]) { - for (index, param) in entry.params.iter().enumerate() { - let Some(domain) = param.domain.as_deref() else { - continue; - }; - if entry - .contextual_domain - .as_ref() - .is_some_and(|contextual| contextual.domain == domain) - { - continue; - } - let Some(bound_arg) = bound.get(index) else { - continue; - }; - let span = hir_span_to_frontend(bound_arg.span()); - match bound_arg { - HirExpr::Enum { value_type, .. } if value_type == domain => {} - HirExpr::Enum { value_type, .. } => self.error_at( - "enum-domain-mismatch", - format!( - "argument {} of '{}' expects enum domain '{}', found '{}'", - index + 1, - entry.id, - domain, - value_type - ), - span, - ), - _ => self.error_at( - "enum-domain-mismatch", - format!( - "argument {} of '{}' expects an enum value of domain '{}'", - index + 1, - entry.id, - domain - ), - span, - ), - } - } - } - - fn error_at(&mut self, code: &str, message: String, span: Span) { - self.errors.push(FrontendError::at(code, message, span)); - } -} - -/// The keyword spellings a parameter accepts (its name plus alternates). -fn keyword_spellings(param: &Param) -> Vec { - let mut spellings = vec![param.name.clone()]; - spellings.extend(param.alternate_names.iter().cloned()); - spellings -} - -/// The source span covering a call's argument list (the start of the first -/// argument through the last argument). -fn arg_span(args: &[CallArg]) -> Span { - args.first().map(CallArg::span).unwrap_or_else(|| { - Span::new( - 0, - crate::diag::Position::new(1, 1), - crate::diag::Position::new(1, 1), - ) - }) -} - -/// Recover the frontend span of a lowered expression (HIR spans are the -/// same source positions, carried through lowering). -fn hir_span_to_frontend(span: Option<&HirSpan>) -> Span { - match span { - Some(span) => Span::new( - span.file, - crate::diag::Position::new(span.start.line, span.start.col), - crate::diag::Position::new(span.end.line, span.end.col), - ), - None => Span::new( - 0, - crate::diag::Position::new(1, 1), - crate::diag::Position::new(1, 1), - ), - } -} - -/// Whether a CST receiver is assignable (the `.append` receiver rule): a -/// variable name (including macro parameters), an array literal, or an index -/// expression — matching the pinned reference, which rejects constant and -/// function receivers ("Cannot modify or assign to …"). -fn assignable_receiver(receiver: &Expr) -> bool { - match receiver { - Expr::Name { name, .. } => name != "eventPlayer", - Expr::Array { .. } | Expr::Index { .. } => true, - _ => false, - } -} - -impl From for HirSpan { - fn from(span: Span) -> HirSpan { - HirSpan { - file: span.file, - start: Position { - line: span.start.line, - col: span.start.col, - }, - end: Position { - line: span.end.line, - col: span.end.col, - }, - } - } -} - -impl From<&Span> for HirSpan { - fn from(span: &Span) -> HirSpan { - (*span).into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::lexer::{LexInput, lex}; - use crate::parser::parse; - use wright_core::hir::types::{Expr as HirExpr, RuleEntry as HirRuleEntry, Stmt as HirStmt}; - - fn lower_ok(text: &str) -> HirProgram { - 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("parse produces a program"); - lower(&program, vec![], vec![]).expect("lowers without errors") - } - - fn rule_conditions_and_actions(hir: &HirProgram) -> (&Vec, &Vec) { - let HirRuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - (&rule.conditions, &rule.actions) - } - - #[test] - fn receiver_calls_lower_to_receiver_call_hir() { - // `eventPlayer.setMoveSpeed(100)` lowers to a ReceiverCall on the - // event player, and `target.setMoveSpeed(50)` to a ReceiverCall on a - // global-variable receiver (#104). - let hir = lower_ok( - "globalvar target\nrule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n target.setMoveSpeed(50)\n", - ); - let (_, actions) = rule_conditions_and_actions(&hir); - assert_eq!(actions.len(), 2); - - let HirStmt::Expr { expr, .. } = &actions[0] else { - panic!("expected expression statement"); - }; - let HirExpr::ReceiverCall { - receiver, - name, - args, - .. - } = expr.as_ref() - else { - panic!("expected receiver call, got {expr:?}"); - }; - assert_eq!(name, "setMoveSpeed"); - assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. })); - assert_eq!(args.len(), 1); - assert!(matches!(&args[0], HirExpr::Number { .. })); - - let HirStmt::Expr { expr, .. } = &actions[1] else { - panic!("expected expression statement"); - }; - let HirExpr::ReceiverCall { receiver, name, .. } = expr.as_ref() else { - panic!("expected receiver call, got {expr:?}"); - }; - assert_eq!(name, "setMoveSpeed"); - assert!( - matches!(receiver.as_ref(), HirExpr::GlobalVar { name, .. } if name == "target"), - "globalvar receiver must resolve to a GlobalVar" - ); - } - - #[test] - fn receiver_call_values_lower_in_conditions() { - // `@Condition eventPlayer.isAlive()` lowers to a ReceiverCall value; - // `eventPlayer.teleport(eventPlayer.getPosition())` nests a receiver - // call inside another receiver call's arguments (#104). - let hir = lower_ok( - "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.isAlive()\n eventPlayer.teleport(eventPlayer.getPosition())\n", - ); - let (conditions, actions) = rule_conditions_and_actions(&hir); - assert_eq!(conditions.len(), 1); - let HirExpr::ReceiverCall { name, args, .. } = &conditions[0] else { - panic!("expected receiver call condition, got {:?}", conditions[0]); - }; - assert_eq!(name, "isAlive"); - assert_eq!(args.len(), 0); - - let HirStmt::Expr { expr, .. } = &actions[0] else { - panic!("expected expression statement"); - }; - let HirExpr::ReceiverCall { - name, - args, - receiver, - .. - } = expr.as_ref() - else { - panic!("expected receiver call, got {expr:?}"); - }; - assert_eq!(name, "teleport"); - assert!(matches!(receiver.as_ref(), HirExpr::EventPlayer { .. })); - assert_eq!(args.len(), 1); - assert!(matches!( - &args[0], - HirExpr::ReceiverCall { name, .. } if name == "getPosition" - )); - } - - #[test] - fn format_string_receiver_stays_a_format_node() { - // `.format()` on a string receiver is unaffected by the receiver-call - // path (existing supported form). - let hir = lower_ok( - "rule \"r\":\n @Event global\n print(\"{} points\".format(len([1, 2])))\n", - ); - let (_, actions) = rule_conditions_and_actions(&hir); - let HirStmt::Expr { expr, .. } = &actions[0] else { - panic!("expected expression statement"); - }; - assert!( - has_format(expr), - "string `.format()` must lower to a Format node" - ); - } - - fn has_format(expr: &HirExpr) -> bool { - match expr { - HirExpr::Format { .. } => true, - HirExpr::Call { args, .. } => args.iter().any(has_format), - HirExpr::ReceiverCall { args, .. } => args.iter().any(has_format), - _ => false, - } - } - - /// Lower one rule action and return the assignment's value expression. - fn lowered_value(source: &str) -> HirExpr { - let program = crate::compile(source, "test.opy", std::path::Path::new("")) - .unwrap_or_else(|error| panic!("compile failed: {error}")); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected a rule"); - }; - let HirStmt::Assign { value, .. } = &rule.actions[0] else { - panic!("expected an assign statement"); - }; - (**value).clone() - } - - #[test] - fn chase_time_reeval_none_lowers_to_the_catalog_enum() { - let value = lowered_value( - "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NONE\n", - ); - assert_enum(&value, "ChaseTimeReeval", "NONE"); - } - - #[test] - fn chase_time_reeval_destination_and_duration_lowers_to_the_catalog_enum() { - let value = lowered_value( - "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.DESTINATION_AND_DURATION\n", - ); - assert_enum(&value, "ChaseTimeReeval", "DESTINATION_AND_DURATION"); - } - - #[test] - fn chase_rate_reeval_members_lower_to_the_catalog_enum() { - for member in ["NONE", "DESTINATION_AND_RATE"] { - let source = format!( - "globalvar g\nrule \"r\":\n @Event global\n g = ChaseRateReeval.{member}\n" - ); - assert_enum(&lowered_value(&source), "ChaseRateReeval", member); - } - } - - /// Assert the expression is the catalog enum `(domain, member)` node, - /// ignoring its source span (the span is frontend-internal provenance). - fn assert_enum(value: &HirExpr, domain: &str, member: &str) { - match value { - HirExpr::Enum { - value_type, value, .. - } => { - assert_eq!(value_type, domain); - assert_eq!(value, member); - } - other => panic!("expected enum {domain}.{member}, got {other:?}"), - } - } - - #[test] - fn unknown_chase_time_reeval_member_is_a_deterministic_source_located_error() { - let error = crate::compile( - "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n", - "test.opy", - std::path::Path::new(""), - ) - .expect_err("an unknown member must fail"); - assert_eq!(error.code, "unknown-enum-member"); - let span = error.span.expect("the error is source-located"); - assert_eq!(span.start.line, 4); - } - - #[test] - fn unknown_enum_receiver_is_an_unsupported_member_error() { - let error = crate::compile( - "globalvar g\nrule \"r\":\n @Event global\n g = NotARealEnum.MEMBER\n", - "test.opy", - std::path::Path::new(""), - ) - .expect_err("an unknown enum type must fail"); - assert_eq!(error.code, "unsupported-member"); - let span = error.span.expect("the error is source-located"); - assert_eq!(span.start.line, 4); - } - - // --- Builtin semantic manifest coverage (#109) --- - - /// Assert a compile failure has the given code at the given line. - fn compile_error(source: &str, line: u32) -> FrontendError { - let error = crate::compile(source, "test.opy", std::path::Path::new("")) - .expect_err("expected a compile failure"); - let span = error.span.expect("the error is source-located"); - assert_eq!(span.start.line, line, "code '{}'", error.code); - error - } - - fn action_source(statement: &str) -> String { - format!("globalvar g\nrule \"r\":\n @Event global\n {statement}\n") - } - - #[test] - fn chase_over_time_resolves_and_compiles_with_reference_signatures() { - // 4-argument form with an explicit reevaluation member (#106). - let hir = crate::compile( - &action_source("chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE)"), - "test.opy", - std::path::Path::new(""), - ) - .expect("reference-supported chaseOverTime compiles"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - let HirStmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement"); - }; - let HirExpr::Call { name, args, .. } = expr.as_ref() else { - panic!("expected a call, got {expr:?}"); - }; - assert_eq!(name, "chaseOverTime"); - assert_eq!(args.len(), 4); - assert!(matches!( - &args[3], - HirExpr::Enum { value_type, value, .. } - if value_type == "ChaseTimeReeval" && value == "NONE" - )); - - // 3-argument form fills the reference default member. - let hir = crate::compile( - &action_source("chaseOverTime(g, 10, 3)"), - "test.opy", - std::path::Path::new(""), - ) - .expect("default-reevaluation chaseOverTime compiles"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - let HirStmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement"); - }; - let HirExpr::Call { args, .. } = expr.as_ref() else { - panic!("expected a call"); - }; - assert_eq!(args.len(), 4); - assert!(matches!( - &args[3], - HirExpr::Enum { value_type, value, .. } - if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION" - )); - } - - #[test] - fn is_game_in_progress_resolves_as_a_builtin_value() { - // Generic value gap from #106: `isGameInProgress()` in a condition. - let hir = crate::compile( - &action_source("@Condition isGameInProgress() == true"), - "test.opy", - std::path::Path::new(""), - ) - .expect("reference-supported isGameInProgress compiles"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - assert!(matches!(&rule.conditions[0], HirExpr::Binary { .. })); - } - - #[test] - fn enum_gated_members_resolve_through_the_manifest() { - // Enum-gated members from #106: setInvisibility (Invis), getThrottle - // (member value), worldVector (Transform arg), setStatusEffect - // (Status arg). - let source = "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - @Condition eventPlayer.getThrottle() != vect(0, 0, 0)\n \ - @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0)\n \ - eventPlayer.setInvisibility(Invis.ALL)\n \ - eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2)\n"; - let hir = crate::compile(source, "test.opy", std::path::Path::new("")) - .expect("enum-gated members compile"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - assert_eq!(rule.actions.len(), 2); - } - - #[test] - fn get_players_in_radius_fills_reference_enum_defaults() { - // 2-argument form fills Team.ALL and LosCheck.OFF (reference - // emission: `Players Within Radius(..., All Teams, Off)`). - let hir = crate::compile( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0\n \ - disableInspector()\n", - "test.opy", - std::path::Path::new(""), - ) - .expect("getPlayersInRadius with defaults compiles"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - let HirExpr::Binary { left, .. } = &rule.conditions[0] else { - panic!("expected a comparison"); - }; - let HirExpr::Call { name, args, .. } = left.as_ref() else { - panic!("expected len call"); - }; - assert_eq!(name, "len"); - let HirExpr::Call { name, args, .. } = &args[0] else { - panic!("expected getPlayersInRadius call"); - }; - assert_eq!(name, "getPlayersInRadius"); - assert_eq!(args.len(), 4); - assert!(matches!( - &args[2], - HirExpr::Enum { value_type, value, .. } - if value_type == "Team" && value == "ALL" - )); - assert!(matches!( - &args[3], - HirExpr::Enum { value_type, value, .. } - if value_type == "LosCheck" && value == "OFF" - )); - } - - #[test] - fn value_call_in_action_position_is_rejected() { - let error = compile_error(&action_source("isGameInProgress()"), 4); - assert_eq!(error.code, "value-in-action-position"); - } - - #[test] - fn value_member_in_action_position_is_rejected() { - // The #106 baseline records the oracle rejecting `B.isAlive()` as a - // statement; the manifest enforces that contract (#109). - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.isAlive()\n", - 4, - ); - assert_eq!(error.code, "value-in-action-position"); - } - - // --- Named/keyword argument binding and chase call context (#110) --- - - /// Compile a program and return the lowered first action's expression - /// (the statement expression, or the value of a leading assignment). - fn first_action_expr(source: &str) -> HirExpr { - let program = crate::compile(source, "test.opy", std::path::Path::new("")) - .unwrap_or_else(|error| panic!("compile failed: {error}")); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected a rule"); - }; - match &rule.actions[0] { - HirStmt::Expr { expr, .. } => (**expr).clone(), - HirStmt::Assign { value, .. } => (**value).clone(), - other => panic!("expected an expression or assignment, got {other:?}"), - } - } - - /// Remove every `span`/`name_span` key from a serialized expression (the - /// differential suite's normalization). - fn strip_spans(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - map.remove("span"); - map.remove("name_span"); - for nested in map.values_mut() { - strip_spans(nested); - } - } - serde_json::Value::Array(items) => { - for item in items { - strip_spans(item); - } - } - _ => {} - } - } - - #[test] - fn chase_keyword_forms_dispatch_to_the_concrete_chase_functions() { - // The reference `chase` form: `rate = …` dispatches to chaseAtRate - // with the ChaseRateReeval domain, `duration = …` to chaseOverTime - // with the ChaseTimeReeval domain; the `ChaseReeval` member resolves - // only through this call context (issue #110). - let expr = first_action_expr(&action_source("chase(g, 10, rate=2, ChaseReeval.NONE)")); - let HirExpr::Call { name, args, .. } = &expr else { - panic!("expected a call, got {expr:?}"); - }; - assert_eq!(name, "chaseAtRate"); - assert!(matches!( - &args[3], - HirExpr::Enum { value_type, value, .. } - if value_type == "ChaseRateReeval" && value == "NONE" - )); - - let expr = first_action_expr(&action_source( - "chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION)", - )); - let HirExpr::Call { name, args, .. } = &expr else { - panic!("expected a call, got {expr:?}"); - }; - assert_eq!(name, "chaseOverTime"); - assert!(matches!( - &args[3], - HirExpr::Enum { value_type, value, .. } - if value_type == "ChaseTimeReeval" && value == "DESTINATION_AND_DURATION" - )); - - // Player-variable first arguments resolve too (the emission layer - // picks the player form). - let expr = first_action_expr( - "playervar P\nrule \"r\":\n @Event eachPlayer\n \ - chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE)\n", - ); - let HirExpr::Call { name, args, .. } = &expr else { - panic!("expected a call, got {expr:?}"); - }; - assert_eq!(name, "chaseAtRate"); - assert!(matches!(&args[0], HirExpr::PlayerVar { .. })); - } - - #[test] - fn chase_reeval_resolves_only_in_the_chase_call_context() { - // `ChaseReeval` is not a declared enum domain: a bare member access - // outside the chase signature is rejected. - let error = compile_error(&action_source("g = ChaseReeval.NONE"), 4); - assert_eq!(error.code, "unsupported-member"); - - // A member of the wrong concrete domain is rejected through the - // selected option (reference: "Unknown chaseratereeval …"). - let error = compile_error( - &action_source("chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION)"), - 4, - ); - assert_eq!(error.code, "enum-domain-mismatch"); - assert!(error.message.contains("ChaseRateReeval")); - - // A non-enum 4th argument is rejected like the reference's - // "Expected a member of the 'ChaseReeval' enum" check. - let error = compile_error(&action_source("chase(g, 10, rate=2, 5)"), 4); - assert_eq!(error.code, "enum-domain-mismatch"); - } - - #[test] - fn chase_requires_the_keyword_rate_or_duration_third_argument() { - let error = compile_error(&action_source("chase(g, 10, 2, ChaseReeval.NONE)"), 4); - assert_eq!(error.code, "keyword-required"); - assert!(error.message.contains("rate")); - } - - #[test] - fn chase_family_requires_a_variable_first_argument() { - // The reference rejects non-variable first arguments for the chase - // family ("Expected variable for 1st argument of function - // 'chaseOverTime'", issue #110) — the variable kind also selects - // the global/player emission form. - let error = compile_error(&action_source("chase(10, 10, rate=2, ChaseReeval.NONE)"), 4); - assert_eq!(error.code, "invalid-argument"); - - let error = compile_error( - &action_source("chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)"), - 4, - ); - assert_eq!(error.code, "invalid-argument"); - } - - #[test] - fn keyword_binding_matches_positional_binding_in_hir() { - // Keyword binding consumes the manifest signatures: the bound HIR is - // identical to the positional form's (defaults filled the same way), - // modulo source spans (the keyword values sit at different columns). - fn without_spans(expr: &HirExpr) -> serde_json::Value { - let mut value = serde_json::to_value(expr).unwrap(); - strip_spans(&mut value); - value - } - let keyword = without_spans(&first_action_expr(&action_source( - "chaseOverTime(g, 10, duration=3)", - ))); - let positional = without_spans(&first_action_expr(&action_source( - "chaseOverTime(g, 10, 3)", - ))); - assert_eq!(keyword, positional); - - let keyword = without_spans(&first_action_expr(&action_source("wait(time=1)"))); - let positional = without_spans(&first_action_expr(&action_source("wait(1)"))); - assert_eq!(keyword, positional); - - // Out-of-order keywords bind by name. - let keyword = without_spans(&first_action_expr(&action_source( - "wait(waitBehavior=Wait.IGNORE_CONDITION, time=2)", - ))); - let positional = without_spans(&first_action_expr(&action_source("wait(2)"))); - assert_eq!(keyword, positional); - - let keyword = without_spans(&first_action_expr(&action_source( - "g = vect(x=1, y=2, z=3)", - ))); - let positional = without_spans(&first_action_expr(&action_source("g = vect(1, 2, 3)"))); - assert_eq!(keyword, positional); - } - - #[test] - fn keyword_binding_diagnostics_are_structured_and_source_located() { - // Unknown keyword name. - let error = compile_error(&action_source("chaseOverTime(g, 10, bogus=1)"), 4); - assert_eq!(error.code, "unknown-keyword"); - assert!(error.message.contains("bogus")); - - // Duplicate (positional slot filled again by keyword). - let error = compile_error( - &action_source( - "chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, \ - reevaluation=ChaseTimeReeval.NONE)", - ), - 4, - ); - assert_eq!(error.code, "duplicate-argument"); - - // Positional after keyword. - let error = compile_error(&action_source("chaseOverTime(g, duration=3, 5)"), 4); - assert_eq!(error.code, "positional-after-keyword"); - - // Missing required argument (reference: "Missing argument 'duration'"). - let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4); - assert_eq!(error.code, "missing-argument"); - - // Positional-only parameter bound by keyword (`chase`'s leading - // arguments; the reference rejects the keyword form). - let error = compile_error( - &action_source("chase(variable=g, destination=10, rate=2, ChaseReeval.NONE)"), - 4, - ); - assert_eq!(error.code, "unknown-keyword"); - } - - #[test] - fn keyword_arguments_are_rejected_for_reference_special_cases() { - // The reference routes `range`, `random.*`, and `.format` around its - // generic keyword binder; keyword arguments fail deterministically. - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event global\n \ - for I in range(start=0, stop=3):\n debug(I)\n", - 4, - ); - assert_eq!(error.code, "keyword-unsupported"); - - let error = compile_error(&action_source("g = random.uniform(min=1, max=2)"), 4); - assert_eq!(error.code, "keyword-unsupported"); - - let error = compile_error(&action_source("print(\"{} points\".format(value=1))"), 4); - assert_eq!(error.code, "keyword-unsupported"); - } - - #[test] - fn wait_uses_the_reference_keyword_names() { - // The manifest's `wait` parameter names match the pinned reference - // (`time`, `waitBehavior`), so `wait(duration=1)` is an unknown - // keyword exactly like the oracle. - let error = compile_error(&action_source("wait(duration=1)"), 4); - assert_eq!(error.code, "unknown-keyword"); - assert!(error.message.contains("duration")); - } - - #[test] - fn action_call_in_value_position_is_rejected() { - let error = compile_error(&action_source("g = wait(1)"), 4); - assert_eq!(error.code, "action-in-value-position"); - } - - #[test] - fn missing_required_argument_is_a_source_located_diagnostic() { - // Too-few calls reject with the reference's missing-argument - // diagnostic (`chaseOverTime(g, 10)` → "Missing argument 'duration'", - // issue #110); positional overflow keeps `invalid-arity`. - let error = compile_error(&action_source("chaseOverTime(g, 10)"), 4); - assert_eq!(error.code, "missing-argument"); - assert!(error.message.contains("duration")); - - let error = compile_error(&action_source("chaseOverTime(g, 10, 3, 4, 5)"), 4); - assert_eq!(error.code, "invalid-arity"); - } - - #[test] - fn missing_member_argument_is_a_source_located_diagnostic() { - // #106 evidence: `getPlayersInRadius(...).setStatusEffect(eventPlayer, - // 30)` must reject like the oracle (the `status` argument is - // missing; the reference: "Missing argument 'status' for function - // '.setStatusEffect'", issue #110). - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30)\n", - 4, - ); - assert_eq!(error.code, "missing-argument"); - assert!(error.message.contains("duration")); - } - - #[test] - fn invalid_receiver_categories_are_rejected() { - // `.append` requires an assignable receiver; `.format` a string - // literal (both reference-enforced categories). - let error = compile_error(&action_source("3.append(1)"), 4); - assert_eq!(error.code, "invalid-receiver"); - assert!(error.message.contains("append")); - - let error = compile_error(&action_source("print(3.format(\"{}\"))"), 4); - assert_eq!(error.code, "invalid-receiver"); - assert!(error.message.contains("format")); - } - - #[test] - fn enum_domain_mismatch_is_a_source_located_diagnostic() { - // Wrong enum domain for a parameter (#106): the oracle rejects - // `chaseOverTime(..., Invis.ALL)` and - // `eventPlayer.setInvisibility(ChaseTimeReeval.NONE)`. - let error = compile_error(&action_source("chaseOverTime(g, 10, 3, Invis.ALL)"), 4); - assert_eq!(error.code, "enum-domain-mismatch"); - assert!(error.message.contains("ChaseTimeReeval")); - - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - eventPlayer.setInvisibility(ChaseTimeReeval.NONE)\n", - 4, - ); - assert_eq!(error.code, "enum-domain-mismatch"); - assert!(error.message.contains("Invis")); - } - - #[test] - fn non_enum_arguments_for_enum_parameters_are_rejected() { - // The reference requires an enum member for enum-domain parameters; - // numbers, strings, and even variables are rejected. - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - eventPlayer.setInvisibility(g)\n", - 4, - ); - assert_eq!(error.code, "enum-domain-mismatch"); - - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - eventPlayer.setInvisibility(3)\n", - 4, - ); - assert_eq!(error.code, "enum-domain-mismatch"); - } - - #[test] - fn unknown_builtins_fail_at_resolution_not_emission() { - let error = compile_error(&action_source("frobnicate()"), 4); - assert_eq!(error.code, "unknown-action"); - - let error = compile_error(&action_source("g = frobnicate()"), 4); - assert_eq!(error.code, "unknown-value"); - - let error = compile_error( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n", - 4, - ); - assert_eq!(error.code, "unknown-member"); - } - - #[test] - fn wright_only_catalog_names_are_rejected() { - // `createHudText` and `squareRoot` are Workshop emission spellings, - // not OPY source functions; the pinned reference rejects them, so - // the manifest does not preserve the accidental acceptance. - let error = compile_error(&action_source("createHudText(1)"), 4); - assert_eq!(error.code, "unknown-action"); - - let error = compile_error(&action_source("g = squareRoot(9)"), 4); - assert_eq!(error.code, "unknown-value"); - } - - #[test] - fn generic_member_only_actions_are_rejected() { - // `setMoveSpeed(eventPlayer, 100)` is not an OPY function: the - // member form is the reference surface. - let error = compile_error(&action_source("setMoveSpeed(eventPlayer, 100)"), 4); - assert_eq!(error.code, "unknown-action"); - } - - #[test] - fn range_is_for_iterables_only() { - // Standalone `range(...)` is rejected by the reference; the - // for-header form keeps 1-3 arguments. - let error = compile_error(&action_source("@Condition len(range(1, 5, 1)) > 0"), 4); - assert_eq!(error.code, "invalid-call-context"); - - let error = compile_error(&action_source("for g in [1, 2]:\n debug(g)"), 4); - assert_eq!(error.code, "invalid-iterable"); - - crate::compile( - &action_source("for g in range(3):\n debug(g)"), - "test.opy", - std::path::Path::new(""), - ) - .expect("the for-header range form compiles"); - } - - #[test] - fn source_aliases_resolve_to_canonical_names() { - // Non-contextual aliases rewrite to the canonical entry so identity, - // position, and emission use the target name. - let hir = crate::compile( - &action_source("stopChasingVariable(g)"), - "test.opy", - std::path::Path::new(""), - ) - .expect("the alias target compiles"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - let HirStmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement"); - }; - let HirExpr::Call { name, .. } = expr.as_ref() else { - panic!("expected a call"); - }; - assert_eq!(name, "stopChasing"); - - let hir = crate::compile( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ - @Condition eventPlayer.getCurrentHero() != null\n \ - @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false\n \ - disableInspector()\n", - "test.opy", - std::path::Path::new(""), - ) - .expect("member aliases compile"); - let RuleEntry::Rule(rule) = &hir.rules[0] else { - panic!("expected a rule"); - }; - let HirExpr::Binary { left, .. } = &rule.conditions[0] else { - panic!("expected a comparison"); - }; - let HirExpr::ReceiverCall { name, .. } = left.as_ref() else { - panic!("expected a receiver call"); - }; - assert_eq!(name, "getHero"); - } - - #[test] - fn reference_rejected_enum_members_are_rejected() { - // The KNOWN_ENUMS table previously accepted Color.CYAN and - // DynamicEffect.SPARKLES; the pinned reference rejects those - // spellings, so the manifest's reference-validated member lists do - // not preserve them (#109). - let error = compile_error(&action_source("g = Color.CYAN"), 4); - assert_eq!(error.code, "unknown-enum-member"); - - let error = compile_error(&action_source("g = DynamicEffect.SPARKLES"), 4); - assert_eq!(error.code, "unknown-enum-member"); - } - - #[test] - fn default_var_for_binder_resolves_at_all_range_arities() { - // The agent-lab regression: `for I in range(0, 10):` with `I` not - // declared. `I` is an OverPy default variable name (A–Z, AA–…), which - // the pinned reference accepts as an implicit global loop binder - // (#114). All range arities keep compiling (1, 2, and 3 arguments). - for (binder, iterable) in [ - ("I", "range(0, 10)"), - ("I", "range(3)"), - ("I", "range(1, 5, 2)"), - ] { - let hir = lower_ok(&format!( - "globalvar total\nrule \"r\":\n @Event global\n for {binder} in {iterable}:\n total += {binder}\n" - )); - let (_, actions) = rule_conditions_and_actions(&hir); - let HirStmt::For { variable, body, .. } = &actions[0] else { - panic!("expected a for statement"); - }; - assert!( - matches!(variable.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"), - "the binder resolves to the implicit global 'I', got {variable:?}" - ); - assert!(!body.is_empty(), "the loop body lowers"); - // The binder use in the body resolves too: `total += I` has a - // GlobalVar operand. - let HirStmt::Assign { value, .. } = &body[0] else { - panic!("expected an assignment in the body"); - }; - let HirExpr::Binary { right, .. } = value.as_ref() else { - panic!("expected a binary expression"); - }; - assert!( - matches!(right.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"), - "the binder use inside the body resolves to the implicit global" - ); - } - } - - #[test] - fn default_var_names_resolve_as_implicit_globals() { - // Default variable names resolve anywhere a variable may appear, - // matching the pinned reference (no `globalvar` declaration needed). - let hir = lower_ok("rule \"r\":\n @Event global\n I = 5\n debug(I)\n"); - let (_, actions) = rule_conditions_and_actions(&hir); - let HirStmt::Assign { target, .. } = &actions[0] else { - panic!("expected an assignment"); - }; - assert!( - matches!(target.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"), - "the implicit global resolves, got {target:?}" - ); - // `AA` (slot 26) and `Z` (slot 25) are default names; `i` is not. - assert_eq!(default_var_index("I"), Some(8)); - assert_eq!(default_var_index("AA"), Some(26)); - assert_eq!(default_var_index("Z"), Some(25)); - assert_eq!(default_var_index("DX"), Some(127)); - assert_eq!(default_var_index("DY"), None); - assert_eq!(default_var_index("i"), None); - } - - #[test] - fn nested_same_name_for_binders_reuse_the_implicit_global() { - // Nested loops with the same default-var binder reuse the single - // implicit variable, matching the pinned reference (the inner loop - // overwrites the same Workshop global — no separate binding). - let hir = lower_ok( - "rule \"r\":\n @Event global\n for I in range(3):\n for I in range(2):\n debug(I)\n", - ); - let (_, actions) = rule_conditions_and_actions(&hir); - let HirStmt::For { - variable: outer, - body, - .. - } = &actions[0] - else { - panic!("expected an outer for statement"); - }; - let HirStmt::For { - variable: inner, .. - } = &body[0] - else { - panic!("expected an inner for statement"); - }; - assert!( - matches!(outer.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I") - && matches!(inner.as_ref(), HirExpr::GlobalVar { name, .. } if name == "I"), - "both loops bind the same implicit global (spans differ per binder site)" - ); - } - - #[test] - fn undeclared_lowercase_binder_is_still_an_unknown_identifier() { - // A lowercase undeclared binder is not a default variable name; the - // pinned reference rejects the program ("Unknown function name"), and - // Wright reports the same reject with the structured - // `unknown-identifier` diagnostic (#114). - let error = compile_error( - "rule \"r\":\n @Event global\n for i in range(3):\n debug(i)\n", - 3, - ); - assert_eq!(error.code, "unknown-identifier"); - let span = error.span.expect("the error is source-located"); - assert_eq!(span.start.line, 3); - } -} diff --git a/crates/wright-opy/src/manifest/data/manifest.json b/crates/wright-opy/src/manifest/data/manifest.json deleted file mode 100644 index 68b01f0..0000000 --- a/crates/wright-opy/src/manifest/data/manifest.json +++ /dev/null @@ -1,823 +0,0 @@ -{ - "aliases": [ - { - "evidence": [ - "aliases" - ], - "kind": "functionAlias", - "source": "stopChasingVariable", - "target": "stopChasing" - }, - { - "evidence": [ - "member-aliases" - ], - "kind": "memberAlias", - "source": "getCurrentHero", - "target": "getHero" - }, - { - "evidence": [ - "member-aliases" - ], - "kind": "memberAlias", - "source": "hasStatusEffect", - "target": "hasStatus" - } - ], - "enumDomains": [ - { - "domain": "Beam", - "evidence": [ - "builtin-enums" - ], - "members": [ - "GOOD", - "GRAPPLE" - ] - }, - { - "domain": "Color", - "evidence": [ - "builtin-enums" - ], - "members": [ - "YELLOW", - "WHITE", - "RED", - "ORANGE", - "GREEN", - "BLUE", - "BLACK", - "PURPLE", - "AQUA", - "VIOLET", - "ROSE" - ] - }, - { - "domain": "DynamicEffect", - "evidence": [ - "builtin-enums" - ], - "members": [ - "BAD_EXPLOSION", - "GOOD_EXPLOSION", - "RING_EXPLOSION", - "GOOD_PICKUP_EFFECT", - "BAD_PICKUP_EFFECT", - "BUFF_IMPACT_SOUND", - "DEBUFF_IMPACT_SOUND" - ] - }, - { - "domain": "EffectReeval", - "evidence": [ - "builtin-enums" - ], - "members": [ - "VISIBILITY", - "COLOR", - "VISIBILITY_AND_COLOR" - ] - }, - { - "domain": "ChaseTimeReeval", - "evidence": [ - "builtin-enums", - "chase-over-time", - "chase-over-time-defaults" - ], - "members": [ - "NONE", - "DESTINATION_AND_DURATION" - ] - }, - { - "domain": "ChaseRateReeval", - "evidence": [ - "builtin-enums" - ], - "members": [ - "NONE", - "DESTINATION_AND_RATE" - ] - }, - { - "domain": "Wait", - "evidence": [ - "builtin-enums" - ], - "members": [ - "IGNORE_CONDITION" - ] - }, - { - "domain": "Invis", - "evidence": [ - "builtin-enums", - "enum-gated-members" - ], - "members": [ - "ALL", - "ENEMIES", - "NONE" - ] - }, - { - "domain": "Transform", - "evidence": [ - "builtin-enums", - "enum-gated-members" - ], - "members": [ - "ROTATION", - "ROTATION_AND_TRANSLATION" - ] - }, - { - "domain": "Status", - "evidence": [ - "builtin-enums", - "enum-gated-members" - ], - "members": [ - "ASLEEP", - "BURNING", - "FROZEN", - "HACKED", - "INVINCIBLE", - "KNOCKED_DOWN", - "PHASED_OUT", - "ROOTED", - "STUNNED", - "UNKILLABLE" - ] - }, - { - "domain": "LosCheck", - "evidence": [ - "builtin-enums", - "get-players-in-radius" - ], - "members": [ - "OFF", - "SURFACES", - "SURFACES_AND_ALL_BARRIERS", - "SURFACES_AND_ENEMY_BARRIERS" - ] - }, - { - "domain": "Team", - "evidence": [ - "builtin-enums", - "get-players-in-radius" - ], - "members": [ - "ALL" - ] - } - ], - "functions": [ - { - "catalogId": "wait", - "evidence": [ - "generic-builtins" - ], - "id": "wait", - "kind": "action", - "params": [ - { - "default": 0.016, - "name": "time" - }, - { - "default": "IGNORE_CONDITION", - "domain": "Wait", - "name": "waitBehavior" - } - ] - }, - { - "catalogId": "disableInspector", - "evidence": [ - "generic-builtins" - ], - "id": "disableInspector", - "kind": "action", - "params": [] - }, - { - "evidence": [ - "generic-builtins" - ], - "id": "debug", - "kind": "action", - "params": [ - { - "name": "value" - } - ] - }, - { - "evidence": [ - "generic-builtins" - ], - "id": "print", - "kind": "action", - "params": [ - { - "name": "text" - } - ] - }, - { - "catalogId": "createBeamEffect", - "evidence": [ - "generic-builtins" - ], - "id": "createBeam", - "kind": "action", - "params": [ - { - "name": "visibleTo" - }, - { - "domain": "Beam", - "name": "type" - }, - { - "name": "startPosition" - }, - { - "name": "endPosition" - }, - { - "domain": "Color", - "name": "color", - "optional": true - }, - { - "domain": "EffectReeval", - "name": "reevaluation" - } - ] - }, - { - "catalogId": "playEffect", - "evidence": [ - "generic-builtins" - ], - "id": "playEffect", - "kind": "action", - "params": [ - { - "name": "visibleTo" - }, - { - "domain": "DynamicEffect", - "name": "type" - }, - { - "domain": "Color", - "name": "color" - }, - { - "name": "position" - }, - { - "name": "radius" - } - ] - }, - { - "contextualDomain": { - "by": "rate", - "domain": "ChaseReeval", - "options": { - "duration": { - "domain": "ChaseTimeReeval", - "target": "chaseOverTime" - }, - "rate": { - "domain": "ChaseRateReeval", - "target": "chaseAtRate" - } - } - }, - "evidence": [ - "chase-keywords", - "chase-reeval-context" - ], - "id": "chase", - "kind": "action", - "params": [ - { - "name": "variable", - "positionalOnly": true, - "variable": true - }, - { - "name": "destination", - "positionalOnly": true - }, - { - "alternateNames": [ - "duration" - ], - "keywordOnly": true, - "name": "rate" - }, - { - "domain": "ChaseReeval", - "name": "reevaluation" - } - ] - }, - { - "catalogId": "chaseOverTime", - "evidence": [ - "chase-over-time", - "chase-over-time-defaults" - ], - "id": "chaseOverTime", - "kind": "action", - "params": [ - { - "name": "variable", - "variable": true - }, - { - "name": "destination" - }, - { - "name": "duration" - }, - { - "default": "DESTINATION_AND_DURATION", - "domain": "ChaseTimeReeval", - "name": "reevaluation" - } - ] - }, - { - "evidence": [ - "aliases" - ], - "id": "stopChasing", - "kind": "action", - "params": [ - { - "name": "variable" - } - ] - }, - { - "catalogId": "countOf", - "evidence": [ - "generic-builtins" - ], - "id": "len", - "kind": "value", - "params": [ - { - "name": "array" - } - ] - }, - { - "catalogId": "absoluteValue", - "evidence": [ - "generic-builtins" - ], - "id": "abs", - "kind": "value", - "params": [ - { - "name": "value" - } - ] - }, - { - "catalogId": "squareRoot", - "evidence": [ - "generic-builtins" - ], - "id": "sqrt", - "kind": "value", - "params": [ - { - "name": "value" - } - ] - }, - { - "catalogId": "allPlayers", - "evidence": [ - "generic-builtins" - ], - "id": "getAllPlayers", - "kind": "value", - "params": [] - }, - { - "catalogId": "randomReal", - "evidence": [ - "generic-builtins" - ], - "id": "random.uniform", - "keywordArgs": false, - "kind": "value", - "params": [ - { - "name": "min" - }, - { - "name": "max" - } - ] - }, - { - "catalogId": "randomValueInArray", - "evidence": [ - "generic-builtins" - ], - "id": "random.choice", - "keywordArgs": false, - "kind": "value", - "params": [ - { - "name": "array" - } - ] - }, - { - "context": "forIterable", - "evidence": [ - "range-for-header" - ], - "id": "range", - "keywordArgs": false, - "kind": "value", - "params": [ - { - "name": "start" - }, - { - "name": "stop", - "optional": true - }, - { - "name": "step", - "optional": true - } - ] - }, - { - "catalogId": "vector", - "evidence": [ - "generic-builtins" - ], - "id": "vect", - "kind": "value", - "params": [ - { - "name": "x" - }, - { - "name": "y" - }, - { - "name": "z" - } - ] - }, - { - "catalogId": "isGameInProgress", - "evidence": [ - "is-game-in-progress" - ], - "id": "isGameInProgress", - "kind": "value", - "params": [] - }, - { - "catalogId": "getPlayersInRadius", - "evidence": [ - "get-players-in-radius" - ], - "id": "getPlayersInRadius", - "kind": "value", - "params": [ - { - "name": "center" - }, - { - "name": "radius" - }, - { - "default": "ALL", - "domain": "Team", - "name": "team" - }, - { - "default": "OFF", - "domain": "LosCheck", - "name": "losCheck" - } - ] - }, - { - "catalogId": "worldVector", - "evidence": [ - "enum-gated-members" - ], - "id": "worldVector", - "kind": "value", - "params": [ - { - "name": "localVector" - }, - { - "name": "relativePlayer" - }, - { - "domain": "Transform", - "name": "transformation" - } - ] - }, - { - "evidence": [ - "receiver-calls" - ], - "id": "append", - "kind": "memberAction", - "params": [ - { - "name": "value" - } - ], - "receiver": "Variable" - }, - { - "catalogId": "setMoveSpeed", - "evidence": [ - "receiver-calls" - ], - "id": "setMoveSpeed", - "kind": "memberAction", - "params": [ - { - "name": "moveSpeedPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setMaxHealth", - "evidence": [ - "receiver-calls" - ], - "id": "setMaxHealth", - "kind": "memberAction", - "params": [ - { - "name": "healthPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setHealth", - "evidence": [ - "receiver-calls" - ], - "id": "setHealth", - "kind": "memberAction", - "params": [ - { - "name": "amount" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setAimSpeed", - "evidence": [ - "receiver-calls" - ], - "id": "setAimSpeed", - "kind": "memberAction", - "params": [ - { - "name": "turnSpeedPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setGravity", - "evidence": [ - "receiver-calls" - ], - "id": "setGravity", - "kind": "memberAction", - "params": [ - { - "name": "gravityPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setDamageDealt", - "evidence": [ - "receiver-calls" - ], - "id": "setDamageDealt", - "kind": "memberAction", - "params": [ - { - "name": "damageDealtPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setDamageReceived", - "evidence": [ - "receiver-calls" - ], - "id": "setDamageReceived", - "kind": "memberAction", - "params": [ - { - "name": "damageReceivedPercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setUltCharge", - "evidence": [ - "receiver-calls" - ], - "id": "setUltCharge", - "kind": "memberAction", - "params": [ - { - "name": "chargePercent" - } - ], - "receiver": "Player" - }, - { - "catalogId": "teleport", - "evidence": [ - "receiver-calls" - ], - "id": "teleport", - "kind": "memberAction", - "params": [ - { - "name": "position" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setInvisibility", - "evidence": [ - "enum-gated-members" - ], - "id": "setInvisibility", - "kind": "memberAction", - "params": [ - { - "domain": "Invis", - "name": "invisibleTo" - } - ], - "receiver": "Player" - }, - { - "catalogId": "setStatusEffect", - "evidence": [ - "enum-gated-members" - ], - "id": "setStatusEffect", - "kind": "memberAction", - "params": [ - { - "name": "assister" - }, - { - "domain": "Status", - "name": "status" - }, - { - "name": "duration" - } - ], - "receiver": "Player" - }, - { - "catalogId": "getPosition", - "evidence": [ - "receiver-calls" - ], - "id": "getPosition", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "catalogId": "getHealth", - "evidence": [ - "receiver-calls" - ], - "id": "getHealth", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "catalogId": "isAlive", - "evidence": [ - "receiver-calls" - ], - "id": "isAlive", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "catalogId": "hasSpawned", - "evidence": [ - "receiver-calls" - ], - "id": "hasSpawned", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "catalogId": "getThrottle", - "evidence": [ - "enum-gated-members" - ], - "id": "getThrottle", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "evidence": [ - "member-aliases" - ], - "id": "getHero", - "kind": "memberValue", - "params": [], - "receiver": "Player" - }, - { - "evidence": [ - "member-aliases" - ], - "id": "hasStatus", - "kind": "memberValue", - "params": [ - { - "domain": "Status", - "name": "status" - } - ], - "receiver": "Player" - }, - { - "catalogId": "customString", - "evidence": [ - "generic-builtins" - ], - "id": "format", - "keywordArgs": false, - "kind": "memberValue", - "params": [], - "receiver": "String", - "unbounded": true - } - ], - "provenance": { - "generator": "wright-opy semantic compatibility manifest v1 (Wright-authored; probe-validated against the pinned OverPy 9.7.10 oracle)", - "license": "AGPL-3.0-or-later", - "reviewed": true - }, - "reference": { - "contentCommit": "889d974", - "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", - "name": "overpy", - "version": "9.7.10" - }, - "schemaVersion": 1 -} diff --git a/crates/wright-opy/src/manifest/mod.rs b/crates/wright-opy/src/manifest/mod.rs deleted file mode 100644 index 15bb1b1..0000000 --- a/crates/wright-opy/src/manifest/mod.rs +++ /dev/null @@ -1,935 +0,0 @@ -//! The OPY semantic compatibility manifest (issue #109). -//! -//! This module owns the Wright-authored, reference-validated semantic table -//! that the native `.opy` frontend resolves builtin names, member functions, -//! receiver categories, signatures/arity, parameter enum domains, enum -//! members, and non-contextual source aliases against — the authoritative -//! replacement for the hardcoded `KNOWN_ENUMS` table and the semantic -//! catalog-coverage gap behind `unknown-action`/`unknown-value`/ -//! `unsupported-member` emission failures. -//! -//! * The data lives in [`data/manifest.json`](data/manifest.json) (schema -//! v1, per `docs/opy/compat-manifest-spec.md`). -//! * Every entry records the pinned-oracle probe that validates it -//! (`probes/probes.json`); `probes/validate.py` runs each probe against the -//! pinned OverPy 9.7.10 oracle and verifies accept/reject, emission hash, -//! and diagnostic category deterministically. -//! * `catalogId` links each entry to the canonical Workshop emission -//! catalog (workshop-rs, consumed directly) -//! by canonical identity without duplicating localization/output spelling -//! data; a cross-check test verifies every declared id exists with the -//! matching kind. -//! -//! The manifest is language-compatibility metadata, not runtime content data -//! (issue #96 stays deferred), and it is Wright-authored data validated -//! against observed oracle behavior — never a mechanical conversion of -//! OverPy's GPL-3.0 data files (ADR-0004, `docs/licensing.md`). - -use std::collections::HashMap; -use std::sync::OnceLock; - -use serde::{Deserialize, Serialize}; - -/// The embedded schema-v1 manifest data. -pub const MANIFEST_DATA: &str = include_str!("data/manifest.json"); - -/// The embedded probe evidence record for the manifest data. -pub const PROBES_DATA: &str = include_str!("probes/probes.json"); - -/// The pinned reference identity the manifest data is validated against. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Reference { - pub name: String, - pub version: String, - #[serde(rename = "contentCommit")] - pub content_commit: String, - pub integrity: String, -} - -/// Provenance of the manifest data. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Provenance { - pub generator: String, - pub license: String, - pub reviewed: bool, -} - -/// The kind of a builtin function entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FunctionKind { - /// A generic action (`chaseOverTime(...)` as a statement). - Action, - /// A generic value (`isGameInProgress()` in an expression). - Value, - /// An action called on a receiver (`eventPlayer.setMoveSpeed(100)`). - MemberAction, - /// A value called on a receiver (`eventPlayer.isAlive()`). - MemberValue, -} - -impl FunctionKind { - /// Whether this kind is an action (statement-position builtin). - pub fn is_action(self) -> bool { - matches!(self, FunctionKind::Action | FunctionKind::MemberAction) - } - - /// Whether this kind is a value (expression-position builtin). - pub fn is_value(self) -> bool { - matches!(self, FunctionKind::Value | FunctionKind::MemberValue) - } - - /// Whether this kind is a receiver member function. - pub fn is_member(self) -> bool { - matches!(self, FunctionKind::MemberAction | FunctionKind::MemberValue) - } -} - -/// The declared receiver category of a member function. -/// -/// `Player` is the metadata category for player-oriented members (the pinned -/// reference does not type-check those receivers, so the frontend does not -/// reject them); `Variable` and `String` are enforced where the reference -/// semantics are clear (`.append` requires an assignable receiver, `.format` -/// requires a string literal). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ReceiverCategory { - Player, - Variable, - String, - Any, -} - -impl ReceiverCategory { - /// A human-readable description of the category for diagnostics. - pub fn describe(self) -> &'static str { - match self { - ReceiverCategory::Player => "a player-valued expression", - ReceiverCategory::Variable => "an assignable variable", - ReceiverCategory::String => "a string literal", - ReceiverCategory::Any => "any expression", - } - } -} - -/// A parameter default that the frontend expands: an enum member -/// (`"MEMBER"`) or a scalar (`0.016`). Only enum-member defaults are -/// expanded at lowering (matching the reference emission); scalar defaults -/// are declared data (the `wait` special form fills its own). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ParamDefault { - EnumMember(String), - Number(f64), -} - -/// One ordered parameter of a function entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Param { - pub name: String, - /// The enum domain this parameter requires, when it is an enum argument. - #[serde(default)] - pub domain: Option, - /// An explicit default the frontend may expand; see [`ParamDefault`]. - #[serde(default)] - pub default: Option, - /// Whether the argument is omittable without an emitted expansion - /// (`"optional": true`; the reference accepts the short form). - #[serde(default)] - pub optional: bool, - /// Whether the argument must be passed as a keyword (`name = expr`): - /// the reference `chase` form requires its 3rd argument to be - /// `rate = ...` or `duration = ...` (issue #110). - #[serde(default)] - pub keyword_only: bool, - /// Whether the argument can only be passed positionally (keyword - /// binding is rejected): the reference `chase` form's leading arguments - /// (issue #110). - #[serde(default)] - pub positional_only: bool, - /// Additional accepted keyword spellings for this parameter (the - /// reference `chase` form accepts both `rate` and `duration` for its - /// 3rd argument). - #[serde(default)] - pub alternate_names: Vec, - /// Whether the argument must be a variable reference (a global variable - /// or a player variable); the chase family requires a variable first - /// argument to select the global/player emission form. - #[serde(default)] - pub variable: bool, -} - -/// A call-context restriction on a function entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FunctionContext { - /// Only valid as a `for ... in` iterable (`range`; the pinned reference - /// rejects standalone `range` calls). - ForIterable, -} - -/// One contextual enum-domain selection: the `chase` dispatch (issue #110). -/// -/// The reference `chase` form binds its 4th argument as a member of a -/// merged `ChaseReeval` domain that does not exist as a standalone enum: -/// the keyword name used for the `by` parameter selects the concrete domain -/// and the function the call lowers to (`rate` → `ChaseRateReeval` / -/// `chaseAtRate`, `duration` → `ChaseTimeReeval` / `chaseOverTime`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContextualDomain { - /// The contextual (merged) domain name; never resolvable outside the - /// declaring function's signature context. - pub domain: String, - /// The parameter whose bound keyword name selects the option. - pub by: String, - /// The options keyed by the accepted keyword spellings of the `by` - /// parameter. - pub options: std::collections::BTreeMap, -} - -/// One contextual-domain option: the concrete enum domain and the function -/// name the call lowers to. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContextualDomainOption { - pub domain: String, - pub target: String, -} - -/// One builtin function entry (generic action/value or member function). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Function { - pub id: String, - pub kind: FunctionKind, - /// The receiver category of member functions. - #[serde(default)] - pub receiver: Option, - #[serde(default)] - pub params: Vec, - /// Whether the argument count is unbounded (`.format` placeholders). - #[serde(default)] - pub unbounded: bool, - /// Whether keyword arguments are accepted (`name = expr`). Defaults to - /// `true` (the reference's `parseArgs` applies to every workshop - /// function); entries the reference routes around that mechanism - /// (`range`, `random.*`, `.format`) declare `"keywordArgs": false` - /// (issue #110). - #[serde(default = "default_keyword_args")] - pub keyword_args: bool, - /// The contextual enum-domain dispatch (the `chase` form), when this - /// entry has one. - #[serde(default)] - pub contextual_domain: Option, - #[serde(default)] - pub context: Option, - /// The canonical Workshop catalog id this entry emits through; absent - /// when emission is special-cased or not yet catalog-covered. - #[serde(default)] - #[serde(rename = "catalogId")] - pub catalog_id: Option, - /// The probe ids that validate this entry against the pinned oracle. - #[serde(default)] - pub evidence: Vec, -} - -impl Function { - /// The (minimum, maximum) argument count: the first parameter with a - /// default makes every following parameter optional; `unbounded` entries - /// accept any count. - pub fn arity_bounds(&self) -> (usize, Option) { - if self.unbounded { - return (0, None); - } - let first_default = self - .params - .iter() - .position(|param| param.default.is_some() || param.optional); - let min = first_default.unwrap_or(self.params.len()); - (min, Some(self.params.len())) - } -} - -fn default_keyword_args() -> bool { - true -} - -/// One enum value domain (`Invis`, `Transform`, `ChaseTimeReeval`, …). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnumDomain { - pub domain: String, - pub members: Vec, - #[serde(default)] - pub evidence: Vec, -} - -/// A non-contextual source alias: a pure name rewrite to a declared entry. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Alias { - pub source: String, - pub target: String, - pub kind: AliasKind, - #[serde(default)] - pub evidence: Vec, -} - -/// The alias target class; `functionAlias` targets a generic function, -/// `memberAlias` a member function. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AliasKind { - FunctionAlias, - MemberAlias, -} - -/// One recorded probe in the embedded evidence record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Probe { - pub id: String, - pub source: String, - pub sha256: String, - pub expect: String, - #[serde(default)] - pub output_sha256: Option, - #[serde(default)] - pub diagnostic_contains: Option, -} - -/// A validation failure while loading the manifest. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ManifestError(pub String); - -impl std::fmt::Display for ManifestError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for ManifestError {} - -/// The validated OPY semantic compatibility manifest. -#[derive(Debug, Clone)] -pub struct Manifest { - pub schema_version: u32, - pub reference: Reference, - pub functions: Vec, - pub enum_domains: Vec, - pub aliases: Vec, - pub provenance: Provenance, - /// The recorded probe evidence (`probes/probes.json`). - pub probes: Vec, - by_function: HashMap, - by_member: HashMap, - by_domain: HashMap, - alias_by_source: HashMap, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ManifestFile { - schema_version: u32, - reference: Reference, - #[serde(default)] - functions: Vec, - #[serde(default)] - enum_domains: Vec, - #[serde(default)] - aliases: Vec, - provenance: Provenance, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProbesFile { - schema_version: u32, - #[serde(default)] - probes: Vec, -} - -impl Manifest { - /// Parse and validate manifest data plus its probe evidence record. - pub fn load(manifest_json: &str, probes_json: &str) -> Result { - let file: ManifestFile = serde_json::from_str(manifest_json) - .map_err(|error| ManifestError(format!("manifest data: {error}")))?; - if file.schema_version != 1 { - return Err(ManifestError(format!( - "unsupported manifest schemaVersion {}", - file.schema_version - ))); - } - let probes_file: ProbesFile = serde_json::from_str(probes_json) - .map_err(|error| ManifestError(format!("probes data: {error}")))?; - if probes_file.schema_version != 1 { - return Err(ManifestError(format!( - "unsupported probes schemaVersion {}", - probes_file.schema_version - ))); - } - let mut manifest = Manifest { - schema_version: file.schema_version, - reference: file.reference.clone(), - functions: Vec::new(), - enum_domains: Vec::new(), - aliases: Vec::new(), - provenance: file.provenance.clone(), - probes: probes_file.probes, - by_function: HashMap::new(), - by_member: HashMap::new(), - by_domain: HashMap::new(), - alias_by_source: HashMap::new(), - }; - manifest.validate(file)?; - Ok(manifest) - } - - fn validate(&mut self, file: ManifestFile) -> Result<(), ManifestError> { - // Probe ids must be unique and must record the accept probes the - // entries reference. - let mut probes: HashMap<&str, &Probe> = HashMap::new(); - for probe in &self.probes { - if probes.insert(&probe.id, probe).is_some() { - return Err(ManifestError(format!("duplicate probe id '{}'", probe.id))); - } - } - - // Enum domains: unique, non-empty, distinct members. - for domain in &file.enum_domains { - if self.by_domain.contains_key(&domain.domain) { - return Err(ManifestError(format!( - "duplicate enum domain '{}'", - domain.domain - ))); - } - if domain.members.is_empty() { - return Err(ManifestError(format!( - "enum domain '{}' declares no members", - domain.domain - ))); - } - let mut members = std::collections::HashSet::new(); - for member in &domain.members { - if !members.insert(member) { - return Err(ManifestError(format!( - "enum domain '{}' repeats member '{}'", - domain.domain, member - ))); - } - } - self.by_domain - .insert(domain.domain.clone(), self.enum_domains.len()); - self.enum_domains.push(domain.clone()); - } - - // Functions: unique ids, member-only receiver/kind combinations, - // declared enum domains, declared enum-default members, and probe - // evidence that records acceptance. - for function in &file.functions { - if self.by_function.contains_key(&function.id) { - return Err(ManifestError(format!( - "duplicate function id '{}'", - function.id - ))); - } - match function.kind { - FunctionKind::MemberAction | FunctionKind::MemberValue => { - if function.receiver.is_none() { - return Err(ManifestError(format!( - "member function '{}' declares no receiver category", - function.id - ))); - } - } - FunctionKind::Action | FunctionKind::Value => { - if function.receiver.is_some() { - return Err(ManifestError(format!( - "non-member function '{}' declares a receiver category", - function.id - ))); - } - } - } - for param in function.params.iter() { - if let Some(domain) = ¶m.domain { - // A parameter may declare the function's own contextual - // domain (`chase`'s `ChaseReeval`): it resolves only in - // this signature's context and has no standalone member - // list. - let is_contextual = function - .contextual_domain - .as_ref() - .is_some_and(|contextual| &contextual.domain == domain); - let declared = if is_contextual { - None - } else { - Some(self.enum_domain(domain).ok_or_else(|| { - ManifestError(format!( - "function '{}' parameter '{}' references undeclared enum \ - domain '{}'", - function.id, param.name, domain - )) - })?) - }; - if let (Some(declared), Some(ParamDefault::EnumMember(member))) = - (declared, ¶m.default) - { - if !declared.members.contains(member) { - return Err(ManifestError(format!( - "function '{}' parameter '{}' default '{}' is not a member \ - of enum domain '{}'", - function.id, param.name, member, domain - ))); - } - } - } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) { - return Err(ManifestError(format!( - "function '{}' parameter '{}' has an enum-member default but no \ - declared domain", - function.id, param.name - ))); - } - if param.keyword_only && param.positional_only { - return Err(ManifestError(format!( - "function '{}' parameter '{}' cannot be both keyword-only and \ - positional-only", - function.id, param.name - ))); - } - for alternate in ¶m.alternate_names { - if alternate == ¶m.name { - return Err(ManifestError(format!( - "function '{}' parameter '{}' repeats its name as an \ - alternate keyword spelling", - function.id, param.name - ))); - } - if function.params.iter().any(|other| { - !std::ptr::eq(other, param) - && (&other.name == alternate - || other.alternate_names.contains(alternate)) - }) { - return Err(ManifestError(format!( - "function '{}' alternate keyword spelling '{alternate}' \ - collides with another parameter", - function.id - ))); - } - } - } - if let Some(contextual) = &function.contextual_domain { - if self.enum_domain(&contextual.domain).is_some() { - return Err(ManifestError(format!( - "function '{}' contextual domain '{}' must not be a declared \ - enum domain (it resolves only in this signature's context)", - function.id, contextual.domain - ))); - } - let by_param = function - .params - .iter() - .find(|param| param.name == contextual.by) - .ok_or_else(|| { - ManifestError(format!( - "function '{}' contextual domain '{}' references unknown \ - selector parameter '{}'", - function.id, contextual.domain, contextual.by - )) - })?; - let contextual_param = function - .params - .iter() - .find(|param| param.domain.as_deref() == Some(contextual.domain.as_str())) - .ok_or_else(|| { - ManifestError(format!( - "function '{}' contextual domain '{}' has no parameter \ - declaring that domain", - function.id, contextual.domain - )) - })?; - let _ = contextual_param; - let mut spellings = vec![by_param.name.clone()]; - spellings.extend(by_param.alternate_names.iter().cloned()); - for (keyword, option) in &contextual.options { - if !spellings.contains(keyword) { - return Err(ManifestError(format!( - "function '{}' contextual option '{keyword}' is not a \ - keyword spelling of selector parameter '{}'", - function.id, by_param.name - ))); - } - self.enum_domain(&option.domain).ok_or_else(|| { - ManifestError(format!( - "function '{}' contextual option '{keyword}' references \ - undeclared enum domain '{}'", - function.id, option.domain - )) - })?; - } - } - self.check_evidence(&function.id, &function.evidence, &probes)?; - if function.kind.is_member() { - self.by_member - .insert(function.id.clone(), self.functions.len()); - } else { - self.by_function - .insert(function.id.clone(), self.functions.len()); - } - self.functions.push(function.clone()); - } - - // Aliases: unique sources, declared targets of the matching class, - // no collision with declared function ids. - for alias in &file.aliases { - if self.alias_by_source.contains_key(&alias.source) { - return Err(ManifestError(format!( - "duplicate alias source '{}'", - alias.source - ))); - } - if self.by_function.contains_key(&alias.source) - || self.by_member.contains_key(&alias.source) - { - return Err(ManifestError(format!( - "alias source '{}' collides with a declared function", - alias.source - ))); - } - match alias.kind { - AliasKind::FunctionAlias => { - if self.function(&alias.target).is_none() { - return Err(ManifestError(format!( - "alias '{}' targets '{}' which is not a generic function", - alias.source, alias.target - ))); - } - } - AliasKind::MemberAlias => { - if self.member(&alias.target).is_none() { - return Err(ManifestError(format!( - "alias '{}' targets '{}' which is not a member function", - alias.source, alias.target - ))); - } - } - } - self.check_evidence(&alias.source, &alias.evidence, &probes)?; - self.alias_by_source - .insert(alias.source.clone(), self.aliases.len()); - self.aliases.push(alias.clone()); - } - - // Enum-domain evidence records acceptance probes too. - for domain in &file.enum_domains { - self.check_evidence(&domain.domain, &domain.evidence, &probes)?; - } - Ok(()) - } - - fn check_evidence( - &self, - owner: &str, - evidence: &[String], - probes: &HashMap<&str, &Probe>, - ) -> Result<(), ManifestError> { - if evidence.is_empty() { - return Err(ManifestError(format!( - "entry '{owner}' records no oracle probe evidence" - ))); - } - for probe_id in evidence { - let probe = probes.get(probe_id.as_str()).ok_or_else(|| { - ManifestError(format!( - "entry '{owner}' references undeclared probe '{probe_id}'" - )) - })?; - if probe.expect != "success" { - return Err(ManifestError(format!( - "entry '{owner}' references probe '{probe_id}' which does not record \ - oracle acceptance" - ))); - } - } - Ok(()) - } - - /// The built-in manifest, loaded once from the embedded data. - pub fn builtin() -> Result<&'static Manifest, ManifestError> { - static MANIFEST: OnceLock> = OnceLock::new(); - MANIFEST - .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA)) - .as_ref() - .map_err(Clone::clone) - } - - /// A generic (non-member) function by source name, alias-aware. - pub fn resolve_function(&self, name: &str) -> Option<&Function> { - self.function(name).or_else(|| { - let alias = self.alias_by_source.get(name)?; - let alias = &self.aliases[*alias]; - (alias.kind == AliasKind::FunctionAlias) - .then(|| self.function(&alias.target)) - .flatten() - }) - } - - /// A member function by source name, alias-aware. - pub fn resolve_member(&self, name: &str) -> Option<&Function> { - self.member(name).or_else(|| { - let alias = self.alias_by_source.get(name)?; - let alias = &self.aliases[*alias]; - (alias.kind == AliasKind::MemberAlias) - .then(|| self.member(&alias.target)) - .flatten() - }) - } - - /// The function entry with the given id, if declared. - pub fn function(&self, id: &str) -> Option<&Function> { - self.by_function.get(id).map(|i| &self.functions[*i]) - } - - /// The member function entry with the given id, if declared. - pub fn member(&self, id: &str) -> Option<&Function> { - self.by_member.get(id).map(|i| &self.functions[*i]) - } - - /// The enum domain with the given name, if declared. - pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> { - self.by_domain.get(domain).map(|i| &self.enum_domains[*i]) - } -} - -/// Canonicalize manifest data: parse, validate, and re-serialize -/// deterministically (object keys sorted, stable formatting). Re-running on -/// the same input produces byte-identical output, so the data is -/// reproducible and the committed file must equal its canonical form. -pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result { - Manifest::load(manifest_json, probes_json)?; - let value: serde_json::Value = serde_json::from_str(manifest_json) - .map_err(|error| ManifestError(format!("manifest data: {error}")))?; - serde_json::to_string_pretty(&value) - .map(|mut out| { - out.push('\n'); - out - }) - .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}"))) -} - -/// Adapt the manifest to the Workshop parse context (#111): the expected -/// enum domain for a Workshop call argument, taken solely from the manifest's -/// canonical `catalogId` → parameter-domain data. -/// -/// Workshop text lays member-kind functions (receiver methods) out with the -/// receiver as argument 0, so the manifest's parameter indexes shift by one -/// for those entries. The manifest remains the single source of expected -/// domains; this impl is the bridge mapping them onto Workshop argument -/// positions. -impl wright_core::signatures::ExpectedDomain for Manifest { - fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> { - // Entries with a declared `catalogId` map directly. - if let Some(entry) = self.functions.iter().find(|f| { - f.catalog_id - .as_deref() - .is_some_and(|catalog_id_of| catalog_id_of == catalog_id) - }) { - let offset = usize::from(entry.kind.is_member()); - let param = entry.params.get(arg_index.checked_sub(offset)?)?; - return param.domain.as_deref(); - } - // The contextual dispatch targets (the `chase` form, #110) select - // their expected domain by the catalog id itself: the emitted - // `Chase Global Variable At Rate(..., None)` reparses with the - // `ChaseRateReeval` domain because the emitter's catalog id for the - // rate form is `chaseAtRate` (the same data the frontend used to - // select the form). Only the contextual parameter's argument index - // pins a domain, mirroring declared entries. - for function in &self.functions { - let Some(contextual) = &function.contextual_domain else { - continue; - }; - let Some(contextual_index) = function - .params - .iter() - .position(|param| param.domain.as_deref() == Some(contextual.domain.as_str())) - else { - continue; - }; - for option in contextual.options.values() { - // The emission layer dispatches the player-variable forms - // through their own catalog ids - // (`chasePlayerVariableAtRate`/`chasePlayerVariableOverTime`), - // which pin the same reevaluation domain as their global - // counterparts; their argument list is shifted by one (the - // receiver name occupies an extra leading slot). - let player_form = catalog_id.strip_prefix("chasePlayerVariable"); - let matches = catalog_id == option.target - || player_form.is_some_and(|suffix| option.target.ends_with(suffix)); - let at_contextual = if player_form.is_some() { - arg_index == contextual_index + 1 - } else { - arg_index == contextual_index - }; - if matches && at_contextual { - return Some(&option.domain); - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builtin_manifest_loads_and_validates() { - let manifest = Manifest::builtin().expect("embedded manifest must validate"); - assert_eq!(manifest.schema_version, 1); - assert_eq!(manifest.reference.name, "overpy"); - assert_eq!(manifest.reference.version, "9.7.10"); - assert!(!manifest.functions.is_empty()); - assert!(!manifest.enum_domains.is_empty()); - assert!(!manifest.aliases.is_empty()); - // Every member entry declares a receiver; every entry has evidence. - for function in &manifest.functions { - assert!(!function.evidence.is_empty(), "{}", function.id); - if function.kind.is_member() { - assert!(function.receiver.is_some(), "{}", function.id); - } - } - for domain in &manifest.enum_domains { - assert!(!domain.evidence.is_empty(), "{}", domain.domain); - } - } - - #[test] - fn manifest_data_is_canonical() { - // The committed data file must equal its deterministic canonical - // rewrite (the `build` path), so the data pipeline is reproducible. - let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes"); - assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical"); - // Idempotency: re-canonicalizing the canonical form is byte-stable. - assert_eq!( - canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"), - canonical - ); - } - - #[test] - fn validation_rejects_duplicates_and_undeclared_domains() { - fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result { - let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap(); - mutate(&mut file); - Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA) - } - // duplicate function id - let error = mutate(|file| file.functions.push(file.functions[0].clone())) - .expect_err("duplicate function id must fail"); - assert!(error.0.contains("duplicate function id")); - // undeclared enum domain - let error = mutate(|file| { - file.functions[0].params.push(Param { - name: "bad".to_string(), - domain: Some("NotADomain".to_string()), - default: None, - optional: false, - keyword_only: false, - positional_only: false, - alternate_names: Vec::new(), - variable: false, - }) - }) - .expect_err("undeclared domain must fail"); - assert!(error.0.contains("undeclared enum domain")); - // entry without evidence - let error = mutate(|file| file.functions[0].evidence.clear()) - .expect_err("missing evidence must fail"); - assert!(error.0.contains("no oracle probe evidence")); - } - - #[test] - fn arity_bounds_follow_defaults_and_unbounded() { - let manifest = Manifest::builtin().expect("builtin"); - let chase = manifest.function("chaseOverTime").expect("entry"); - assert_eq!(chase.arity_bounds(), (3, Some(4))); - let radius = manifest.function("getPlayersInRadius").expect("entry"); - assert_eq!(radius.arity_bounds(), (2, Some(4))); - let status = manifest.member("setStatusEffect").expect("entry"); - assert_eq!(status.arity_bounds(), (3, Some(3))); - let format = manifest.member("format").expect("entry"); - assert_eq!(format.arity_bounds(), (0, None)); - let range = manifest.function("range").expect("entry"); - assert_eq!(range.arity_bounds(), (1, Some(3))); - assert_eq!(range.context, Some(FunctionContext::ForIterable)); - } - - #[test] - fn aliases_resolve_to_declared_targets() { - let manifest = Manifest::builtin().expect("builtin"); - let alias = manifest - .resolve_function("stopChasingVariable") - .expect("alias"); - assert_eq!(alias.id, "stopChasing"); - assert!(alias.kind.is_action()); - let member = manifest.resolve_member("getCurrentHero").expect("alias"); - assert_eq!(member.id, "getHero"); - assert!(member.kind.is_value()); - // Unknown names stay unresolved. - assert!(manifest.resolve_function("frobnicate").is_none()); - assert!(manifest.resolve_member("frobnicate").is_none()); - } - - #[test] - fn catalog_ids_link_to_the_workshop_emission_catalog() { - // Every declared `catalogId` must exist in the canonical Workshop - // emission catalog (workshop-rs, consumed directly) under the - // matching kind, so manifest entries never surface as accidental - // emitter catalog misses. Entries without a - // `catalogId` (special emission forms like `debug`/`print`, or - // emission surfaces not yet catalog-covered like the alias targets) - // are exempt by design. - let manifest = Manifest::builtin().expect("builtin"); - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let has_id = |kind: workshop_rs::catalog::Kind, id: &str| { - catalog.entries_of(kind).any(|entry| entry.id == id) - }; - for function in &manifest.functions { - let Some(catalog_id) = &function.catalog_id else { - continue; - }; - let kind = match function.kind { - FunctionKind::Action | FunctionKind::MemberAction => { - workshop_rs::catalog::Kind::Action - } - FunctionKind::Value | FunctionKind::MemberValue => { - workshop_rs::catalog::Kind::Value - } - }; - assert!( - has_id(kind, catalog_id), - "catalogId '{}' of '{}' is missing from the Workshop emission catalog", - catalog_id, - function.id - ); - // Contextual dispatch targets (the `chase` form) must exist in - // the action catalog too, so a valid contextual call never - // surfaces as an accidental emitter catalog miss. - if let Some(contextual) = &function.contextual_domain { - for (keyword, option) in &contextual.options { - assert!( - has_id(workshop_rs::catalog::Kind::Action, &option.target), - "contextual target '{}' (keyword '{keyword}') of '{}' is missing \ - from the Workshop emission catalog", - option.target, - function.id - ); - } - } - } - } -} diff --git a/crates/wright-opy/src/manifest/probes/action-in-value-position.opy b/crates/wright-opy/src/manifest/probes/action-in-value-position.opy deleted file mode 100644 index 12ef579..0000000 --- a/crates/wright-opy/src/manifest/probes/action-in-value-position.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - g = wait(1) diff --git a/crates/wright-opy/src/manifest/probes/aliases.opy b/crates/wright-opy/src/manifest/probes/aliases.opy deleted file mode 100644 index f9de898..0000000 --- a/crates/wright-opy/src/manifest/probes/aliases.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g -rule "r": - @Event global - stopChasingVariable(g) - stopChasing(g) diff --git a/crates/wright-opy/src/manifest/probes/builtin-enums.opy b/crates/wright-opy/src/manifest/probes/builtin-enums.opy deleted file mode 100644 index 6015209..0000000 --- a/crates/wright-opy/src/manifest/probes/builtin-enums.opy +++ /dev/null @@ -1,51 +0,0 @@ -globalvar g -rule "r": - @Event global - g = Beam.GOOD - g = Beam.GRAPPLE - g = Color.YELLOW - g = Color.WHITE - g = Color.RED - g = Color.ORANGE - g = Color.GREEN - g = Color.BLUE - g = Color.BLACK - g = Color.PURPLE - g = Color.AQUA - g = Color.VIOLET - g = Color.ROSE - g = DynamicEffect.BAD_EXPLOSION - g = DynamicEffect.GOOD_EXPLOSION - g = DynamicEffect.RING_EXPLOSION - g = DynamicEffect.GOOD_PICKUP_EFFECT - g = DynamicEffect.BAD_PICKUP_EFFECT - g = DynamicEffect.BUFF_IMPACT_SOUND - g = DynamicEffect.DEBUFF_IMPACT_SOUND - g = EffectReeval.VISIBILITY - g = EffectReeval.COLOR - g = EffectReeval.VISIBILITY_AND_COLOR - g = ChaseTimeReeval.NONE - g = ChaseTimeReeval.DESTINATION_AND_DURATION - g = ChaseRateReeval.NONE - g = ChaseRateReeval.DESTINATION_AND_RATE - g = Wait.IGNORE_CONDITION - g = Invis.ALL - g = Invis.ENEMIES - g = Invis.NONE - g = Transform.ROTATION - g = Transform.ROTATION_AND_TRANSLATION - g = Status.ASLEEP - g = Status.BURNING - g = Status.FROZEN - g = Status.HACKED - g = Status.INVINCIBLE - g = Status.KNOCKED_DOWN - g = Status.PHASED_OUT - g = Status.ROOTED - g = Status.STUNNED - g = Status.UNKILLABLE - g = LosCheck.OFF - g = LosCheck.SURFACES - g = LosCheck.SURFACES_AND_ALL_BARRIERS - g = LosCheck.SURFACES_AND_ENEMY_BARRIERS - g = Team.ALL diff --git a/crates/wright-opy/src/manifest/probes/catalog-only-names.opy b/crates/wright-opy/src/manifest/probes/catalog-only-names.opy deleted file mode 100644 index ec005c6..0000000 --- a/crates/wright-opy/src/manifest/probes/catalog-only-names.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g -rule "r": - @Event global - createHudText(1) - g = squareRoot(9) diff --git a/crates/wright-opy/src/manifest/probes/chase-arg3-keyword-required.opy b/crates/wright-opy/src/manifest/probes/chase-arg3-keyword-required.opy deleted file mode 100644 index 4b49101..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-arg3-keyword-required.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chase(g, 10, 2, ChaseReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/chase-duplicate-keyword.opy b/crates/wright-opy/src/manifest/probes/chase-duplicate-keyword.opy deleted file mode 100644 index 1017a65..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-duplicate-keyword.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE, reevaluation=ChaseTimeReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/chase-keyword-binding.opy b/crates/wright-opy/src/manifest/probes/chase-keyword-binding.opy deleted file mode 100644 index 7fe8f2d..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-keyword-binding.opy +++ /dev/null @@ -1,13 +0,0 @@ -globalvar g - -rule "chase keyword binding": - @Event eachPlayer - chaseOverTime(g, 10, duration=3) - chaseOverTime(g, 10, 3, reevaluation=ChaseTimeReeval.NONE) - wait(time=1) - wait(waitBehavior=Wait.IGNORE_CONDITION, time=2) - print(text="x") - g = len(array=[1, 2]) - g = vect(x=1, y=2, z=3) - eventPlayer.setStatusEffect(assister=eventPlayer, status=Status.ROOTED, duration=2) - g = getPlayersInRadius(center=vect(0, 0, 0), radius=10, team=Team.ALL) diff --git a/crates/wright-opy/src/manifest/probes/chase-keywords.opy b/crates/wright-opy/src/manifest/probes/chase-keywords.opy deleted file mode 100644 index 4962898..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-keywords.opy +++ /dev/null @@ -1,9 +0,0 @@ -globalvar g -playervar P - -rule "chase keyword forms": - @Event eachPlayer - chase(g, 10, rate=2, ChaseReeval.NONE) - chase(g, 10, duration=3, ChaseReeval.NONE) - chase(eventPlayer.P, 0, rate=1, ChaseReeval.NONE) - chase(eventPlayer.P, 0, duration=1, ChaseReeval.DESTINATION_AND_DURATION) diff --git a/crates/wright-opy/src/manifest/probes/chase-missing-argument.opy b/crates/wright-opy/src/manifest/probes/chase-missing-argument.opy deleted file mode 100644 index 1c55f5c..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-missing-argument.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chaseOverTime(g, 10) diff --git a/crates/wright-opy/src/manifest/probes/chase-over-time-defaults.opy b/crates/wright-opy/src/manifest/probes/chase-over-time-defaults.opy deleted file mode 100644 index a1764eb..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-over-time-defaults.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - chaseOverTime(g, 10, 3) diff --git a/crates/wright-opy/src/manifest/probes/chase-over-time-variable.opy b/crates/wright-opy/src/manifest/probes/chase-over-time-variable.opy deleted file mode 100644 index a727292..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-over-time-variable.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/chase-over-time.opy b/crates/wright-opy/src/manifest/probes/chase-over-time.opy deleted file mode 100644 index df29c3a..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-over-time.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - chaseOverTime(g, 10, 3, ChaseTimeReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/chase-positional-after-keyword.opy b/crates/wright-opy/src/manifest/probes/chase-positional-after-keyword.opy deleted file mode 100644 index 4f84924..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-positional-after-keyword.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chaseOverTime(g, duration=3, 5) diff --git a/crates/wright-opy/src/manifest/probes/chase-reeval-context.opy b/crates/wright-opy/src/manifest/probes/chase-reeval-context.opy deleted file mode 100644 index f110f98..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-reeval-context.opy +++ /dev/null @@ -1,6 +0,0 @@ -globalvar g - -rule "chase reevaluation domain selection": - @Event global - chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_RATE) - chase(g, 10, duration=3, ChaseReeval.DESTINATION_AND_DURATION) diff --git a/crates/wright-opy/src/manifest/probes/chase-reeval-outside.opy b/crates/wright-opy/src/manifest/probes/chase-reeval-outside.opy deleted file mode 100644 index 2d91f32..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-reeval-outside.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - g = ChaseReeval.NONE diff --git a/crates/wright-opy/src/manifest/probes/chase-reeval-wrong-domain.opy b/crates/wright-opy/src/manifest/probes/chase-reeval-wrong-domain.opy deleted file mode 100644 index d3b069f..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-reeval-wrong-domain.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chase(g, 10, rate=2, ChaseReeval.DESTINATION_AND_DURATION) diff --git a/crates/wright-opy/src/manifest/probes/chase-unknown-keyword.opy b/crates/wright-opy/src/manifest/probes/chase-unknown-keyword.opy deleted file mode 100644 index 94a43a7..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-unknown-keyword.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chaseOverTime(g, 10, bogus=1) diff --git a/crates/wright-opy/src/manifest/probes/chase-variable-first-arg.opy b/crates/wright-opy/src/manifest/probes/chase-variable-first-arg.opy deleted file mode 100644 index fb27d4d..0000000 --- a/crates/wright-opy/src/manifest/probes/chase-variable-first-arg.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - chase(10, 10, rate=2, ChaseReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/enum-arg-non-enum.opy b/crates/wright-opy/src/manifest/probes/enum-arg-non-enum.opy deleted file mode 100644 index a07eac6..0000000 --- a/crates/wright-opy/src/manifest/probes/enum-arg-non-enum.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - eventPlayer.setInvisibility(3) diff --git a/crates/wright-opy/src/manifest/probes/enum-arg-variable.opy b/crates/wright-opy/src/manifest/probes/enum-arg-variable.opy deleted file mode 100644 index 752c237..0000000 --- a/crates/wright-opy/src/manifest/probes/enum-arg-variable.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - eventPlayer.setInvisibility(g) diff --git a/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-1.opy b/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-1.opy deleted file mode 100644 index 0e9aae0..0000000 --- a/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-1.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - chaseOverTime(g, 10, 3, Invis.ALL) diff --git a/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-2.opy b/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-2.opy deleted file mode 100644 index 1fcb1d8..0000000 --- a/crates/wright-opy/src/manifest/probes/enum-domain-mismatch-2.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - eventPlayer.setInvisibility(ChaseTimeReeval.NONE) diff --git a/crates/wright-opy/src/manifest/probes/enum-gated-members.opy b/crates/wright-opy/src/manifest/probes/enum-gated-members.opy deleted file mode 100644 index 1e671cf..0000000 --- a/crates/wright-opy/src/manifest/probes/enum-gated-members.opy +++ /dev/null @@ -1,7 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - @Condition eventPlayer.getThrottle() != vect(0, 0, 0) - @Condition worldVector(vect(1, 2, 3), eventPlayer, Transform.ROTATION) != vect(0, 0, 0) - eventPlayer.setInvisibility(Invis.ALL) - eventPlayer.setStatusEffect(eventPlayer, Status.ROOTED, 2) diff --git a/crates/wright-opy/src/manifest/probes/generic-builtins.opy b/crates/wright-opy/src/manifest/probes/generic-builtins.opy deleted file mode 100644 index dfcba2c..0000000 --- a/crates/wright-opy/src/manifest/probes/generic-builtins.opy +++ /dev/null @@ -1,16 +0,0 @@ -globalvar g -rule "r": - @Event global - @Condition abs(-2) > 0 - @Condition sqrt(4) > 0 - @Condition len(getAllPlayers()) >= 0 - @Condition random.uniform(0, 1) >= 0 - @Condition len(random.choice([[1]])) >= 0 - @Condition vect(1, 2, 3) != vect(0, 0, 0) - disableInspector() - debug(len([1])) - print("x") - wait() - wait(0.5) - playEffect(getAllPlayers(), DynamicEffect.BAD_EXPLOSION, Color.YELLOW, g, 1) - createBeam(getAllPlayers(), Beam.GOOD, g, g, Color.YELLOW, EffectReeval.VISIBILITY) diff --git a/crates/wright-opy/src/manifest/probes/generic-member-only-action.opy b/crates/wright-opy/src/manifest/probes/generic-member-only-action.opy deleted file mode 100644 index 4b4f9d4..0000000 --- a/crates/wright-opy/src/manifest/probes/generic-member-only-action.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - setMoveSpeed(eventPlayer, 100) diff --git a/crates/wright-opy/src/manifest/probes/get-players-in-radius.opy b/crates/wright-opy/src/manifest/probes/get-players-in-radius.opy deleted file mode 100644 index ad42f44..0000000 --- a/crates/wright-opy/src/manifest/probes/get-players-in-radius.opy +++ /dev/null @@ -1,6 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10)) > 0 - @Condition len(getPlayersInRadius(eventPlayer.getPosition(), 10, Team.ALL, LosCheck.OFF)) > 0 - disableInspector() diff --git a/crates/wright-opy/src/manifest/probes/invalid-arity-member.opy b/crates/wright-opy/src/manifest/probes/invalid-arity-member.opy deleted file mode 100644 index d0f6347..0000000 --- a/crates/wright-opy/src/manifest/probes/invalid-arity-member.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - getPlayersInRadius(eventPlayer.getPosition(), 10).setStatusEffect(eventPlayer, 30) diff --git a/crates/wright-opy/src/manifest/probes/invalid-arity-too-few.opy b/crates/wright-opy/src/manifest/probes/invalid-arity-too-few.opy deleted file mode 100644 index e087155..0000000 --- a/crates/wright-opy/src/manifest/probes/invalid-arity-too-few.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - chaseOverTime(g, 10) diff --git a/crates/wright-opy/src/manifest/probes/invalid-arity-wait.opy b/crates/wright-opy/src/manifest/probes/invalid-arity-wait.opy deleted file mode 100644 index 8b3e745..0000000 --- a/crates/wright-opy/src/manifest/probes/invalid-arity-wait.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - wait(1, 2, 3) diff --git a/crates/wright-opy/src/manifest/probes/invalid-receiver-append.opy b/crates/wright-opy/src/manifest/probes/invalid-receiver-append.opy deleted file mode 100644 index 6facd79..0000000 --- a/crates/wright-opy/src/manifest/probes/invalid-receiver-append.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - 3.append(1) diff --git a/crates/wright-opy/src/manifest/probes/invalid-receiver-format.opy b/crates/wright-opy/src/manifest/probes/invalid-receiver-format.opy deleted file mode 100644 index 1450c7b..0000000 --- a/crates/wright-opy/src/manifest/probes/invalid-receiver-format.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - print(3.format("{}")) diff --git a/crates/wright-opy/src/manifest/probes/is-game-in-progress.opy b/crates/wright-opy/src/manifest/probes/is-game-in-progress.opy deleted file mode 100644 index 48d5842..0000000 --- a/crates/wright-opy/src/manifest/probes/is-game-in-progress.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g -rule "r": - @Event global - @Condition isGameInProgress() == true - disableInspector() diff --git a/crates/wright-opy/src/manifest/probes/keyword-arguments-unsupported.opy b/crates/wright-opy/src/manifest/probes/keyword-arguments-unsupported.opy deleted file mode 100644 index 86a3d69..0000000 --- a/crates/wright-opy/src/manifest/probes/keyword-arguments-unsupported.opy +++ /dev/null @@ -1,6 +0,0 @@ -globalvar g - -rule "r": - @Event global - for I in range(start=0, stop=3): - debug(I) diff --git a/crates/wright-opy/src/manifest/probes/member-aliases.opy b/crates/wright-opy/src/manifest/probes/member-aliases.opy deleted file mode 100644 index ab780f2..0000000 --- a/crates/wright-opy/src/manifest/probes/member-aliases.opy +++ /dev/null @@ -1,6 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - @Condition eventPlayer.getCurrentHero() != null - @Condition eventPlayer.hasStatusEffect(Status.BURNING) == false - disableInspector() diff --git a/crates/wright-opy/src/manifest/probes/member-value-in-action-position.opy b/crates/wright-opy/src/manifest/probes/member-value-in-action-position.opy deleted file mode 100644 index 2bc2158..0000000 --- a/crates/wright-opy/src/manifest/probes/member-value-in-action-position.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - eventPlayer.isAlive() diff --git a/crates/wright-opy/src/manifest/probes/probes.json b/crates/wright-opy/src/manifest/probes/probes.json deleted file mode 100644 index 591d3d1..0000000 --- a/crates/wright-opy/src/manifest/probes/probes.json +++ /dev/null @@ -1,313 +0,0 @@ -{ - "schemaVersion": 1, - "probes": [ - { - "diagnosticContains": "Expected a value, but got function 'wait' which is an action", - "expect": "failure", - "id": "action-in-value-position", - "sha256": "9e8f4dbcb58dfe6c2ef69b497785153862558711e0e2406846e92c22b51d04fd", - "source": "action-in-value-position.opy" - }, - { - "expect": "success", - "id": "aliases", - "outputSha256": "a6e298c6cca101812d7471383d3091d3a0873fac8d2193788eaa88797a81eef0", - "sha256": "7137c6c459efee047d49aeb96e3e3324e300098ead933c65653512d91cd8f8ef", - "source": "aliases.opy" - }, - { - "expect": "success", - "id": "builtin-enums", - "outputSha256": "860dd0fdc020122d128715067711004775967820519749ec0e6f14dd6d19ddd1", - "sha256": "f73a456e26707e2b49edbf7a1259ad990455649d78a8ba0710cbaa0bdfc0fe4b", - "source": "builtin-enums.opy" - }, - { - "diagnosticContains": "Unknown function 'createHudText'", - "expect": "failure", - "id": "catalog-only-names", - "sha256": "f5ee4ed1a779991253afa2d4be9685c2f85c837923d88cbf14f3e36296dacb58", - "source": "catalog-only-names.opy" - }, - { - "expect": "failure", - "id": "chase-arg3-keyword-required", - "sha256": "559e04258c77b4e1794dcf2c0cb7a2a8521c762fb3a3d939aedb33c22902067f", - "source": "chase-arg3-keyword-required.opy", - "diagnosticContains": "must be 'rate = xxxx' or 'duration = xxxx'" - }, - { - "expect": "failure", - "id": "chase-duplicate-keyword", - "sha256": "44947d3028149f8507fd9ae5e0b193b5707769bb4810eb4bbc25f3c93a847f7d", - "source": "chase-duplicate-keyword.opy", - "diagnosticContains": "is defined twice" - }, - { - "expect": "success", - "id": "chase-keyword-binding", - "sha256": "ceeb33dca2ae30bdfa7d2b5457f5f17d071b5b4e03910491ce927b3f41308185", - "source": "chase-keyword-binding.opy", - "outputSha256": "54a1e643ee6235f9f8894bc29409690992c96b8e453f46ae09a26073f0e4bd64" - }, - { - "expect": "success", - "id": "chase-keywords", - "sha256": "4b2e31975709e04d5121cc800bd086baf4b8d9e1e78da6074d91ef7300bea906", - "source": "chase-keywords.opy", - "outputSha256": "70e4208094e783ac7f674e927a45aadf0f6184d489dd1c152b2ebb0dad74c51c" - }, - { - "expect": "failure", - "id": "chase-missing-argument", - "sha256": "f4b858aed0c77b02c66c1d96bea0c3a64a34cad670d8bf66dd67d807a6d35476", - "source": "chase-missing-argument.opy", - "diagnosticContains": "Missing argument 'duration'" - }, - { - "expect": "success", - "id": "chase-over-time", - "outputSha256": "1d68ff79d6381ddd8fe81bbc703aa1f1e9df0457b715108d5579fce33c0509c0", - "sha256": "5e6fac560f1f85f635960b45c3dbae72c054c597bcf5bb70b4bcedeaadf06b2c", - "source": "chase-over-time.opy" - }, - { - "expect": "success", - "id": "chase-over-time-defaults", - "outputSha256": "c09b3417002f90231cff3111d2fd3b038ce999aaab4d2aa62c0bd85762239774", - "sha256": "bc3129b756a5e63b92e10c08f9cc38dc6ebc51f32116d4860091ac1f1ae89ace", - "source": "chase-over-time-defaults.opy" - }, - { - "expect": "failure", - "id": "chase-over-time-variable", - "sha256": "932e70be36fdd809801e9dad7d73187b46452da8cfdca79e02c92c911dbaa6ff", - "source": "chase-over-time-variable.opy", - "diagnosticContains": "Expected variable for 1st argument of function 'chaseOverTime'" - }, - { - "expect": "failure", - "id": "chase-positional-after-keyword", - "sha256": "8da563c09bb5d1da4b7fc32f62189a7cb298bd3d6f10184c9097f754f6f61d14", - "source": "chase-positional-after-keyword.opy", - "diagnosticContains": "Cannot use positional arguments after keyword arguments" - }, - { - "expect": "success", - "id": "chase-reeval-context", - "sha256": "42998e1212e7b9941dc0e8317ef12489aa9002cdeabbfb75ea102d9349e25e61", - "source": "chase-reeval-context.opy", - "outputSha256": "24f13ab112521854a5b474fc9e3751b725a93b5ec61309ec90433c1d5a11b532" - }, - { - "expect": "failure", - "id": "chase-reeval-outside", - "sha256": "fc79ad626446c27be49eaf7d950d11c9f6f701d7ddcdf2d74df1b0b79357f17a", - "source": "chase-reeval-outside.opy", - "diagnosticContains": "Unknown member 'NONE' of 'ChaseReeval'" - }, - { - "expect": "failure", - "id": "chase-reeval-wrong-domain", - "sha256": "4daa766f072ba77902f8f387dfec30ad74591626a73d735aa35f972e8d215cc7", - "source": "chase-reeval-wrong-domain.opy", - "diagnosticContains": "Unknown chaseratereeval" - }, - { - "expect": "failure", - "id": "chase-unknown-keyword", - "sha256": "6b959459187346932379288b01cb1b915e13901055bd2eee1b3a80f6b018d348", - "source": "chase-unknown-keyword.opy", - "diagnosticContains": "Unknown keyword argument 'bogus'" - }, - { - "expect": "failure", - "id": "chase-variable-first-arg", - "sha256": "eb8b4348eb425945cbf0499b2cce1a663dec29358fdd2b64c6839c3eae1a7575", - "source": "chase-variable-first-arg.opy", - "diagnosticContains": "Expected variable for 1st argument of function 'chaseAtRate'" - }, - { - "diagnosticContains": "Expected type 'Invis' for the 2nd argument of function '.setInvisibility'", - "expect": "failure", - "id": "enum-arg-non-enum", - "sha256": "c5f41d43a347da13e489bb7cd5699120855016f2c425c9dd3cbc9802809dcdec", - "source": "enum-arg-non-enum.opy" - }, - { - "diagnosticContains": "Expected type 'Invis' for the 2nd argument of function '.setInvisibility'", - "expect": "failure", - "id": "enum-arg-variable", - "sha256": "590d8108e117e11b2c4ca951417346c2f951dc7ed6436890cc7ff26a045715d8", - "source": "enum-arg-variable.opy" - }, - { - "diagnosticContains": "Expected type 'ChaseTimeReeval' for the 4th argument of function 'chaseOverTime'", - "expect": "failure", - "id": "enum-domain-mismatch-1", - "sha256": "e564620792923ff010a31738849eacc40af42ce257bbfe99d95d9036737af5fb", - "source": "enum-domain-mismatch-1.opy" - }, - { - "diagnosticContains": "Expected type 'Invis' for the 2nd argument of function '.setInvisibility'", - "expect": "failure", - "id": "enum-domain-mismatch-2", - "sha256": "482c5164effd7fb2e5a3bcb89385de100d910753371d6c1ea26332284de237f3", - "source": "enum-domain-mismatch-2.opy" - }, - { - "expect": "success", - "id": "enum-gated-members", - "outputSha256": "aea63820d0ebfc177a7a32f2b4af9bc20adfea499509b2952aa3b79b814acaaa", - "sha256": "14d5f9542b07daaf4599aa7ecb27f21e99edadcbee80f76b5f8974b5640511d7", - "source": "enum-gated-members.opy" - }, - { - "expect": "success", - "id": "generic-builtins", - "outputSha256": "29706885620f5e8e270148321989911a29a251f9b326fcc5c8e095da9ca14b8a", - "sha256": "f8cc098038f525941ebccac6fddaed85de3972804c76f97438606a3dee101901", - "source": "generic-builtins.opy" - }, - { - "diagnosticContains": "Unknown function 'setMoveSpeed'", - "expect": "failure", - "id": "generic-member-only-action", - "sha256": "56f2d32fa673102751ef178e2d120fce9a9c9853e25a6ff33df12de9ecca3029", - "source": "generic-member-only-action.opy" - }, - { - "expect": "success", - "id": "get-players-in-radius", - "outputSha256": "36ed8b01ebbcedba64a48742a934398cbc32e31242f876f58b1068e094266673", - "sha256": "357a74741a6f2fe094e68351d046722331471c5d5e4b66dfa0cf97956ee595e5", - "source": "get-players-in-radius.opy" - }, - { - "diagnosticContains": "Missing argument 'duration' for function '.setStatusEffect'", - "expect": "failure", - "id": "invalid-arity-member", - "sha256": "a270a6db6777219ad864c5de8141180516821ff2888d538017476a02c91851fb", - "source": "invalid-arity-member.opy" - }, - { - "diagnosticContains": "Missing argument 'duration' for function 'chaseOverTime'", - "expect": "failure", - "id": "invalid-arity-too-few", - "sha256": "241484f5c57b2fac9b2ef857b3f3e9531b7b090539f840913f668b5dea3d3632", - "source": "invalid-arity-too-few.opy" - }, - { - "diagnosticContains": "Function 'wait' takes 2 arguments, received 3", - "expect": "failure", - "id": "invalid-arity-wait", - "sha256": "0df395c3105137790c3119a91a627ccee631f5db2a91e6172dd15eea425c5122", - "source": "invalid-arity-wait.opy" - }, - { - "diagnosticContains": "Cannot modify or assign to number '3'", - "expect": "failure", - "id": "invalid-receiver-append", - "sha256": "36964992dc3bcb5f37390499376411361227db72a478c2342e9d47ca53382c16", - "source": "invalid-receiver-append.opy" - }, - { - "diagnosticContains": "Expected a string literal for .format(), but got '3'", - "expect": "failure", - "id": "invalid-receiver-format", - "sha256": "b78406bd3690f7f26fbcf40cd79d12d8e7b211fdbf3e8219ab01a81fab66cee8", - "source": "invalid-receiver-format.opy" - }, - { - "expect": "success", - "id": "is-game-in-progress", - "outputSha256": "d6703d63ceb007a823e382b5d7444f4897c7c4d631ff1ee1009c74259dec97a2", - "sha256": "a76edc7c82ee9d6654b7e459f91432371ef3486e422651863c7dab86f1bc1c28", - "source": "is-game-in-progress.opy" - }, - { - "expect": "failure", - "id": "keyword-arguments-unsupported", - "sha256": "2a4b1b11447c4c7be4e02eb39af2e35e9203e2c19cb1e52da6ecc652ee9af88e", - "source": "keyword-arguments-unsupported.opy", - "diagnosticContains": "Unknown function name 'start'" - }, - { - "expect": "success", - "id": "member-aliases", - "outputSha256": "dd250b6bb625584867937f72f477eda6a25789361cbd761a29c3f731c2990835", - "sha256": "a8eeee51f7ab5ffeefaa449c101f780c332e406a19b1521a07fcbaa184896d41", - "source": "member-aliases.opy" - }, - { - "diagnosticContains": "Expected an action, but got function '.isAlive' which is a value", - "expect": "failure", - "id": "member-value-in-action-position", - "sha256": "98ca1546d1baaec19f9dcfc7407143a654da99406a90271d404e0f7ba48bc8f8", - "source": "member-value-in-action-position.opy" - }, - { - "expect": "success", - "id": "range-for-header", - "outputSha256": "6dc18d6b87769152eeaea1fbd47ffb510648776686a6fdd444cae696579cc49b", - "sha256": "6ef7fbb8f9429ea0bbe204550245e7c5a3f985c56cda2d2e199a47b23fa6d64b", - "source": "range-for-header.opy" - }, - { - "diagnosticContains": "Unknown type 'Iterator' of 'range'", - "expect": "failure", - "id": "range-standalone", - "sha256": "c29a8f89f81427c1f70933ebb8e1e7c27757a2b88edf290c5a2561738709b3bb", - "source": "range-standalone.opy" - }, - { - "expect": "success", - "id": "receiver-calls", - "outputSha256": "827a36368c290664087189244f197822790e8874dec3e0c89ca7647c1af0c1a7", - "sha256": "abc9c1f92eb179879da0bc17906bb489b8f764bd89ef7e8af36f3bd4bf3ca968", - "source": "receiver-calls.opy" - }, - { - "diagnosticContains": "Unknown member 'MEMBER' of 'NotARealEnum'", - "expect": "failure", - "id": "unknown-enum", - "sha256": "bf43c69871bbc7d596ee05f7b86e1bf631180609f7b5ed912ce2f34c2c28c8eb", - "source": "unknown-enum.opy" - }, - { - "diagnosticContains": "Unknown function 'frobnicate'", - "expect": "failure", - "id": "unknown-function", - "sha256": "457cff126a3e257f09597f9d3808ece0b695cbdb60fb084ea4a0f0ebccc99a28", - "source": "unknown-function.opy" - }, - { - "diagnosticContains": "Unknown function '.frobnicate'", - "expect": "failure", - "id": "unknown-member", - "sha256": "00eec5e1b1689b880cd24f024be52733910caf270f0fbf0c30433da1cd5a85e1", - "source": "unknown-member.opy" - }, - { - "diagnosticContains": "Unknown function 'frobnicate'", - "expect": "failure", - "id": "unknown-value", - "sha256": "af4d8f8fe85c141e38867a3b5655525b6a886c000151f39b2a49878c38ddb37b", - "source": "unknown-value.opy" - }, - { - "diagnosticContains": "Expected an action, but got function 'isGameInProgress' which is a value", - "expect": "failure", - "id": "value-in-action-position", - "sha256": "eae22cf4b2e2190b3deffd9fe9fa57d49d2cf9882b8635cfa0ad324e17521fc6", - "source": "value-in-action-position.opy" - }, - { - "expect": "failure", - "id": "wait-keyword-names", - "sha256": "275864fc4db1e06955e674ed4656dc8350faf96d3f0877d06d2c9906cfde6ec9", - "source": "wait-keyword-names.opy", - "diagnosticContains": "Unknown keyword argument 'duration'" - } - ] -} diff --git a/crates/wright-opy/src/manifest/probes/range-for-header.opy b/crates/wright-opy/src/manifest/probes/range-for-header.opy deleted file mode 100644 index 805d8b5..0000000 --- a/crates/wright-opy/src/manifest/probes/range-for-header.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g -rule "r": - @Event global - for g in range(3): - debug(g) diff --git a/crates/wright-opy/src/manifest/probes/range-standalone.opy b/crates/wright-opy/src/manifest/probes/range-standalone.opy deleted file mode 100644 index 7082ba6..0000000 --- a/crates/wright-opy/src/manifest/probes/range-standalone.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g -rule "r": - @Event global - @Condition len(range(1, 5, 1)) > 0 - disableInspector() diff --git a/crates/wright-opy/src/manifest/probes/receiver-calls.opy b/crates/wright-opy/src/manifest/probes/receiver-calls.opy deleted file mode 100644 index f1cf1e1..0000000 --- a/crates/wright-opy/src/manifest/probes/receiver-calls.opy +++ /dev/null @@ -1,19 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - @Condition eventPlayer.isAlive() - @Condition eventPlayer.hasSpawned() - @Condition eventPlayer.getHealth() < 50 - eventPlayer.setMoveSpeed(100) - eventPlayer.setMaxHealth(200) - eventPlayer.setHealth(200) - eventPlayer.teleport(eventPlayer.getPosition()) - eventPlayer.setAimSpeed(150) - eventPlayer.setGravity(100) - eventPlayer.setDamageDealt(50) - eventPlayer.setDamageReceived(50) - eventPlayer.setUltCharge(100) - g = eventPlayer - g.setMoveSpeed(50) - [1, 2].append(3) - g.append(4) diff --git a/crates/wright-opy/src/manifest/probes/unknown-enum.opy b/crates/wright-opy/src/manifest/probes/unknown-enum.opy deleted file mode 100644 index 2b02919..0000000 --- a/crates/wright-opy/src/manifest/probes/unknown-enum.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - g = NotARealEnum.MEMBER diff --git a/crates/wright-opy/src/manifest/probes/unknown-function.opy b/crates/wright-opy/src/manifest/probes/unknown-function.opy deleted file mode 100644 index e6e4a49..0000000 --- a/crates/wright-opy/src/manifest/probes/unknown-function.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - frobnicate() diff --git a/crates/wright-opy/src/manifest/probes/unknown-member.opy b/crates/wright-opy/src/manifest/probes/unknown-member.opy deleted file mode 100644 index 245aab6..0000000 --- a/crates/wright-opy/src/manifest/probes/unknown-member.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event eachPlayer - eventPlayer.frobnicate() diff --git a/crates/wright-opy/src/manifest/probes/unknown-value.opy b/crates/wright-opy/src/manifest/probes/unknown-value.opy deleted file mode 100644 index c1f53ed..0000000 --- a/crates/wright-opy/src/manifest/probes/unknown-value.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - g = frobnicate() diff --git a/crates/wright-opy/src/manifest/probes/value-in-action-position.opy b/crates/wright-opy/src/manifest/probes/value-in-action-position.opy deleted file mode 100644 index 23687fb..0000000 --- a/crates/wright-opy/src/manifest/probes/value-in-action-position.opy +++ /dev/null @@ -1,4 +0,0 @@ -globalvar g -rule "r": - @Event global - isGameInProgress() diff --git a/crates/wright-opy/src/manifest/probes/wait-keyword-names.opy b/crates/wright-opy/src/manifest/probes/wait-keyword-names.opy deleted file mode 100644 index 3452a10..0000000 --- a/crates/wright-opy/src/manifest/probes/wait-keyword-names.opy +++ /dev/null @@ -1,5 +0,0 @@ -globalvar g - -rule "r": - @Event global - wait(duration=1) diff --git a/crates/wright-opy/src/parser.rs b/crates/wright-opy/src/parser.rs deleted file mode 100644 index f96e133..0000000 --- a/crates/wright-opy/src/parser.rs +++ /dev/null @@ -1,1386 +0,0 @@ -//! The indentation-aware `.opy` CST parser. -//! -//! Consumes the expanded token stream from [`crate::preprocess`] and builds a -//! [`cst::Program`]. Parsing is deterministic and corpus-backed; malformed -//! input produces structured [`FrontendError`]s rather than panics, and the -//! parser recovers at statement/line boundaries so multiple useful errors are -//! 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::diag::{FrontendError, Position, Span}; -use crate::lexer::{Token, TokenKind}; - -/// The outcome of a parse. -#[derive(Debug, Default)] -pub struct ParseOutput { - /// The parsed program, present only when no errors were collected. - pub program: Option, - /// Every structured error collected during the parse. - pub errors: Vec, -} - -/// Parse an expanded token stream into a CST program. -pub fn parse(tokens: &[Token]) -> ParseOutput { - let mut parser = Parser { - tokens, - pos: 0, - errors: Vec::new(), - }; - let program = parser.parse_program(); - if parser.errors.is_empty() { - ParseOutput { - program: Some(program), - errors: Vec::new(), - } - } else { - ParseOutput { - program: None, - errors: parser.errors, - } - } -} - -struct Parser<'a> { - tokens: &'a [Token], - pos: usize, - errors: Vec, -} - -impl Parser<'_> { - fn peek(&self) -> &Token { - &self.tokens[self.pos.min(self.tokens.len() - 1)] - } - - fn peek_kind(&self) -> TokenKind { - self.peek().kind - } - - fn advance(&mut self) -> Token { - let token = self.tokens[self.pos.min(self.tokens.len() - 1)].clone(); - if self.pos < self.tokens.len() - 1 { - self.pos += 1; - } - token - } - - fn skip_newlines(&mut self) { - while self.peek_kind() == TokenKind::Newline { - self.advance(); - } - } - - fn is_ident(&self, text: &str) -> bool { - self.peek_kind() == TokenKind::Ident && self.peek().text == text - } - - fn expect_ident(&mut self, what: &str) -> Result { - if self.peek_kind() == TokenKind::Ident { - Ok(self.advance().text) - } else { - self.error_at_current(format!("expected {what}")); - Err(()) - } - } - - fn expect(&mut self, kind: TokenKind, what: &str) -> Result { - if self.peek_kind() == kind { - Ok(self.advance()) - } else { - self.error_at_current(format!("expected {what}")); - Err(()) - } - } - - fn error_at_current(&mut self, message: String) { - let span = self.peek().span; - self.errors - .push(FrontendError::at("parse-error", message, span)); - } - - // ---- program ---- - - fn parse_program(&mut self) -> Program { - let mut declarations = Vec::new(); - let mut rules = Vec::new(); - loop { - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof { - break; - } - let ok = self.parse_top_level(&mut declarations, &mut rules); - if !ok { - self.recover_line(); - } - } - Program { - declarations, - rules, - settings: None, - } - } - - fn parse_top_level( - &mut self, - declarations: &mut Vec, - rules: &mut Vec, - ) -> 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), - "globalvar" => return self.parse_variable(declarations, true), - "playervar" => return self.parse_variable(declarations, false), - "subroutine" => return self.parse_subroutine(declarations), - "enum" => return self.parse_enum(declarations), - "macro" => return self.parse_macro(declarations), - _ => {} - } - } - self.error_at_current(format!( - "expected a top-level declaration (rule/def/globalvar/playervar/subroutine/enum/macro) but found '{}'", - token.text - )); - false - } - - /// Skip to the end of the current line (error recovery). - fn recover_line(&mut self) { - while self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { - self.advance(); - } - } - - // ---- declarations ---- - - fn parse_variable(&mut self, declarations: &mut Vec, global: bool) -> bool { - let start = self.advance(); // `globalvar`/`playervar` - // The name token follows the keyword; its span is the exact declared - // identifier occurrence (rename targets, not the keyword/statement). - let name_token = self.peek().clone(); - let name = match self.expect_ident("a variable name after the keyword") { - Ok(name) => name, - Err(()) => return false, - }; - let name_span = if name_token.kind == TokenKind::Ident { - name_token.span - } else { - start.span - }; - let mut index = None; - let mut initializer = None; - if self.peek_kind() == TokenKind::Assign { - self.advance(); - match self.parse_expr() { - Ok(expr) => initializer = Some(expr), - Err(()) => return false, - } - } else if self.peek_kind() == TokenKind::Number { - // `globalvar cakePos 100`: an explicit Workshop variable index. - let token = self.advance(); - index = token.text.parse::().ok(); - if index.is_none() { - self.errors.push(FrontendError::at( - "parse-error", - format!( - "invalid variable index '{}' (expected an integer)", - token.text - ), - token.span, - )); - return false; - } - } else if self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { - self.error_at_current( - "expected '=', an integer index, or end of line after the variable name" - .to_string(), - ); - return false; - } - let end = self.peek().span.start; - let span = Span::new(start.span.file, start.span.start, end); - let decl = if global { - Decl::GlobalVariable { - name, - index, - span, - name_span, - initializer, - } - } else { - Decl::PlayerVariable { - name, - index, - span, - name_span, - initializer, - } - }; - declarations.push(decl); - true - } - - fn parse_subroutine(&mut self, declarations: &mut Vec) -> bool { - let start = self.advance(); - // The name token follows the `subroutine` keyword; its span is the - // exact declared identifier occurrence. - let name_token = self.peek().clone(); - let name = match self.expect_ident("a subroutine name") { - Ok(name) => name, - Err(()) => return false, - }; - let name_span = if name_token.kind == TokenKind::Ident { - name_token.span - } else { - start.span - }; - let end = self.peek().span.start; - declarations.push(Decl::Subroutine { - name, - span: Span::new(start.span.file, start.span.start, end), - name_span, - }); - true - } - - fn parse_enum(&mut self, declarations: &mut Vec) -> bool { - let start = self.advance(); - let name = match self.expect_ident("an enum name") { - Ok(name) => name, - Err(()) => return false, - }; - if self - .expect(TokenKind::Colon, "':' after the enum name") - .is_err() - { - return false; - } - let line_indent = start.span.start.col; - let body_indent = match self.block_indent(line_indent) { - Some(indent) => indent, - None => return false, - }; - let mut members = Vec::new(); - loop { - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent { - break; - } - if self.peek_kind() == TokenKind::Ident { - let member = self.advance(); - let member_span = member.span; - members.push((member.text, member_span)); - } else { - self.error_at_current("expected an enum member name".to_string()); - self.recover_line(); - continue; - } - if self.peek_kind() == TokenKind::Comma { - self.advance(); - } else { - // A member must end the line (or be comma-separated). - if self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof { - self.error_at_current("expected ',' after the enum member".to_string()); - self.recover_line(); - continue; - } - } - } - declarations.push(Decl::Enum { - name, - members, - span: start.span, - }); - true - } - - fn parse_macro(&mut self, declarations: &mut Vec) -> bool { - let start = self.advance(); - let name = match self.expect_ident("a macro name") { - Ok(name) => name, - Err(()) => return false, - }; - let args = match self.parse_param_list() { - Some(args) => args, - None => return false, - }; - if self - .expect(TokenKind::Colon, "':' after the macro signature") - .is_err() - { - return false; - } - let line_indent = start.span.start.col; - let body_indent = match self.block_indent(line_indent) { - Some(indent) => indent, - None => return false, - }; - let body = self.parse_block(body_indent); - declarations.push(Decl::Macro { - name, - args, - body, - span: start.span, - }); - true - } - - fn parse_param_list(&mut self) -> Option> { - if self.expect(TokenKind::LParen, "'('").is_err() { - return None; - } - let mut params = Vec::new(); - self.skip_newlines(); - if self.peek_kind() == TokenKind::RParen { - self.advance(); - return Some(params); - } - loop { - match self.expect_ident("a parameter name") { - Ok(name) => params.push(name), - Err(()) => return None, - } - self.skip_newlines(); - if self.peek_kind() == TokenKind::Comma { - self.advance(); - self.skip_newlines(); - } else { - break; - } - } - if self.expect(TokenKind::RParen, "')'").is_err() { - return None; - } - Some(params) - } - - // ---- rules and definitions ---- - - fn parse_rule(&mut self, rules: &mut Vec) -> bool { - let start = self.advance(); - let name = match self.peek_kind() { - TokenKind::String => self.advance().text, - _ => { - self.error_at_current("expected a rule name string after `rule`".to_string()); - return false; - } - }; - let name_token_span = self.tokens[self.pos.saturating_sub(1)].span; - // The exact rule-name occurrence is the string content between the - // quotes (the `"name"` token itself spans the quotes). - let name_span = Span::new( - name_token_span.file, - Position::new(name_token_span.start.line, name_token_span.start.col + 1), - Position::new( - name_token_span.end.line, - name_token_span - .end - .col - .saturating_sub(1) - .max(name_token_span.start.col + 1), - ), - ); - if self - .expect(TokenKind::Colon, "':' after the rule name") - .is_err() - { - return false; - } - let line_indent = start.span.start.col; - let body_indent = match self.block_indent(line_indent) { - Some(indent) => indent, - None => return false, - }; - let mut event = None; - let mut conditions = Vec::new(); - let mut actions = Vec::new(); - loop { - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent { - break; - } - if self.peek_kind() == TokenKind::At { - if !self.parse_directive(&mut event, &mut conditions) { - self.recover_line(); - } - continue; - } - match self.parse_statement() { - Ok(stmt) => actions.push(stmt), - Err(()) => self.recover_line(), - } - } - rules.push(RuleEntry::Rule(Rule { - name, - span: Span::new(start.span.file, start.span.start, name_token_span.end), - name_span, - disabled: false, - event: event.unwrap_or_else(|| Event { - name: "global".to_string(), - args: Vec::new(), - span: start.span, - }), - conditions, - actions, - })); - true - } - - fn parse_directive(&mut self, event: &mut Option, conditions: &mut Vec) -> bool { - let at = self.advance(); - let name = match self.expect_ident("a directive name after '@'") { - Ok(name) => name, - Err(()) => 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 mut args = Vec::new(); - if self.peek_kind() == TokenKind::LParen - && self.parse_event_args(&mut args).is_err() - { - return false; - } - let end = self.peek().span.start; - *event = Some(Event { - name: event_name, - args, - span: Span::new(at.span.file, at.span.start, end), - }); - true - } - "Condition" => match self.parse_expr() { - Ok(expr) => { - conditions.push(expr); - true - } - 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" - )); - return false; - } - true - } - other => { - self.error_at_current(format!("unsupported directive '@{other}'")); - false - } - } - } - - fn parse_def(&mut self, rules: &mut Vec) -> 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 - // occurrence (rename targets, not the keyword). - let name_token = self.peek().clone(); - let name = match self.expect_ident("a subroutine name after `def`") { - Ok(name) => name, - Err(()) => return false, - }; - let name_span = if name_token.kind == TokenKind::Ident { - name_token.span - } else { - start.span - }; - let params = match self.parse_param_list() { - Some(params) => params, - None => return false, - }; - if !params.is_empty() { - self.error_at_current( - "subroutine parameters are outside the declared support matrix".to_string(), - ); - return false; - } - if self - .expect(TokenKind::Colon, "':' after the subroutine signature") - .is_err() - { - return false; - } - let line_indent = start.span.start.col; - let body_indent = match self.block_indent(line_indent) { - Some(indent) => indent, - None => return false, - }; - 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) - } else { - start.span - }; - rules.push(RuleEntry::SubroutineDef { - name, - span, - name_span, - body, - }); - true - } - - /// The indentation of the next non-empty line, which must exceed - /// `line_indent` (an indented block follows the colon). - fn block_indent(&mut self, line_indent: u32) -> Option { - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof { - self.error_at_current("expected an indented block".to_string()); - return None; - } - let indent = self.peek().span.start.col; - if indent <= line_indent { - self.error_at_current("expected an indented block after ':'".to_string()); - return None; - } - Some(indent) - } - - // ---- statements ---- - - fn parse_block(&mut self, block_indent: u32) -> Vec { - let mut stmts = Vec::new(); - loop { - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof { - break; - } - if self.peek().span.start.col < block_indent { - break; - } - if self.peek().span.start.col > block_indent { - // A deeper indent without an introducer: recover by line. - self.error_at_current("unexpected indentation".to_string()); - self.recover_line(); - continue; - } - match self.parse_statement() { - Ok(stmt) => stmts.push(stmt), - Err(()) => self.recover_line(), - } - } - stmts - } - - fn parse_statement(&mut self) -> Result { - let token = self.peek(); - if token.kind == TokenKind::Ident { - match token.text.as_str() { - "if" => return self.parse_if(), - "for" => return self.parse_for(), - "while" => return self.parse_while(), - "pass" => { - let start = self.advance(); - return Ok(Stmt::Pass { span: start.span }); - } - _ => {} - } - } - self.parse_expr_statement() - } - - fn parse_expr_statement(&mut self) -> Result { - let start = self.peek().span; - let expr = self.parse_expr()?; - match self.peek_kind() { - TokenKind::Assign => { - self.advance(); - let value = self.parse_expr()?; - let end = self.peek().span.start; - Ok(Stmt::Assign { - target: expr, - value, - span: Span::new(start.file, start.start, end), - }) - } - TokenKind::PlusAssign - | TokenKind::MinusAssign - | TokenKind::StarAssign - | TokenKind::SlashAssign - | TokenKind::DoubleSlashAssign - | TokenKind::PercentAssign => { - let op = match self.peek_kind() { - TokenKind::PlusAssign => "+", - TokenKind::MinusAssign => "-", - TokenKind::StarAssign => "*", - TokenKind::SlashAssign => "/", - TokenKind::DoubleSlashAssign => "//", - TokenKind::PercentAssign => "%", - _ => unreachable!(), - } - .to_string(); - self.advance(); - let rhs = self.parse_expr()?; - let end = self.peek().span.start; - let value = Expr::Binary { - op, - left: Box::new(expr.clone()), - right: Box::new(rhs), - span: Span::new(start.file, start.start, end), - }; - Ok(Stmt::Assign { - target: expr, - value, - span: Span::new(start.file, start.start, end), - }) - } - _ => { - let end = self.peek().span.start; - Ok(Stmt::Expr { - expr, - span: Span::new(start.file, start.start, end), - }) - } - } - } - - fn parse_if(&mut self) -> Result { - let start = self.advance(); - let line_indent = start.span.start.col; - let condition = self.parse_expr()?; - if self - .expect(TokenKind::Colon, "':' after the if condition") - .is_err() - { - return Err(()); - } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); - let mut branches = vec![IfBranch { condition, body }]; - let mut r#else = None; - loop { - let save = self.pos; - self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col != line_indent { - self.pos = save; - break; - } - if self.is_ident("elif") { - self.advance(); - let condition = match self.parse_expr() { - Ok(expr) => expr, - Err(()) => return Err(()), - }; - if self - .expect(TokenKind::Colon, "':' after the elif condition") - .is_err() - { - return Err(()); - } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); - branches.push(IfBranch { condition, body }); - } else if self.is_ident("else") { - self.advance(); - if self.expect(TokenKind::Colon, "':' after `else`").is_err() { - return Err(()); - } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); - r#else = Some(body); - break; - } else { - self.pos = save; - break; - } - } - Ok(Stmt::If { - branches, - r#else, - span: start.span, - }) - } - - fn parse_for(&mut self) -> Result { - let start = self.advance(); - let variable = self.parse_expr()?; - if !self.is_ident("in") { - self.error_at_current("expected `in` in the for statement".to_string()); - return Err(()); - } - self.advance(); - let iterable = self.parse_expr()?; - if self - .expect(TokenKind::Colon, "':' after the for header") - .is_err() - { - return Err(()); - } - let line_indent = start.span.start.col; - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); - Ok(Stmt::For { - variable, - iterable, - body, - span: start.span, - }) - } - - fn parse_while(&mut self) -> Result { - let start = self.advance(); - let condition = self.parse_expr()?; - if self - .expect(TokenKind::Colon, "':' after the while condition") - .is_err() - { - return Err(()); - } - let line_indent = start.span.start.col; - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); - Ok(Stmt::While { - condition, - body, - span: start.span, - }) - } - - // ---- expressions ---- - - fn parse_expr(&mut self) -> Result { - self.parse_or() - } - - fn parse_or(&mut self) -> Result { - let mut left = self.parse_and()?; - while self.is_ident("or") { - self.advance(); - let right = self.parse_and()?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op: "or".to_string(), - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn parse_and(&mut self) -> Result { - let mut left = self.parse_not()?; - while self.is_ident("and") { - self.advance(); - let right = self.parse_not()?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op: "and".to_string(), - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn parse_not(&mut self) -> Result { - if self.is_ident("not") { - let start = self.advance(); - let operand = self.parse_not()?; - let end = operand.span().end; - return Ok(Expr::Unary { - op: "not".to_string(), - operand: Box::new(operand), - span: Span::new(start.span.file, start.span.start, end), - }); - } - self.parse_comparison() - } - - fn parse_comparison(&mut self) -> Result { - let mut left = self.parse_additive()?; - loop { - let op = match self.peek_kind() { - TokenKind::Eq => "==", - TokenKind::Ne => "!=", - TokenKind::Lt => "<", - TokenKind::Le => "<=", - TokenKind::Gt => ">", - TokenKind::Ge => ">=", - _ => break, - }; - self.advance(); - let right = self.parse_additive()?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op: op.to_string(), - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn parse_additive(&mut self) -> Result { - let mut left = self.parse_multiplicative()?; - loop { - let op = match self.peek_kind() { - TokenKind::Plus => "+", - TokenKind::Minus => "-", - _ => break, - }; - self.advance(); - let right = self.parse_multiplicative()?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op: op.to_string(), - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn parse_multiplicative(&mut self) -> Result { - let mut left = self.parse_unary()?; - loop { - let op = match self.peek_kind() { - TokenKind::Star => "*", - TokenKind::Slash => "/", - TokenKind::DoubleSlash => "//", - TokenKind::Percent => "%", - _ => break, - }; - self.advance(); - let right = self.parse_unary()?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op: op.to_string(), - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn parse_unary(&mut self) -> Result { - if self.peek_kind() == TokenKind::Minus { - let start = self.advance(); - let operand = self.parse_unary()?; - let end = operand.span().end; - return Ok(Expr::Unary { - op: "-".to_string(), - operand: Box::new(operand), - span: Span::new(start.span.file, start.span.start, end), - }); - } - self.parse_power() - } - - fn parse_power(&mut self) -> Result { - let base = self.parse_postfix()?; - if self.peek_kind() == TokenKind::DoubleStar { - self.advance(); - // Right-associative. - let exponent = self.parse_unary()?; - let span = Span::new(base.span().file, base.span().start, exponent.span().end); - return Ok(Expr::Binary { - op: "**".to_string(), - left: Box::new(base), - right: Box::new(exponent), - span, - }); - } - Ok(base) - } - - fn parse_postfix(&mut self) -> Result { - let mut base = self.parse_primary()?; - loop { - match self.peek_kind() { - TokenKind::LParen => { - let mut args = Vec::new(); - self.parse_call_args(&mut args)?; - let end = self.tokens[self.pos.saturating_sub(1)].span.end; - base = match base { - Expr::Name { name, span } => Expr::Call { - name, - args, - span: Span::new(span.file, span.start, end), - }, - Expr::Member { - receiver, - member, - span, - } => Expr::ReceiverCall { - receiver, - name: member, - args, - span: Span::new(span.file, span.start, end), - }, - _other => { - self.errors.push(FrontendError::at( - "parse-error", - "cannot call this expression".to_string(), - self.peek().span, - )); - return Err(()); - } - }; - } - TokenKind::LBracket => { - self.advance(); - let index = self.parse_expr()?; - let end = match self.expect(TokenKind::RBracket, "']'") { - Ok(token) => token.span.end, - Err(()) => return Err(()), - }; - let span = Span::new(base.span().file, base.span().start, end); - base = Expr::Index { - array: Box::new(base), - index: Box::new(index), - span, - }; - } - TokenKind::Dot => { - self.advance(); - let member = match self.expect_ident("a member name after '.'") { - Ok(member) => member, - Err(()) => return Err(()), - }; - let end = self.tokens[self.pos.saturating_sub(1)].span.end; - let span = Span::new(base.span().file, base.span().start, end); - base = Expr::Member { - receiver: Box::new(base), - member, - span, - }; - } - _ => break, - } - } - Ok(base) - } - - /// `@Event name(args)`: positional expressions only (keyword arguments - /// are a call-argument form, not an event form). - fn parse_event_args(&mut self, args: &mut Vec) -> Result<(), ()> { - self.expect(TokenKind::LParen, "'('")?; - self.skip_newlines(); - if self.peek_kind() == TokenKind::RParen { - self.advance(); - return Ok(()); - } - loop { - let expr = self.parse_expr()?; - if self.peek_kind() == TokenKind::Assign { - self.error_at_current("keyword arguments are not valid in @Event".to_string()); - return Err(()); - } - args.push(expr); - self.skip_newlines(); - if self.peek_kind() == TokenKind::Comma { - self.advance(); - self.skip_newlines(); - if self.peek_kind() == TokenKind::RParen { - break; - } - } else { - break; - } - } - self.expect(TokenKind::RParen, "')'")?; - Ok(()) - } - - fn parse_call_args(&mut self, args: &mut Vec) -> Result<(), ()> { - self.expect(TokenKind::LParen, "'('")?; - self.skip_newlines(); - if self.peek_kind() == TokenKind::RParen { - self.advance(); - return Ok(()); - } - loop { - match self.parse_expr() { - Ok(expr) => { - // A keyword argument is `name = expr` (issue #110): a - // bare identifier immediately followed by `=`. Anything - // else (`expr = ...`) is not a call argument form and is - // rejected like the pinned reference rejects it. - if self.peek_kind() == TokenKind::Assign { - let Expr::Name { name, span } = expr else { - self.error_at_current( - "expected a keyword name before '=' in this call".to_string(), - ); - return Err(()); - }; - self.advance(); - let value = match self.parse_expr() { - Ok(value) => value, - Err(()) => return Err(()), - }; - args.push(CallArg { - keyword: Some((name, span)), - value, - }); - } else { - args.push(CallArg { - keyword: None, - value: expr, - }); - } - } - Err(()) => return Err(()), - } - self.skip_newlines(); - if self.peek_kind() == TokenKind::Comma { - self.advance(); - self.skip_newlines(); - if self.peek_kind() == TokenKind::RParen { - break; - } - } else { - break; - } - } - self.expect(TokenKind::RParen, "')'")?; - Ok(()) - } - - fn parse_primary(&mut self) -> Result { - let token = self.peek(); - match token.kind { - TokenKind::Number => { - let token = self.advance(); - let value: f64 = token.text.parse().unwrap_or(f64::NAN); - Ok(Expr::Number { - value, - text: token.text.clone(), - span: token.span, - }) - } - TokenKind::String => { - let token = self.advance(); - Ok(Expr::String { - value: token.text.clone(), - span: token.span, - }) - } - TokenKind::Ident => { - let token = self.advance(); - match token.text.as_str() { - "true" => Ok(Expr::Bool { - value: true, - span: token.span, - }), - "false" => Ok(Expr::Bool { - value: false, - span: token.span, - }), - "None" | "null" => Ok(Expr::Null { span: token.span }), - _ => Ok(Expr::Name { - name: token.text.clone(), - span: token.span, - }), - } - } - TokenKind::LParen => { - self.advance(); - let expr = self.parse_expr()?; - self.expect(TokenKind::RParen, "')'")?; - Ok(expr) - } - TokenKind::LBracket => { - let open = self.advance(); - let mut elements = Vec::new(); - self.skip_newlines(); - if self.peek_kind() == TokenKind::RBracket { - let end = self.advance().span.end; - return Ok(Expr::Array { - elements, - span: Span::new(open.span.file, open.span.start, end), - }); - } - loop { - match self.parse_expr() { - Ok(expr) => elements.push(expr), - Err(()) => return Err(()), - } - self.skip_newlines(); - if self.peek_kind() == TokenKind::Comma { - self.advance(); - self.skip_newlines(); - if self.peek_kind() == TokenKind::RBracket { - break; - } - } else { - break; - } - } - let end = match self.expect(TokenKind::RBracket, "']'") { - Ok(token) => token.span.end, - Err(()) => return Err(()), - }; - Ok(Expr::Array { - elements, - span: Span::new(open.span.file, open.span.start, end), - }) - } - _ => { - self.error_at_current(format!("expected an expression but found '{}'", token.text)); - Err(()) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::lexer::{LexInput, lex}; - - fn parse_ok(text: &str) -> Program { - let tokens = lex(LexInput { file_id: 0, text }).unwrap(); - let output = parse(&tokens); - assert!( - output.errors.is_empty(), - "unexpected errors: {:?}", - output.errors - ); - output.program.unwrap() - } - - fn parse_err(text: &str) -> Vec { - let tokens = lex(LexInput { file_id: 0, text }).unwrap(); - parse(&tokens).errors - } - - #[test] - fn parses_basic_rule() { - let program = parse_ok("rule \"setup\":\n @Event global\n disableInspector()\n"); - assert_eq!(program.rules.len(), 1); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected rule"); - }; - assert_eq!(rule.name, "setup"); - assert_eq!(rule.event.name, "global"); - assert_eq!(rule.actions.len(), 1); - } - - #[test] - fn parses_control_flow() { - let program = parse_ok( - "globalvar index = 0\n\nrule \"r\":\n @Event global\n for index in range(3):\n if index == 0:\n debug(index)\n elif index == 1:\n debug(index)\n else:\n debug(index)\n while index < 3:\n index += 1\n wait()\n", - ); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!(); - }; - assert!(matches!(rule.actions[0], Stmt::For { .. })); - let Stmt::For { body, .. } = &rule.actions[0] else { - panic!(); - }; - let Stmt::If { - branches, r#else, .. - } = &body[0] - else { - panic!(); - }; - assert_eq!(branches.len(), 2); - assert!(r#else.is_some()); - let Stmt::While { body, .. } = &rule.actions[1] else { - panic!(); - }; - assert_eq!(body.len(), 2); - } - - #[test] - fn parses_multi_line_array() { - let program = parse_ok( - "globalvar p\nrule \"r\":\n @Event global\n p = [\n vect(1, 0, 0),\n vect(2, 0, 0),\n ]\n", - ); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!(); - }; - let Stmt::Assign { value, .. } = &rule.actions[0] else { - panic!(); - }; - let Expr::Array { elements, .. } = value else { - panic!("expected array, got {value:?}"); - }; - assert_eq!(elements.len(), 2); - } - - #[test] - fn missing_colon_is_a_structured_error() { - let errors = parse_err("rule \"x\"\n @Event global\n"); - assert!(!errors.is_empty()); - assert_eq!(errors[0].code, "parse-error"); - assert!(errors[0].span.is_some()); - } - - #[test] - fn def_and_macro_parse() { - let program = parse_ok( - "subroutine showStatus\n\ndef showStatus():\n print(\"hi\")\n\nmacro double(value):\n value + value\n", - ); - assert_eq!(program.declarations.len(), 2); - assert!(matches!(program.declarations[1], Decl::Macro { .. })); - let Decl::Macro { args, body, .. } = &program.declarations[1] else { - panic!(); - }; - assert_eq!(args, &vec!["value".to_string()]); - assert_eq!(body.len(), 1); - } - - #[test] - fn multiple_errors_are_reported() { - let errors = - parse_err("rule \"a\"\n bad statement here\nrule \"b\"\n @Event global\n"); - assert!(!errors.is_empty()); - } - - #[test] - fn precedence_parses_python_like() { - let program = parse_ok("globalvar x\nrule \"r\":\n @Event global\n x = 1 + 2 * 3\n"); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!(); - }; - let Stmt::Assign { value, .. } = &rule.actions[0] else { - panic!(); - }; - let Expr::Binary { - op, left, right, .. - } = value - else { - panic!(); - }; - assert_eq!(op, "+"); - let Expr::Binary { op: inner, .. } = right.as_ref() else { - panic!(); - }; - assert_eq!(inner, "*"); - assert!(matches!(left.as_ref(), Expr::Number { .. })); - } - - #[test] - fn parses_receiver_calls() { - // `eventPlayer.setMoveSpeed(100)` is a receiver call: postfix `.` - // member access followed by call arguments (#104). - let program = - parse_ok("rule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n"); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected rule"); - }; - let Stmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement, got {:?}", rule.actions[0]); - }; - let Expr::ReceiverCall { - receiver, - name, - args, - .. - } = &expr - else { - panic!("expected receiver call, got {expr:?}"); - }; - assert_eq!(name, "setMoveSpeed"); - assert!( - matches!(receiver.as_ref(), Expr::Name { name, .. } if name == "eventPlayer"), - "receiver must be the eventPlayer name" - ); - assert_eq!(args.len(), 1); - assert!(args[0].keyword.is_none(), "positional argument"); - assert!(matches!(&args[0].value, Expr::Number { .. })); - } - - #[test] - fn parses_keyword_arguments_with_name_spans() { - // `name = expr` call arguments are keyword arguments carrying the - // name token's exact span (issue #110); comparisons stay positional. - let program = - parse_ok("rule \"r\":\n @Event global\n wait(time=1)\n debug(g == 1)\n"); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected rule"); - }; - let Stmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement"); - }; - let Expr::Call { args, .. } = expr else { - panic!("expected a call, got {expr:?}"); - }; - let (keyword, span) = args[0].keyword.as_ref().expect("keyword argument"); - assert_eq!(keyword, "time"); - assert_eq!(span.start.line, 3); - assert!(matches!(&args[0].value, Expr::Number { .. })); - - let Stmt::Expr { expr, .. } = &rule.actions[1] else { - panic!("expected expression statement"); - }; - let Expr::Call { args, .. } = expr else { - panic!("expected a call, got {expr:?}"); - }; - assert!(args[0].keyword.is_none(), "comparisons are not keywords"); - assert!(matches!(&args[0].value, Expr::Binary { .. })); - } - - #[test] - fn non_name_keyword_lhs_is_a_parse_error() { - // `f(1 = 2)` is not a call argument form; rejected explicitly. - let errors = parse_err("rule \"r\":\n @Event global\n debug(1 = 2)\n"); - assert!(!errors.is_empty()); - assert_eq!(errors[0].code, "parse-error"); - } - - #[test] - fn parses_member_call_on_call_result() { - // `getPlayersInRadius(...).setStatusEffect(...)`: member access - // followed by call arguments on a call result stays a receiver call. - let program = parse_ok( - "rule \"r\":\n @Event eachPlayer\n getPlayersInRadius(eventPlayer, 10).setStatusEffect(eventPlayer, 30)\n", - ); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected rule"); - }; - let Stmt::Expr { expr, .. } = &rule.actions[0] else { - panic!("expected expression statement"); - }; - let Expr::ReceiverCall { - receiver, - name, - args, - .. - } = &expr - else { - panic!("expected receiver call, got {expr:?}"); - }; - assert_eq!(name, "setStatusEffect"); - assert!( - matches!(receiver.as_ref(), Expr::Call { name, .. } if name == "getPlayersInRadius"), - "receiver must be the preceding call" - ); - assert_eq!(args.len(), 2); - } - - #[test] - fn member_without_call_is_not_a_call() { - // `eventPlayer.moveSpeed` alone (no parentheses) stays a member - // access; only a following `(` turns it into a receiver call. - let program = - parse_ok("rule \"r\":\n @Event eachPlayer\n x = eventPlayer.moveSpeed\n"); - let RuleEntry::Rule(rule) = &program.rules[0] else { - panic!("expected rule"); - }; - let Stmt::Assign { value, .. } = &rule.actions[0] else { - panic!("expected assignment"); - }; - assert!(matches!( - &value, - Expr::Member { member, .. } if member == "moveSpeed" - )); - } -} diff --git a/crates/wright-opy/src/preprocess.rs b/crates/wright-opy/src/preprocess.rs deleted file mode 100644 index 55f74bd..0000000 --- a/crates/wright-opy/src/preprocess.rs +++ /dev/null @@ -1,728 +0,0 @@ -//! `.opy` preprocessing: includes, `#!define` macros, and expansion. -//! -//! Operates at the token level, matching the reference frontend's observable -//! behavior: `#!include "file.opy"` splices the included file's tokens at the -//! directive site; `#!define NAME value` and `#!define name(args) value` -//! register macros that expand at their use sites, recursively (a macro may -//! reference earlier macros). The output is a single-file token stream whose -//! spans point at use sites, mirroring the reference adapter's provenance -//! convention (the HIR file registry keeps the main file). Invalid include -//! graphs (cycles, missing files) and recursive defines fail deterministically -//! with structured diagnostics that name the offending file/line. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use crate::diag::{FrontendError, FrontendResult, Span}; -use crate::lexer::{LexInput, Token, TokenKind, lex}; -use crate::settings::SettingsBlock; - -/// A recorded preprocessing define (HIR provenance). -#[derive(Debug, Clone, PartialEq)] -pub struct DefineRecord { - pub name: String, - pub is_function: bool, - pub span: Option, -} - -/// The result of preprocessing. -#[derive(Debug, Clone)] -pub struct Preprocessed { - /// The expanded, single-file token stream. - pub tokens: Vec, - /// The recorded defines in definition order. - pub defines: Vec, - /// The top-of-file `settings { ... }` block, when present (#86). - pub settings: Option, -} - -/// The output file registry: the main file only (reference convention). -#[derive(Debug, Clone, PartialEq)] -pub struct FileRecord { - pub id: u32, - pub path: String, -} - -/// Preprocess the main source text with its include root. -pub fn preprocess( - main_text: &str, - main_path: &str, - root: &Path, -) -> FrontendResult<(Preprocessed, Vec)> { - preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new()) -} - -/// Preprocess with open-document overlays: includes resolve to overlay text -/// (keyed by the include string or the resolved canonical path) before the -/// filesystem. Overlays model unsaved editor buffers without changing the -/// compiler's source-loading contract. -pub fn preprocess_with_overlay( - main_text: &str, - main_path: &str, - root: &Path, - overlay: &BTreeMap, -) -> FrontendResult<(Preprocessed, Vec)> { - preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result -} - -/// The outcome of preprocessing with overlays, retaining the file registry -/// registered so far even when a directive or expansion fails, so callers can -/// map an error's span file id to its actual source. -pub struct PreprocessOutcome { - pub result: FrontendResult<(Preprocessed, Vec)>, - pub files: Vec, -} - -/// Preprocess with open-document overlays while retaining the file registry -/// registered so far on failure. -pub fn preprocess_with_overlay_outcome( - main_text: &str, - main_path: &str, - root: &Path, - overlay: &BTreeMap, -) -> PreprocessOutcome { - let mut pre = Preprocessor { - files: vec![FileRecord { - id: 0, - path: main_path.to_string(), - }], - next_file_id: 1, - root: root.to_path_buf(), - overlay: overlay.clone(), - include_stack: Vec::new(), - macros: Vec::new(), - defines: Vec::new(), - }; - // 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) { - Ok(mut blocks) => blocks.pop(), - Err(error) => { - return PreprocessOutcome { - result: Err(error), - files: pre.files, - }; - } - }; - let tokens = match &settings { - Some(block) => { - let sanitized = crate::settings::sanitize_for_lex(main_text, block); - lex(LexInput { - file_id: 0, - text: &sanitized, - }) - } - None => lex(LexInput { - file_id: 0, - text: main_text, - }), - }; - let mut tokens = match tokens { - Ok(tokens) => tokens, - Err(error) => { - return PreprocessOutcome { - result: Err(error), - files: pre.files, - }; - } - }; - if let Err(error) = pre.process_directives(&mut tokens) { - return PreprocessOutcome { - result: Err(error), - files: pre.files, - }; - } - match pre.expand(tokens) { - Ok(tokens) => { - let result = Ok(( - Preprocessed { - tokens, - defines: pre.defines, - settings, - }, - pre.files.clone(), - )); - PreprocessOutcome { - result, - files: pre.files, - } - } - Err(error) => PreprocessOutcome { - result: Err(error), - files: pre.files, - }, - } -} - -struct Preprocessor { - files: Vec, - next_file_id: u32, - root: PathBuf, - overlay: BTreeMap, - include_stack: Vec, - macros: Vec, - defines: Vec, -} - -/// A registered macro: object-like or function-like. -struct MacroDef { - name: String, - params: Vec, - body: Vec, - /// True when the body came from a `#!define name(args) value` form. - is_function: bool, -} - -impl Preprocessor { - /// Process `#!` directive tokens, splicing includes and registering - /// defines. Non-directive tokens are kept in place. - fn process_directives(&mut self, tokens: &mut Vec) -> FrontendResult<()> { - let mut out: Vec = Vec::with_capacity(tokens.len()); - for token in tokens.drain(..) { - if token.kind == TokenKind::Directive { - self.handle_directive(token, &mut out)?; - } else { - out.push(token); - } - } - *tokens = out; - Ok(()) - } - - 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 rest = rest.trim(); - let include = rest - .strip_prefix('"') - .and_then(|r| r.strip_suffix('"')) - .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\''))); - let Some(include) = include else { - return Err(FrontendError::at( - "include-invalid", - format!( - "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)" - ), - span, - )); - }; - self.include(include, span, out)?; - return Ok(()); - } - if let Some(rest) = text.strip_prefix("define") { - self.define(rest.trim(), span)?; - return Ok(()); - } - if let Some(rest) = text.strip_prefix("undef") { - let name = rest.trim(); - self.macros.retain(|m| m.name != name); - return Ok(()); - } - Err(FrontendError::at( - "unsupported-directive", - format!("unsupported preprocessing directive `#!{text}`"), - span, - )) - } - - /// Resolve, lex, and splice one included file. - fn include(&mut self, include: &str, span: Span, out: &mut Vec) -> FrontendResult<()> { - // The include base is the root; the main file is the only file in the - // registry (reference convention), so path resolution is root-based. - let candidate = self.root.join(include); - let canonical = std::fs::canonicalize(&candidate).ok(); - // An open-document overlay (an unsaved editor buffer) takes - // precedence over the filesystem. Overlays are keyed by the include - // string and by the resolved canonical path, so both spellings work. - let overlay_text = self - .overlay - .get(include) - .or_else(|| { - canonical - .as_ref() - .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned())) - }) - .cloned(); - - // The include-cycle identity: the canonical path when the file exists, - // otherwise the candidate path (overlays may not have a disk backing). - let identity = canonical.clone().unwrap_or_else(|| candidate.clone()); - if self.include_stack.contains(&identity) { - return Err(FrontendError::at( - "include-cycle", - format!( - "include cycle detected: '{}' is already being included", - identity.display() - ), - span, - )); - } - - let text = match overlay_text { - Some(text) => text, - None => { - let canonical = canonical.ok_or_else(|| { - FrontendError::at( - "include-not-found", - format!( - "cannot find included file '{include}' under root '{}'", - self.root.display() - ), - span, - ) - })?; - std::fs::read_to_string(&canonical).map_err(|error| { - FrontendError::at( - "include-not-found", - format!("cannot read included file '{include}': {error}"), - span, - ) - })? - } - }; - // Each include registers a file in the registry (reference behavior). - let file_id = self.next_file_id; - self.next_file_id += 1; - self.files.push(FileRecord { - id: file_id, - path: include.to_string(), - }); - self.include_stack.push(identity); - // 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). - match crate::settings::find_blocks(&text, file_id) { - Err(error) => return Err(error), - Ok(blocks) if !blocks.is_empty() => { - return Err(FrontendError::at( - "settings-placement", - "settings blocks are only supported in the main file".to_string(), - blocks[0].keyword_span, - )); - } - Ok(_) => {} - } - let mut included = lex(LexInput { - file_id, - text: &text, - })?; - self.process_directives(&mut included)?; - // Drop the included file's Eof token (it terminates the file, not - // the spliced stream). - included.retain(|token| token.kind != TokenKind::Eof); - // Included tokens keep their real positions so the parser's - // indentation model works; span comparison is normalized away by the - // differential suite. File identity beyond the main file is preserved - // in diagnostics (include cycles/not-found name the real path). - out.extend(included); - self.include_stack.pop(); - Ok(()) - } - - /// Register one `#!define` (object- or function-like). - /// - /// A define is function-like when `(` immediately follows the name - /// (`cakeBeam(start, end)`); a parenthesized object-like value - /// (`#!define X (a + b)`) keeps its parentheses as value tokens. - fn define(&mut self, rest: &str, span: Span) -> FrontendResult<()> { - let rest = rest.trim(); - let first_open = rest.find('(').unwrap_or(usize::MAX); - let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX); - let is_function_like = first_open < first_space; - - let (name, params, body_text) = if is_function_like { - let name = rest[..first_open].trim(); - let Some(close) = rest[first_open..].find(')') else { - return Err(FrontendError::at( - "define-invalid", - format!("malformed function-like define `#!define {rest}`: missing `)`"), - span, - )); - }; - let close = first_open + close; - let params: Vec = rest[first_open + 1..close] - .split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect(); - let body = rest[close + 1..].trim(); - (name.to_string(), params, body.to_string()) - } else { - let name = rest[..first_space].trim(); - let body = if first_space == usize::MAX { - String::new() - } else { - rest[first_space..].trim().to_string() - }; - (name.to_string(), Vec::new(), body) - }; - if name.is_empty() { - return Err(FrontendError::at( - "define-invalid", - "malformed `#!define` directive: missing macro name", - span, - )); - } - let body_tokens = lex(LexInput { - file_id: span.file, - text: &body_text, - })?; - // Drop the trailing EOF token from the value. - let body_tokens: Vec = body_tokens - .into_iter() - .filter(|t| t.kind != TokenKind::Eof) - .collect(); - let is_function = !params.is_empty(); - self.defines.push(DefineRecord { - name: name.clone(), - is_function, - span: Some(span), - }); - self.macros.push(MacroDef { - name, - params, - body: body_tokens, - is_function, - }); - Ok(()) - } - - /// Expand all macros across the token stream, recursively. - fn expand(&self, tokens: Vec) -> FrontendResult> { - let mut out = Vec::new(); - let mut index = 0; - while index < tokens.len() { - let token = &tokens[index]; - if token.kind == TokenKind::Ident { - let name = token.text.clone(); - if let Some(mac) = self.macros.iter().find(|m| m.name == name) { - if mac.is_function { - // Expect `(` args `)` immediately after the name. - let cursor = index + 1; - if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen { - let (args, after) = self.collect_args(&tokens, cursor)?; - let mut expanded = self.expand_macro(mac, args, token.span)?; - self.expand_into(&mut expanded, &mut Vec::new(), 0)?; - out.append(&mut expanded); - index = after; - continue; - } - // A function-like macro used without arguments: leave - // the name as an ordinary identifier. - out.push(token.clone()); - index += 1; - continue; - } - let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?; - self.expand_into(&mut expanded, &mut Vec::new(), 0)?; - out.append(&mut expanded); - index += 1; - continue; - } - } - out.push(token.clone()); - index += 1; - } - Ok(out) - } - - /// Collect the argument token lists of a function-like macro call, - /// returning `(args, index_after_closing_paren)`. - fn collect_args( - &self, - tokens: &[Token], - open: usize, - ) -> FrontendResult<(Vec>, usize)> { - let mut args: Vec> = Vec::new(); - let mut current: Vec = Vec::new(); - let mut depth = 0usize; - let mut cursor = open + 1; - while cursor < tokens.len() { - let kind = tokens[cursor].kind; - if kind == TokenKind::LParen { - depth += 1; - current.push(tokens[cursor].clone()); - } else if kind == TokenKind::RParen { - if depth == 0 { - args.push(std::mem::take(&mut current)); - return Ok((args, cursor + 1)); - } - depth -= 1; - current.push(tokens[cursor].clone()); - } else if kind == TokenKind::Comma && depth == 0 { - args.push(std::mem::take(&mut current)); - } else { - current.push(tokens[cursor].clone()); - } - cursor += 1; - } - Err(FrontendError::new( - "macro-invalid", - "unterminated macro invocation: missing closing `)`", - )) - } - - /// Substitute macro params with the call arguments and re-stamp the - /// resulting tokens to the use site. - /// Substitute macro params with the call arguments and stamp every - /// expanded token with the use-site span. - /// - /// Expanded tokens share the use-site span: the differential suite - /// normalizes spans away, and stamping the whole expansion with one - /// monotonic span keeps downstream span validation trivially valid. - fn expand_macro( - &self, - mac: &MacroDef, - args: Vec>, - use_site: Span, - ) -> FrontendResult> { - if mac.is_function && args.len() != mac.params.len() { - return Err(FrontendError::at( - "macro-arity", - format!( - "macro '{}' expects {} argument(s) but got {}", - mac.name, - mac.params.len(), - args.len() - ), - use_site, - )); - } - let mut out = Vec::new(); - for token in &mac.body { - if mac.is_function - && token.kind == TokenKind::Ident - && mac.params.iter().any(|p| p == &token.text) - { - let param_index = mac - .params - .iter() - .position(|p| p == &token.text) - .expect("checked above"); - let mut replacement = args.get(param_index).cloned().unwrap_or_default(); - for replacement_token in &mut replacement { - replacement_token.span = use_site; - } - out.extend(replacement); - } else { - let mut token = token.clone(); - token.span = use_site; - out.push(token); - } - } - Ok(out) - } - - /// Recursively expand macros inside an already-expanded run, guarding - /// against direct recursion. - fn expand_into( - &self, - tokens: &mut Vec, - stack: &mut Vec, - depth: usize, - ) -> FrontendResult<()> { - if depth > 64 { - return Err(FrontendError::new( - "macro-recursion", - "macro expansion exceeded the recursion limit (possible recursive define)", - )); - } - let mut out: Vec = Vec::with_capacity(tokens.len()); - let mut index = 0; - while index < tokens.len() { - let token = &tokens[index]; - if token.kind == TokenKind::Ident { - let name = token.text.clone(); - if let Some(mac) = self.macros.iter().find(|m| m.name == name) { - if stack.iter().any(|s| s == &name) { - return Err(FrontendError::new( - "macro-recursion", - format!("recursive macro expansion detected for '{name}'"), - )); - } - if mac.is_function { - if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen { - let (args, after) = self.collect_args(tokens, index)?; - let mut expanded = self.expand_macro(mac, args, token.span)?; - stack.push(name.clone()); - self.expand_into(&mut expanded, stack, depth + 1)?; - stack.pop(); - out.append(&mut expanded); - index = after; - continue; - } - out.push(token.clone()); - index += 1; - continue; - } - let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?; - stack.push(name.clone()); - self.expand_into(&mut expanded, stack, depth + 1)?; - stack.pop(); - out.append(&mut expanded); - index += 1; - continue; - } - } - out.push(token.clone()); - index += 1; - } - *tokens = out; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn object_define_expands_at_use_site() { - let (pre, _) = preprocess( - "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n", - "main.opy", - Path::new("."), - ) - .unwrap(); - assert_eq!(pre.defines.len(), 1); - assert_eq!(pre.defines[0].name, "SIDE"); - assert!(!pre.defines[0].is_function); - let numbers: Vec<&str> = pre - .tokens - .iter() - .filter(|t| t.kind == TokenKind::Number) - .map(|t| t.text.as_str()) - .collect(); - assert_eq!(numbers, vec!["1.5"]); - } - - #[test] - fn function_define_substitutes_params() { - let (pre, _) = preprocess( - "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n", - "main.opy", - Path::new("."), - ) - .unwrap(); - let numbers: Vec<&str> = pre - .tokens - .iter() - .filter(|t| t.kind == TokenKind::Number) - .map(|t| t.text.as_str()) - .collect(); - assert_eq!(numbers, vec!["3", "3"]); - } - - #[test] - fn recursive_defines_expand_transitively() { - let (pre, _) = preprocess( - "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n", - "main.opy", - Path::new("."), - ) - .unwrap(); - let numbers: Vec<&str> = pre - .tokens - .iter() - .filter(|t| t.kind == TokenKind::Number) - .map(|t| t.text.as_str()) - .collect(); - assert_eq!(numbers, vec!["2", "1"]); - } - - #[test] - fn recursive_define_fails_structurally() { - let error = preprocess( - "#!define X X + 1\nrule \"r\":\n x = X\n", - "main.opy", - Path::new("."), - ) - .unwrap_err(); - assert_eq!(error.code, "macro-recursion"); - } - - #[test] - fn missing_include_is_structured() { - let error = preprocess( - "#!include \"nope.opy\"\n", - "main.opy", - Path::new("/nonexistent-root"), - ) - .unwrap_err(); - assert_eq!(error.code, "include-not-found"); - assert!(error.span.is_some()); - } - - #[test] - fn include_cycle_is_detected() { - let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap(); - std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap(); - let main = std::fs::read_to_string(dir.join("a.opy")).unwrap(); - let error = preprocess(&main, "a.opy", &dir).unwrap_err(); - assert_eq!(error.code, "include-cycle"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn unsupported_directive_is_structured() { - let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err(); - assert_eq!(error.code, "unsupported-directive"); - } - - #[test] - fn settings_block_is_extracted_before_lexing() { - let (pre, _) = preprocess( - "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n", - "main.opy", - Path::new("."), - ) - .unwrap(); - let block = pre.settings.expect("settings block extracted"); - assert!(block.text.contains("gamemodes")); - // The block never enters the token stream. - assert!( - !pre.tokens.iter().any(|t| t.text.contains("gamemodes")), - "settings content must not be lexed" - ); - } - - #[test] - fn settings_in_include_is_rejected() { - let dir = - std::env::temp_dir().join(format!("wright-opy-settings-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("shared.opy"), - "settings {\n \"gamemodes\": {}\n}\n", - ) - .unwrap(); - let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n"; - let error = preprocess(main, "main.opy", &dir).unwrap_err(); - assert_eq!(error.code, "settings-placement"); - assert_eq!( - error.span.unwrap().file, - 1, - "the span names the included file" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn dict_literal_braces_still_lex_error() { - // Scoped settings lexing must not mask expression-level braces: - // meipocalypse-style dict literals keep failing as a lex-error. - let error = preprocess( - "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n", - "main.opy", - Path::new("."), - ) - .unwrap_err(); - assert_eq!(error.code, "lex-error"); - assert!(error.message.contains("unexpected character '{'")); - assert_eq!(error.span.unwrap().start.line, 2); - } -} diff --git a/crates/wright-opy/src/reconstruct.rs b/crates/wright-opy/src/reconstruct.rs deleted file mode 100644 index d37b7f6..0000000 --- a/crates/wright-opy/src/reconstruct.rs +++ /dev/null @@ -1,2576 +0,0 @@ -//! Workshop IR → OPY reconstruction (issue #124). -//! -//! Consumes a validated [`workshop_rs::wir::Program`] and emits deterministic, -//! byte-stable canonical OPY source that the native [`crate::compile`] -//! frontend accepts and that re-lowers to a structurally equivalent WIR -//! program under `workshop_rs::roundtrip::equivalent`. -//! -//! Scope and ownership: -//! -//! * Builtin action/value/member/enum identities resolve only through the -//! OPY semantic compatibility manifest ([`Manifest`]) and the Workshop -//! catalog ([`Catalog`]) — no new content/signature tables and no invented -//! OPY syntax. -//! * Reconstructed OPY is simple low-level valid OPY: it does not recover -//! comments, macros, functions, or source abstractions. Names must be valid -//! OPY identifiers; calls must use the OPY source names the manifest -//! declares (`len`, `wait`, `playEffect`, …), because the frontend's own -//! lowering stamps those names into the recompiled WIR and the equivalence -//! contract compares them exactly. -//! * Every WIR construct the frontend cannot recompile identically is -//! rejected with a structured [`ReconstructIssue`] naming the construct — -//! never partial or misleading OPY. This includes the per-player loop -//! form, disabled rules, arbitrary-player variable targets, negative and -//! non-finite number literals (the OPY lexer has no negative-literal -//! token), Workshop-spelled call names with no manifest source form -//! (`add`, `countOf`, `createBeamEffect`, …), enums outside the manifest's -//! declared domains, `Remove From Array` modifies, calls the frontend -//! lowers to dedicated nodes (`debug`, `print`, `append`, `vect`), and any -//! rule layout the frontend's deterministic re-lowering cannot reproduce -//! (non-leading initializer rules, out-of-table-order subroutine rules, -//! unsorted global slots, non-canonical subroutine indices). -//! * `debug`/`print` actions, arrays, vectors, and `format` are emitted in -//! their OPY source forms (`debug(x)`, `print(x)`, `[...]`, `vect(x, y, z)`, -//! `"text".format(...)`) from the dedicated WIR nodes; they are reachable -//! from OPY-derived WIR and are covered by direct unit tests (no Workshop -//! text spells them). -//! -//! Pipeline: [`reconstruct`] validates the table layout, then emits the -//! declarations (variables, subroutines), the `def` bodies, and the rules in -//! deterministic arena order. Any issue collected anywhere fails the whole -//! reconstruction with all collected diagnostics. - -use std::fmt; - -use workshop_rs::catalog::{Catalog, Locale}; -use workshop_rs::source::Span; -use workshop_rs::wir::{self, Action, Event, ModifyOp, Value}; - -use crate::manifest::{Function, FunctionKind, Manifest}; - -/// A structured reconstruction diagnostic naming one non-representable WIR -/// construct. Stable `code`, a human-readable `message`, and the offending -/// source span when the WIR carries one. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReconstructIssue { - pub code: &'static str, - pub message: String, - pub span: Option, -} - -/// All reconstruction failures for one program, in deterministic arena -/// order. The emitter never returns partial output: a non-empty issue list -/// means no OPY was produced. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReconstructError { - pub issues: Vec, -} - -impl fmt::Display for ReconstructError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (index, issue) in self.issues.iter().enumerate() { - if index > 0 { - writeln!(f)?; - } - let location = match issue.span { - Some(span) => format!(" at {}:{}", span.start.line, span.start.col), - None => String::new(), - }; - write!(f, "{}: {}{location}", issue.code, issue.message)?; - } - Ok(()) - } -} - -impl std::error::Error for ReconstructError {} - -/// Reconstruct a validated WIR program into deterministic OPY source. -/// -/// Resolves builtin identities through the built-in OPY semantic manifest -/// and the built-in Workshop catalog (`en-US`), the declared surface for -/// reconstruction (issue #124). Returns an error carrying every -/// non-representable construct diagnostic when the program cannot be -/// reconstructed. -pub fn reconstruct(program: &wir::Program) -> Result { - let manifest = match Manifest::builtin() { - Ok(manifest) => manifest, - Err(error) => { - return Err(ReconstructError { - issues: vec![ReconstructIssue { - code: "manifest-error", - message: format!( - "cannot load the OPY semantic compatibility manifest: {error}" - ), - span: None, - }], - }); - } - }; - let catalog = match Catalog::builtin() { - Ok(catalog) => catalog, - Err(error) => { - return Err(ReconstructError { - issues: vec![ReconstructIssue { - code: "catalog-error", - message: format!("cannot load the Workshop catalog: {error}"), - span: None, - }], - }); - } - }; - reconstruct_with(program, manifest, &catalog, &Locale::new("en-US")) -} - -/// The context-sensitive form of [`reconstruct`]: resolves identities through -/// the supplied manifest and catalog. The locale selects the catalog -/// spellings used for cross-checks (reconstruction emits OPY, which is -/// locale-independent; `en-US` is the catalog's declared surface). -pub fn reconstruct_with( - program: &wir::Program, - manifest: &Manifest, - catalog: &Catalog, - locale: &Locale, -) -> Result { - let mut emitter = Emitter::new(program, manifest, catalog, locale); - emitter.run(); - if emitter.issues.is_empty() { - Ok(emitter.out) - } else { - Err(ReconstructError { - issues: emitter.issues, - }) - } -} - -/// OPY names the parser treats as keywords or literals; a WIR table name that -/// collides with one of these can never be referenced or declared faithfully. -const RESERVED_NAMES: &[&str] = &[ - "true", - "false", - "None", - "null", - "eventPlayer", - "rule", - "def", - "globalvar", - "playervar", - "subroutine", - "enum", - "macro", - "if", - "for", - "while", - "pass", - "elif", - "else", - "in", - "and", - "or", - "not", -]; - -/// Whether `name` is a valid OPY identifier (the lexer's identifier rule). -fn is_opy_identifier(name: &str) -> bool { - let mut chars = name.chars(); - let Some(first) = chars.next() else { - return false; - }; - (first.is_ascii_alphabetic() || first == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Binary operator spellings the OPY frontend lowers to `Value::Call`s with -/// the same name (source operators, not Workshop spellings like `add`). -const BINARY_OPS: &[&str] = &[ - "+", "-", "*", "/", "%", "**", "==", "!=", "<", "<=", ">", ">=", "and", "or", -]; - -/// Call names the frontend lowers to dedicated WIR nodes (never `Call`s). -const DEDICATED_ACTION_NAMES: &[&str] = &["debug", "print", "append"]; -const DEDICATED_VALUE_NAMES: &[&str] = &["vect", "range", "chase"]; - -struct Emitter<'a> { - program: &'a wir::Program, - manifest: &'a Manifest, - catalog: &'a Catalog, - locale: &'a Locale, - issues: Vec, - out: String, - /// Subroutine names, for call-vs-subroutine ambiguity checks. - subroutine_names: std::collections::HashSet, -} - -/// The canonical rule layout the frontend's re-lowering reproduces. -struct RuleLayout<'a> { - /// The leading "Initialize global variables" rule, converted to - /// declaration initializers. - global_init: Option>, - /// The leading "Initialize player variables" rule, converted to - /// declaration initializers. - player_init: Option>, - /// Subroutine-body rules (defs), in subroutine table order. - sub_rules: Vec<&'a wir::Rule>, - /// Everything else, in input order. - normal_rules: Vec<&'a wir::Rule>, -} - -impl<'a> Emitter<'a> { - fn new( - program: &'a wir::Program, - manifest: &'a Manifest, - catalog: &'a Catalog, - locale: &'a Locale, - ) -> Self { - let subroutine_names = program - .subroutines - .iter() - .map(|subroutine| subroutine.name.clone()) - .collect(); - Emitter { - program, - manifest, - catalog, - locale, - issues: Vec::new(), - out: String::new(), - subroutine_names, - } - } - - fn run(&mut self) { - self.validate_tables(); - if self.issues.is_empty() { - let layout = self.classify_rules(); - if self.issues.is_empty() { - self.emit_program(&layout); - } - } - } - // ---- diagnostics ---- - - fn issue(&mut self, code: &'static str, message: impl Into, span: Option) { - self.issues.push(ReconstructIssue { - code, - message: message.into(), - span, - }); - } - - // ---- table validation ---- - - fn validate_tables(&mut self) { - if self.program.settings.is_some() { - self.issue( - "unsupported-settings", - "custom-game-settings are outside the reconstruction surface", - None, - ); - } - // Global table: unique names, valid OPY identifiers, non-decreasing - // slot order (the frontend's re-lowering sorts the table by index, - // so only slot-ordered input reproduces the same table). - let mut previous_index: Option = None; - for (position, variable) in self.program.global_variables.iter().enumerate() { - self.check_variable_name(variable.name.as_str(), variable.span, "global variable"); - self.check_duplicate_name( - variable.name.as_str(), - position, - "global variable", - variable.span, - ); - if let Some(previous) = previous_index { - if variable.index < previous { - self.issue( - "unsupported-global-order", - format!( - "global variables must be in ascending index order \ - (slot {} precedes slot {})", - previous, variable.index - ), - variable.span, - ); - } - } - previous_index = Some(variable.index); - } - // Player table: unique names and valid identifiers; player slots are - // explicit in the `playervar name ` form, so no order rule. - for (position, variable) in self.program.player_variables.iter().enumerate() { - self.check_variable_name(variable.name.as_str(), variable.span, "player variable"); - self.check_duplicate_name( - variable.name.as_str(), - position, - "player variable", - variable.span, - ); - } - // Subroutine table: unique names, valid identifiers, and indices - // exactly equal to table position (the OPY `subroutine name` - // declaration cannot carry an index; the re-lowered index is the - // table position). - for (position, subroutine) in self.program.subroutines.iter().enumerate() { - self.check_variable_name(subroutine.name.as_str(), subroutine.span, "subroutine"); - self.check_duplicate_name( - subroutine.name.as_str(), - position, - "subroutine", - subroutine.span, - ); - if subroutine.index as usize != position { - self.issue( - "unsupported-subroutine-index", - format!( - "subroutine '{}' has index {} but the OPY surface requires \ - table position {} (subroutine declarations cannot carry an index)", - subroutine.name, subroutine.index, position - ), - subroutine.span, - ); - } - } - } - - fn check_variable_name(&mut self, name: &str, span: Option, kind: &str) { - if !is_opy_identifier(name) { - self.issue( - "unsupported-name", - format!( - "{kind} name '{name}' is not a valid OPY identifier on the \ - reconstruction surface" - ), - span, - ); - } else if RESERVED_NAMES.contains(&name) { - self.issue( - "unsupported-name", - format!( - "{kind} name '{name}' collides with an OPY keyword or literal \ - and cannot be referenced on the reconstruction surface" - ), - span, - ); - } - } - - /// Whether a name repeats an earlier entry of its table (duplicates - /// cannot be declared or referenced faithfully on the OPY surface). - fn check_duplicate_name( - &mut self, - name: &str, - position: usize, - kind: &str, - span: Option, - ) { - let duplicate = match kind { - "global variable" => self - .program - .global_variables - .iter() - .enumerate() - .take(position) - .any(|(_, other)| other.name == name), - "player variable" => self - .program - .player_variables - .iter() - .enumerate() - .take(position) - .any(|(_, other)| other.name == name), - _ => self - .program - .subroutines - .iter() - .enumerate() - .take(position) - .any(|(_, other)| other.name == name), - }; - if duplicate { - self.issue( - "unsupported-duplicate-name", - format!("duplicate {kind} name '{name}'"), - span, - ); - } - } - - /// The canonical rule layout: optional leading initializer rules, then - /// all subroutine-body rules in subroutine table order, then the normal - /// rules. Any other arrangement cannot be reproduced by the frontend's - /// deterministic re-lowering and is rejected. - fn classify_rules(&mut self) -> RuleLayout<'a> { - let rules: Vec<&wir::Rule> = self.program.rules.iter().collect(); - let mut index = 0; - let mut global_init = None; - let mut player_init = None; - if let Some(rule) = rules.first() { - if rule.name == "Initialize global variables" { - global_init = self.canonical_init(rule, true); - index = 1; - } else if rule.name == "Initialize player variables" { - player_init = self.canonical_init(rule, false); - index = 1; - } - } - if index == 1 { - if let Some(rule) = rules.get(1) { - if rule.name == "Initialize player variables" && global_init.is_some() { - player_init = self.canonical_init(rule, false); - index = 2; - } - } - } - - let mut sub_rules = Vec::new(); - let mut normal_rules = Vec::new(); - let mut in_sub_rules = true; - for rule in rules.iter().copied().skip(index) { - match &rule.event { - Event::Subroutine(_) => { - if !in_sub_rules { - self.issue( - "unsupported-rule-order", - format!( - "subroutine-body rule '{}' appears after a normal rule; \ - the frontend re-lowering emits subroutine rules first", - rule.name - ), - rule.span, - ); - } - if !rule.conditions.is_empty() { - self.issue( - "unsupported-rule-order", - format!( - "subroutine-body rule '{}' carries conditions; `def` \ - bodies cannot express them", - rule.name - ), - rule.span, - ); - } - sub_rules.push(rule); - } - _ => { - in_sub_rules = false; - normal_rules.push(rule); - } - } - } - - // Subroutine rules must be in subroutine table order, and each rule - // must carry the exact name the re-lowering synthesizes for its def. - let mut expected = 0usize; - for rule in &sub_rules { - let Event::Subroutine(subroutine) = &rule.event else { - continue; - }; - if subroutine.index() != expected { - self.issue( - "unsupported-rule-order", - format!( - "subroutine-body rules must appear in subroutine table order; \ - '{}' is out of order", - rule.name - ), - rule.span, - ); - } - expected += 1; - if let Some(definition) = self.program.subroutines.get(*subroutine) { - let expected_name = format!("Subroutine {}", definition.name); - if rule.name != expected_name { - self.issue( - "unsupported-rule-order", - format!( - "subroutine-body rule name '{}' does not match the def \ - form '{}' the frontend synthesizes", - rule.name, expected_name - ), - rule.span, - ); - } - } - } - - RuleLayout { - global_init, - player_init, - sub_rules, - normal_rules, - } - } - - /// Validate a leading initializer rule: exactly the synthesized shape - /// (name, event, empty conditions, all-Set actions). Returns the action - /// ids to convert into declaration initializers, or records an issue. - fn canonical_init(&mut self, rule: &wir::Rule, global: bool) -> Option> { - let expected_name = if global { - "Initialize global variables" - } else { - "Initialize player variables" - }; - if !rule.conditions.is_empty() { - self.issue( - "unsupported-init-rule", - format!( - "initializer rule '{expected_name}' carries conditions; the \ - frontend synthesizes it from declarations with none" - ), - rule.span, - ); - return None; - } - let mut actions = Vec::with_capacity(rule.actions.len()); - for action in &rule.actions { - let Some(node) = self.program.actions.get(*action) else { - self.issue("unsupported-dangling", "dangling action id", rule.span); - return None; - }; - let set = matches!( - (global, node), - (true, Action::SetGlobalVariable { .. }) - | (false, Action::SetPlayerVariable { .. }) - ); - if !set { - self.issue( - "unsupported-init-rule", - format!( - "initializer rule '{expected_name}' mixes non-Set actions; \ - the frontend's synthesized initializer rule is all-Set" - ), - node.span(), - ); - return None; - } - actions.push(*action); - } - Some(actions) - } - - // ---- emission ---- - - fn emit_program(&mut self, layout: &RuleLayout) { - let global_initializers = self.collect_global_initializers(&layout.global_init); - let player_initializers = self.collect_player_initializers(&layout.player_init); - self.check_initializer_slot(&global_initializers); - - // Declarations. - for (position, variable) in self.program.global_variables.iter().enumerate() { - self.out.push_str("globalvar "); - self.out.push_str(&variable.name); - match global_initializers.get(&position) { - Some(value) => { - self.out.push_str(" = "); - self.emit_initializer(*value); - } - None => { - self.out.push(' '); - self.out.push_str(&variable.index.to_string()); - } - } - self.out.push('\n'); - } - for (position, variable) in self.program.player_variables.iter().enumerate() { - self.out.push_str("playervar "); - self.out.push_str(&variable.name); - match player_initializers.get(&position) { - Some(value) => { - self.out.push_str(" = "); - self.emit_initializer(*value); - } - None => { - self.out.push(' '); - self.out.push_str(&variable.index.to_string()); - } - } - self.out.push('\n'); - } - if self.program.subroutines.is_empty() { - self.out.push('\n'); - } else { - for subroutine in self.program.subroutines.iter() { - self.out.push_str("subroutine "); - self.out.push_str(&subroutine.name); - self.out.push('\n'); - } - self.out.push('\n'); - } - - // Subroutine bodies. - for rule in &layout.sub_rules { - let Event::Subroutine(subroutine) = &rule.event else { - continue; - }; - let Some(definition) = self.program.subroutines.get(*subroutine) else { - continue; - }; - self.out.push_str("def "); - self.out.push_str(&definition.name); - self.out.push_str("():\n"); - self.emit_actions(&rule.actions, 1); - self.out.push('\n'); - } - - // Rules. - for rule in &layout.normal_rules { - if rule.disabled { - self.issue( - "unsupported-disabled-rule", - format!( - "rule '{}' is disabled; the OPY surface cannot express it", - rule.name - ), - rule.span, - ); - continue; - } - if rule.actions.is_empty() { - continue; - } - self.out.push_str("rule \""); - self.out.push_str(&rule.name); - self.out.push_str("\":\n"); - match &rule.event { - Event::Global => self.out.push_str(" @Event global\n"), - Event::EachPlayer => self.out.push_str(" @Event eachPlayer\n"), - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - } => self.out.push_str(" @Event eachPlayer\n"), - Event::EachPlayerWithFilters { .. } | Event::Player { .. } => { - self.issue( - "unsupported-rule-event", - format!("rule '{}' uses an event outside the OPY surface", rule.name), - rule.span, - ); - continue; - } - Event::Subroutine(_) => { - self.issue( - "unsupported-rule-order", - format!( - "rule '{}' has a subroutine event outside the def layout", - rule.name - ), - rule.span, - ); - continue; - } - } - for condition in &rule.conditions { - self.out.push_str(" @Condition "); - self.emit_value(*condition); - self.out.push('\n'); - } - self.emit_actions(&rule.actions, 1); - self.out.push('\n'); - } - } - - /// Map initializer rule actions onto declaration positions (table order), - /// validating the rule's Sets are in table order like the frontend's - /// synthesized initializer rule. - fn collect_global_initializers( - &mut self, - actions: &Option>, - ) -> std::collections::HashMap { - let mut initializers = std::collections::HashMap::new(); - let Some(actions) = actions else { - return initializers; - }; - let mut previous: Option = None; - for action in actions { - let span = self - .program - .actions - .get(*action) - .and_then(|node| node.span()); - let Some(Action::SetGlobalVariable { - variable, value, .. - }) = self.program.actions.get(*action) - else { - continue; - }; - let variable_position = variable.index(); - let name = self - .program - .global_variables - .get(*variable) - .map(|variable| variable.name.clone()) - .unwrap_or_default(); - if let Some(previous_position) = previous { - if variable_position <= previous_position { - self.issue( - "unsupported-init-rule", - format!( - "initializer rule Sets '{name}' out of global table order; \ - the frontend synthesizes initializers in declaration order" - ), - span, - ); - } - } - previous = Some(variable_position); - initializers.insert(variable_position, *value); - } - initializers - } - - fn collect_player_initializers( - &mut self, - actions: &Option>, - ) -> std::collections::HashMap { - let mut initializers = std::collections::HashMap::new(); - let Some(actions) = actions else { - return initializers; - }; - let mut previous: Option = None; - for action in actions { - let span = self - .program - .actions - .get(*action) - .and_then(|node| node.span()); - let Some(Action::SetPlayerVariable { - player, - variable, - value, - .. - }) = self.program.actions.get(*action) - else { - continue; - }; - if !self.is_event_player(*player) { - self.issue( - "unsupported-init-rule", - "player initializer targets a non-event-player expression", - span, - ); - } - let variable_position = variable.index(); - let name = self - .program - .player_variables - .get(*variable) - .map(|variable| variable.name.clone()) - .unwrap_or_default(); - if let Some(previous_position) = previous { - if variable_position <= previous_position { - self.issue( - "unsupported-init-rule", - format!( - "initializer rule Sets '{name}' out of player table order; \ - the frontend synthesizes initializers in declaration order" - ), - span, - ); - } - } - previous = Some(variable_position); - initializers.insert(variable_position, *value); - } - initializers - } - - /// A declaration initializer: same value emission, but zero literals are - /// spelled `0.0` because the frontend drops integer-`0` initializers - /// (matching the reference adapter). - fn emit_initializer(&mut self, value: wir::ValueId) { - let Some(node) = self.program.values.get(value) else { - self.issue("unsupported-dangling", "dangling value id", None); - return; - }; - if let Value::Number { value: number, .. } = &node.value { - if *number == 0.0 { - self.out.push_str("0.0"); - return; - } - } - self.emit_value(value); - } - - /// The OPY declaration `globalvar name = value` cannot carry an explicit - /// slot, so the frontend re-lowering assigns the lowest free slot. An - /// initializer-bearing global is only representable when that slot equals - /// its WIR index; otherwise the reconstructed table would differ. - fn check_initializer_slot( - &mut self, - initializers: &std::collections::HashMap, - ) { - let mut taken: std::collections::HashSet = std::collections::HashSet::new(); - for (position, variable) in self.program.global_variables.iter().enumerate() { - if initializers.contains_key(&position) { - let mut next_free = 0u32; - while taken.contains(&next_free) { - next_free += 1; - } - if next_free != variable.index { - self.issues.push(ReconstructIssue { - code: "unsupported-indexed-initializer", - message: format!( - "initializer-bearing global '{}' occupies slot {} but the \ - OPY `globalvar name = value` form assigns the lowest free \ - slot ({}) on re-lowering", - variable.name, variable.index, next_free - ), - span: variable.span, - }); - } - taken.insert(next_free); - } else { - taken.insert(variable.index); - } - } - } - - fn emit_actions(&mut self, actions: &[wir::ActionId], level: usize) { - for action in actions { - self.emit_action(*action, level); - } - } - - fn indent(level: usize) -> String { - " ".repeat(level) - } - - fn emit_action(&mut self, id: wir::ActionId, level: usize) { - let Some(node) = self.program.actions.get(id) else { - self.issue("unsupported-dangling", "dangling action id", None); - return; - }; - let span = node.span(); - let indent = Self::indent(level); - match node { - Action::SetGlobalVariable { - variable, value, .. - } => { - let variable_id = *variable; - let Some(variable) = self.program.global_variables.get(variable_id) else { - self.issue("unsupported-dangling", "dangling global variable id", span); - return; - }; - if self.set_has_modify_pattern(*value, variable_id.index(), true) { - self.issue( - "unsupported-set-binary", - format!( - "Set Global Variable('{}', ) \ - re-lowers to a Modify action; emit the modify form", - variable.name - ), - span, - ); - return; - } - self.out.push_str(&indent); - self.out.push_str(&variable.name); - self.out.push_str(" = "); - self.emit_value(*value); - self.out.push('\n'); - } - Action::ModifyGlobalVariable { - variable, - op, - value, - .. - } => { - let Some(variable) = self.program.global_variables.get(*variable) else { - self.issue("unsupported-dangling", "dangling global variable id", span); - return; - }; - self.emit_modify(level, &variable.name, *op, *value, span); - } - Action::SetPlayerVariable { - player, - variable, - value, - .. - } => { - let variable_id = *variable; - let Some(variable) = self.program.player_variables.get(variable_id) else { - self.issue("unsupported-dangling", "dangling player variable id", span); - return; - }; - if !self.is_event_player(*player) { - self.issue( - "unsupported-arbitrary-player-target", - "Set Player Variable targets a non-event-player expression; \ - the OPY surface only exposes eventPlayer.member" - .to_string(), - span, - ); - return; - } - if self.set_has_modify_pattern(*value, variable_id.index(), false) { - self.issue( - "unsupported-set-binary", - format!( - "Set Player Variable('{}', ) \ - re-lowers to a Modify action; emit the modify form", - variable.name - ), - span, - ); - return; - } - self.out.push_str(&indent); - self.out.push_str("eventPlayer."); - self.out.push_str(&variable.name); - self.out.push_str(" = "); - self.emit_value(*value); - self.out.push('\n'); - } - Action::ModifyPlayerVariable { - player, - variable, - op, - value, - .. - } => { - let Some(variable) = self.program.player_variables.get(*variable) else { - self.issue("unsupported-dangling", "dangling player variable id", span); - return; - }; - if !self.is_event_player(*player) { - self.issue( - "unsupported-arbitrary-player-target", - "Modify Player Variable targets a non-event-player expression; \ - the OPY surface only exposes eventPlayer.member" - .to_string(), - span, - ); - return; - } - self.emit_modify( - level, - &format!("eventPlayer.{}", variable.name), - *op, - *value, - span, - ); - } - Action::AssignMember { span, .. } => { - self.issue( - "unsupported-member-assignment", - "dynamic member assignments are outside the OPY reconstruction surface", - *span, - ); - } - Action::CallSubroutine { - subroutine, span, .. - } => { - let Some(subroutine) = self.program.subroutines.get(*subroutine) else { - self.issue("unsupported-dangling", "dangling subroutine id", *span); - return; - }; - self.out.push_str(&indent); - self.out.push_str(&subroutine.name); - self.out.push_str("()\n"); - } - Action::If { - branches, - else_body, - span, - } => { - for (index, branch) in branches.iter().enumerate() { - let keyword = if index == 0 { "if" } else { "elif" }; - self.out.push_str(&indent); - self.out.push_str(keyword); - self.out.push(' '); - self.emit_value(branch.condition); - self.out.push_str(":\n"); - self.emit_actions(&branch.body, level + 1); - } - if let Some(else_body) = else_body { - self.out.push_str(&indent); - self.out.push_str("else:\n"); - self.emit_actions(else_body, level + 1); - } - let _ = span; - } - Action::While { - condition, - body, - span, - } => { - self.out.push_str(&indent); - self.out.push_str("while "); - self.emit_value(*condition); - self.out.push_str(":\n"); - self.emit_actions(body, level + 1); - let _ = span; - } - Action::ForGlobalVariable { - variable, - start, - stop, - step, - body, - span, - .. - } => { - let Some(variable) = self.program.global_variables.get(*variable) else { - self.issue("unsupported-dangling", "dangling loop variable id", *span); - return; - }; - self.out.push_str(&indent); - self.out.push_str("for "); - self.out.push_str(&variable.name); - self.out.push_str(" in range("); - self.emit_value(*start); - self.out.push_str(", "); - self.emit_value(*stop); - self.out.push_str(", "); - self.emit_value(*step); - self.out.push_str("):\n"); - self.emit_actions(body, level + 1); - } - Action::ForPlayerVariable { span, .. } => { - self.issue( - "unsupported-per-player-loop", - "For Player Variable is outside the reconstruction surface \ - (the OPY `for` form binds a global variable)", - *span, - ); - } - Action::Debug { value, span } => { - self.out.push_str(&indent); - self.out.push_str("debug("); - self.emit_value(*value); - self.out.push_str(")\n"); - let _ = span; - } - Action::Print { message, span } => { - self.out.push_str(&indent); - self.out.push_str("print("); - self.emit_value(*message); - self.out.push_str(")\n"); - let _ = span; - } - Action::Call { name, args, span } => { - self.emit_call_action(name, args, &indent, *span); - } - } - } - - /// `x = x v` (or the player form) re-lowers to a Modify action, so a - /// Set whose value matches the pattern cannot be reconstructed as a Set. - fn set_has_modify_pattern( - &self, - value: wir::ValueId, - variable_index: usize, - global: bool, - ) -> bool { - let Some(node) = self.program.values.get(value) else { - return false; - }; - let Value::Call { name, args } = &node.value else { - return false; - }; - if !matches!(name.as_str(), "+" | "-" | "*" | "/" | "%" | "**") { - return false; - } - if args.len() != 2 { - return false; - } - args.iter().any(|operand| { - let Some(node) = self.program.values.get(*operand) else { - return false; - }; - if global { - matches!(node.value, Value::GlobalVariable(id) if id.index() == variable_index) - } else { - matches!( - node.value, - Value::PlayerVariable { variable: id, .. } if id.index() == variable_index - ) - } - }) - } - - /// Whether a value node is the event-player pseudo-symbol. - fn is_event_player(&self, value: wir::ValueId) -> bool { - matches!( - self.program.values.get(value).map(|node| &node.value), - Some(Value::EventPlayer) - ) - } - - fn emit_modify( - &mut self, - level: usize, - name: &str, - op: ModifyOp, - value: wir::ValueId, - span: Option, - ) { - let indent = Self::indent(level); - match op { - ModifyOp::AppendToArray => { - self.out.push_str(&indent); - self.out.push_str(name); - self.out.push_str(".append("); - self.emit_value(value); - self.out.push_str(")\n"); - } - ModifyOp::RemoveFromArray => { - self.issue( - "unsupported-modify-op", - "Modify ... Remove From Array is outside the reconstruction surface \ - (the OPY surface has no remove-from-array form)", - span, - ); - } - ModifyOp::RemoveFromArrayByIndex => { - self.issue( - "unsupported-modify-op", - "Modify ... Remove From Array By Index is outside the reconstruction \ - surface (the OPY surface has no indexed remove-from-array form)", - span, - ); - } - ModifyOp::Add - | ModifyOp::Subtract - | ModifyOp::Multiply - | ModifyOp::Divide - | ModifyOp::Modulo - | ModifyOp::RaiseToPower => { - let operator = match op { - ModifyOp::Add => "+", - ModifyOp::Subtract => "-", - ModifyOp::Multiply => "*", - ModifyOp::Divide => "/", - ModifyOp::Modulo => "%", - ModifyOp::RaiseToPower => "**", - _ => unreachable!(), - }; - self.out.push_str(&indent); - self.out.push_str(name); - self.out.push_str(" = "); - self.out.push_str(name); - self.out.push(' '); - self.out.push_str(operator); - self.out.push(' '); - self.emit_value(value); - self.out.push('\n'); - } - } - } - - /// A generic or member action call in statement position. - fn emit_call_action( - &mut self, - name: &str, - args: &[wir::ValueId], - indent: &str, - span: Option, - ) { - if DEDICATED_ACTION_NAMES.contains(&name) { - self.issue( - "unsupported-action-call", - format!( - "action call '{name}' is lowered to a dedicated WIR node by the \ - OPY frontend and has no reconstructible call form" - ), - span, - ); - return; - } - let Some(entry) = self.manifest.resolve_function(name) else { - match self.manifest.resolve_member(name) { - Some(entry) if entry.kind.is_action() => { - self.emit_member_call(entry, args, indent, span); - } - Some(_) => { - self.issue( - "unsupported-action-call", - format!( - "member value '{name}' cannot be emitted as an action on \ - the reconstruction surface" - ), - span, - ); - } - None => { - self.issue( - "unsupported-action-call", - format!( - "action call '{name}' has no OPY source form on the \ - reconstruction surface" - ), - span, - ); - } - } - return; - }; - if !entry.kind.is_action() { - self.issue( - "unsupported-action-call", - format!( - "value function '{name}' cannot be emitted as an action on \ - the reconstruction surface" - ), - span, - ); - return; - } - if args.is_empty() && self.subroutine_names.contains(name) { - self.issue( - "unsupported-action-call", - format!( - "action '{name}' with no arguments is ambiguous with a subroutine \ - of the same name on the OPY surface" - ), - span, - ); - return; - } - self.out.push_str(indent); - self.emit_manifest_call(entry, args, false, span); - self.out.push('\n'); - } - - /// Emit a manifest function call with explicit full-arity arguments, no - /// indent and no trailing newline (the caller frames the line). The OPY - /// frontend fills declared defaults at recompile time, so any WIR call - /// that omits a defaulted or required parameter cannot be reconstructed - /// identically and is rejected. - fn emit_manifest_call( - &mut self, - entry: &Function, - args: &[wir::ValueId], - member: bool, - span: Option, - ) { - let (receiver, params) = if member { - match args.split_first() { - Some((receiver, rest)) => (Some(*receiver), rest), - None => { - self.issue( - "unsupported-invalid-arity", - format!("member '{}' requires a receiver argument", entry.id), - span, - ); - return; - } - } - } else { - (None, args) - }; - let name = entry.id.as_str(); - if params.len() > entry.params.len() { - self.issue( - "unsupported-invalid-arity", - format!( - "{} '{}' expects at most {} arguments but the WIR carries {}", - kind_label(entry.kind), - name, - entry.params.len(), - params.len() - ), - span, - ); - return; - } - // Every parameter beyond the provided arguments must be omittable - // (`optional`). A required parameter (with or without a declared - // default) cannot be omitted: the OPY frontend would reject it or - // fill its default, changing the recompiled WIR. - for (_index, param) in entry.params.iter().enumerate().skip(params.len()) { - if !param.optional { - self.issue( - "unsupported-missing-argument", - format!( - "{} '{}' omits parameter '{}'; the OPY frontend would \ - reject or default-fill it and change the recompiled WIR", - kind_label(entry.kind), - name, - param.name - ), - span, - ); - } - } - - if let Some(receiver) = receiver { - self.emit_value(receiver); - self.out.push('.'); - } - self.out.push_str(name); - self.out.push('('); - // Cross-check through the Workshop catalog: a manifest entry with a - // declared `catalogId` must resolve there under the matching kind and - // the reconstruction locale (mirroring the manifest's own catalog - // cross-check test), so the reconstruction identity layer never - // drifts from the catalog. - if let Some(catalog_id) = &entry.catalog_id { - let expected_kind = match entry.kind { - FunctionKind::Action | FunctionKind::MemberAction => { - workshop_rs::catalog::Kind::Action - } - FunctionKind::Value | FunctionKind::MemberValue => { - workshop_rs::catalog::Kind::Value - } - }; - if self - .catalog - .spelling(expected_kind, self.locale, catalog_id) - .is_none() - { - self.issue( - "catalog-error", - format!( - "manifest entry '{}' links catalogId '{catalog_id}' which is \ - missing from the Workshop catalog", - entry.id - ), - span, - ); - } - } - for (index, arg) in params.iter().enumerate() { - if index > 0 { - self.out.push_str(", "); - } - self.check_param_argument(entry, index, *arg, span); - self.emit_value(*arg); - } - self.out.push(')'); - } - - /// A member call: `receiver.name(args...)`. - fn emit_member_call( - &mut self, - entry: &Function, - args: &[wir::ValueId], - indent: &str, - span: Option, - ) { - self.out.push_str(indent); - self.emit_manifest_call(entry, args, true, span); - self.out.push('\n'); - } - - /// Validate a provided argument against its manifest parameter: enum - /// domains are enforced (like the frontend) and `variable`-required - /// parameters must be variable references. - fn check_param_argument( - &mut self, - entry: &Function, - index: usize, - arg: wir::ValueId, - span: Option, - ) { - let Some(param) = entry.params.get(index) else { - return; - }; - let Some(node) = self.program.values.get(arg) else { - return; - }; - if let Some(domain) = ¶m.domain { - match &node.value { - Value::Enum { value_type, value } if value_type == domain => { - if !self.enum_member_in_domain(domain, value) { - self.issue( - "unsupported-enum-member", - format!( - "argument {} of '{}' uses enum member '{domain}.{value}' \ - which is outside the manifest's declared domain", - index + 1, - entry.id - ), - span, - ); - } - } - Value::Enum { value_type, .. } => { - self.issue( - "unsupported-enum-domain-mismatch", - format!( - "argument {} of '{}' expects enum domain '{domain}' but \ - the WIR carries '{value_type}'", - index + 1, - entry.id - ), - span, - ); - } - _ => { - self.issue( - "unsupported-enum-domain-mismatch", - format!( - "argument {} of '{}' expects an enum member of domain \ - '{domain}'", - index + 1, - entry.id - ), - span, - ); - } - } - } - if param.variable { - let is_variable = matches!( - node.value, - Value::GlobalVariable(_) | Value::PlayerVariable { .. } - ); - if !is_variable { - self.issue( - "unsupported-invalid-argument", - format!( - "argument {} of '{}' must be a variable reference", - index + 1, - entry.id - ), - span, - ); - } - } - } - - fn enum_member_in_domain(&self, domain: &str, member: &str) -> bool { - self.manifest - .enum_domain(domain) - .is_some_and(|domain| domain.members.iter().any(|candidate| candidate == member)) - } - - // ---- value emission ---- - - fn emit_value(&mut self, id: wir::ValueId) { - let Some(node) = self.program.values.get(id) else { - self.issue("unsupported-dangling", "dangling value id", None); - return; - }; - match &node.value { - Value::Number { value, .. } => { - if !value.is_finite() { - self.issue( - "unsupported-non-finite-number", - format!("non-finite number literal '{value}' has no OPY spelling"), - node.span, - ); - } else if *value < 0.0 { - self.issue( - "unsupported-negative-number", - format!( - "negative number literal '{}' has no OPY literal form \ - (the lexer has no negative-number token)", - wright_ir::format::format_number(*value) - ), - node.span, - ); - } else { - self.out.push_str(&wright_ir::format::format_number(*value)); - } - } - Value::String(value) => self.emit_string_literal(value), - Value::LocalizedString(value) => { - self.issue( - "unsupported-localized-string", - format!("localized Workshop preset string '{value}' has no OPY source representation"), - node.span, - ); - } - Value::Bool(value) => { - self.out.push_str(if *value { "true" } else { "false" }); - } - Value::Null => { - self.out.push_str("None"); - } - Value::Array(elements) => { - self.out.push('['); - for (index, element) in elements.iter().enumerate() { - if index > 0 { - self.out.push_str(", "); - } - self.emit_value(*element); - } - self.out.push(']'); - } - Value::Vector { x, y, z } => { - self.out.push_str("vect("); - self.emit_value(*x); - self.out.push_str(", "); - self.emit_value(*y); - self.out.push_str(", "); - self.emit_value(*z); - self.out.push(')'); - } - Value::Enum { value_type, value } => { - self.emit_enum(value_type, value, node.span); - } - Value::GlobalVariable(variable) => { - let Some(variable) = self.program.global_variables.get(*variable) else { - self.issue( - "unsupported-dangling", - "dangling global variable id", - node.span, - ); - return; - }; - self.out.push_str(&variable.name); - } - Value::PlayerVariable { player, variable } => { - if !self.is_event_player(*player) { - self.issue( - "unsupported-arbitrary-player-target", - "a player-variable access on a non-event-player expression is \ - outside the reconstruction surface (only eventPlayer.member \ - is representable)", - node.span, - ); - return; - } - let Some(variable) = self.program.player_variables.get(*variable) else { - self.issue( - "unsupported-dangling", - "dangling player variable id", - node.span, - ); - return; - }; - self.out.push_str("eventPlayer."); - self.out.push_str(&variable.name); - } - Value::Subroutine(_) => { - self.issue( - "unsupported-subroutine-value", - "subroutine values are outside the OPY reconstruction surface", - node.span, - ); - } - Value::EventPlayer => { - self.out.push_str("eventPlayer"); - } - Value::Call { name, args } => { - self.emit_value_call(name, args, node.span); - } - } - } - - fn emit_enum(&mut self, value_type: &str, value: &str, span: Option) { - let Some(domain) = self.manifest.enum_domain(value_type) else { - self.issue( - "unsupported-enum-domain", - format!( - "enum domain '{value_type}' is outside the manifest's declared \ - reconstruction surface" - ), - span, - ); - return; - }; - if !domain.members.iter().any(|member| member == value) { - self.issue( - "unsupported-enum-member", - format!( - "enum member '{value_type}.{value}' is outside the manifest's \ - declared domain" - ), - span, - ); - return; - } - self.out.push_str(value_type); - self.out.push('.'); - self.out.push_str(value); - } - - fn emit_value_call(&mut self, name: &str, args: &[wir::ValueId], span: Option) { - // Binary and unary operator calls keep their source spelling. - if BINARY_OPS.contains(&name) && args.len() == 2 { - self.out.push('('); - self.emit_value(args[0]); - self.out.push(' '); - self.out.push_str(name); - self.out.push(' '); - self.emit_value(args[1]); - self.out.push(')'); - return; - } - if name == "not" && args.len() == 1 { - self.out.push_str("(not "); - self.emit_value(args[0]); - self.out.push(')'); - return; - } - if name == "-" && args.len() == 1 { - self.out.push_str("(-"); - self.emit_value(args[0]); - self.out.push(')'); - return; - } - // The `format` special form: `"text".format(args...)`. - if name == "format" { - let Some(first) = args.first() else { - self.issue( - "unsupported-value-call", - "format call without a receiver is outside the reconstruction surface", - span, - ); - return; - }; - let Some(Value::String(text)) = self.program.values.get(*first).map(|node| &node.value) - else { - self.issue( - "unsupported-value-call", - "format call without a string receiver is outside the \ - reconstruction surface", - span, - ); - return; - }; - self.emit_string_literal(text); - self.out.push_str(".format("); - for (index, arg) in args.iter().skip(1).enumerate() { - if index > 0 { - self.out.push_str(", "); - } - self.emit_value(*arg); - } - self.out.push(')'); - return; - } - if DEDICATED_VALUE_NAMES.contains(&name) { - self.issue( - "unsupported-value-call", - format!( - "value call '{name}' is lowered to a dedicated WIR node by the \ - OPY frontend and has no reconstructible call form" - ), - span, - ); - return; - } - let Some(entry) = self.manifest.resolve_function(name) else { - match self.manifest.resolve_member(name) { - Some(entry) if entry.kind.is_value() => { - self.emit_manifest_call(entry, args, true, span); - } - Some(_) => { - self.issue( - "unsupported-value-call", - format!( - "member action '{name}' cannot be emitted as a value on \ - the reconstruction surface" - ), - span, - ); - } - None => { - self.issue( - "unsupported-value-call", - format!( - "value call '{name}' has no OPY source form on the \ - reconstruction surface" - ), - span, - ); - } - } - return; - }; - if !entry.kind.is_value() { - self.issue( - "unsupported-value-call", - format!( - "action function '{name}' cannot be emitted as a value on the \ - reconstruction surface" - ), - span, - ); - return; - } - if entry.context.is_some() { - self.issue( - "unsupported-value-call", - format!( - "value call '{name}' is only valid as a for-loop iterable on \ - the OPY surface" - ), - span, - ); - return; - } - self.emit_manifest_call(entry, args, false, span); - } - - fn emit_string_literal(&mut self, value: &str) { - self.out.push('"'); - for ch in value.chars() { - match ch { - '\\' => self.out.push_str("\\\\"), - '"' => self.out.push_str("\\\""), - '\n' => self.out.push_str("\\n"), - '\t' => self.out.push_str("\\t"), - '\r' => self.out.push_str("\\r"), - other => self.out.push(other), - } - } - self.out.push('"'); - } -} - -fn kind_label(kind: FunctionKind) -> &'static str { - match kind { - FunctionKind::Action => "action", - FunctionKind::Value => "value", - FunctionKind::MemberAction => "member action", - FunctionKind::MemberValue => "member value", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use workshop_rs::source::{Position, SourceFile}; - use workshop_rs::wir::{Program, ValueNode}; - - fn catalog() -> Catalog { - Catalog::builtin().unwrap() - } - - fn manifest() -> &'static Manifest { - Manifest::builtin().unwrap() - } - - fn en() -> Locale { - Locale::new("en-US") - } - - fn span() -> Span { - Span::new( - wright_ir::ids::Id::from_index(0), - Position::new(1, 1), - Position::new(1, 1), - ) - } - - fn value(program: &mut Program, value: Value) -> wir::ValueId { - program.values.push(ValueNode::new(value, Some(span()))) - } - - fn number(program: &mut Program, number: f64) -> wir::ValueId { - value( - program, - Value::Number { - value: number, - text: wright_ir::format::format_number(number), - }, - ) - } - - fn global(program: &mut Program, name: &str) -> (wir::GlobalVarId, wir::ValueId) { - let id = program.global_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index: program.global_variables.len() as u32, - span: None, - name_span: None, - }); - let read = value(program, Value::GlobalVariable(id)); - (id, read) - } - - fn player(program: &mut Program, name: &str) -> (wir::PlayerVarId, wir::ValueId) { - let id = program.player_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index: program.player_variables.len() as u32, - span: None, - name_span: None, - }); - let player = value(program, Value::EventPlayer); - let read = value( - program, - Value::PlayerVariable { - player, - variable: id, - }, - ); - (id, read) - } - - fn event_player(program: &mut Program) -> wir::ValueId { - value(program, Value::EventPlayer) - } - - fn global_rule(program: &mut Program, name: &str, actions: Vec) { - program.rules.push(wir::Rule { - name: name.to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Global, - conditions: Vec::new(), - actions, - }); - } - - fn emit(program: &Program) -> Result { - reconstruct_with(program, manifest(), &catalog(), &en()) - } - - /// The shipped end-to-end path for a WIR program built without a - /// Workshop source: reconstruct → native frontend → WIR, asserted - /// equivalent. - fn round_trip(program: &Program) { - let source = emit(program).expect("reconstruction succeeds"); - let recompiled = recompile(&source); - assert!( - workshop_rs::roundtrip::equivalent(program, &recompiled), - "recompiled WIR must be equivalent to the input:\n{source}" - ); - } - - /// Recompile reconstructed OPY through the shipped native frontend. - fn recompile(source: &str) -> workshop_rs::wir::Program { - let hir = crate::compile(source, "reconstructed.opy", std::path::Path::new("")) - .expect("the native frontend accepts the reconstructed OPY"); - wright_ir::lower::lower(&hir.to_ir().expect("converts to internal HIR")) - .expect("lowers to WIR") - } - - fn file_registry(program: &mut Program) { - program - .files - .push(SourceFile::new("reconstructed.opy".to_string())); - } - - #[test] - fn reconstructs_a_basic_program_deterministically() { - let mut program = Program::default(); - file_registry(&mut program); - let (score, _score_read) = global(&mut program, "score"); - let (_other, other_read) = global(&mut program, "other"); - let (_, has_started_read) = player(&mut program, "hasStarted"); - let one = number(&mut program, 1.0); - let sum = value( - &mut program, - Value::Call { - name: "+".to_string(), - args: vec![other_read, one], - }, - ); - let action = program.actions.push(Action::SetGlobalVariable { - variable: score, - value: sum, - span: None, - target_span: None, - }); - let print_action = program.actions.push(Action::Print { - message: has_started_read, - span: None, - }); - global_rule(&mut program, "main", vec![action, print_action]); - - let first = emit(&program).expect("reconstructs"); - let second = emit(&program).expect("reconstructs"); - assert_eq!(first, second, "reconstruction must be byte-stable"); - // Structural assertions on the reconstructed source (no golden - // bytes): the declarations, the event, the binary expression, and - // the print statement all appear in their OPY source forms. - assert!(first.starts_with("globalvar score 0\nglobalvar other 1\nplayervar hasStarted 0")); - assert!(first.contains("score = (other + 1)")); - assert!(first.contains("print(eventPlayer.hasStarted)")); - // `equivalent` does not cover the dedicated Debug/Print nodes, so - // the round-trip is checked on the Set actions and the print - // recompilation is verified structurally on the recompiled WIR. - let mut comparable = program.clone(); - let rule_id = wright_ir::ids::Id::from_index(0); - comparable.rules.get_mut(rule_id).unwrap().actions.pop(); - round_trip(&comparable); - let recompiled = recompile(&first); - assert!( - recompiled.dump().contains("print eventPlayer.hasStarted"), - "print recompiles to the Print node:\n{}", - recompiled.dump() - ); - } - - #[test] - fn emits_debug_arrays_vectors_and_format() { - let mut program = Program::default(); - file_registry(&mut program); - let (result, result_read) = global(&mut program, "result"); - let one = number(&mut program, 1.0); - let two = number(&mut program, 2.0); - let three = number(&mut program, 3.0); - let array = value(&mut program, Value::Array(vec![one, two])); - let vector = value( - &mut program, - Value::Vector { - x: one, - y: two, - z: three, - }, - ); - let text = value(&mut program, Value::String("x: {0}".to_string())); - let formatted = value( - &mut program, - Value::Call { - name: "format".to_string(), - args: vec![text, result_read], - }, - ); - let actions = vec![ - program.actions.push(Action::SetGlobalVariable { - variable: result, - value: array, - span: None, - target_span: None, - }), - program.actions.push(Action::SetGlobalVariable { - variable: result, - value: vector, - span: None, - target_span: None, - }), - program.actions.push(Action::SetGlobalVariable { - variable: result, - value: formatted, - span: None, - target_span: None, - }), - program.actions.push(Action::Debug { - value: result_read, - span: None, - }), - ]; - global_rule(&mut program, "main", actions); - let source = emit(&program).expect("reconstructs"); - assert!(source.contains("result = [1, 2]")); - assert!(source.contains("result = vect(1, 2, 3)")); - assert!(source.contains("result = \"x: {0}\".format(result)")); - assert!(source.contains("debug(result)")); - // `equivalent` does not cover the dedicated Debug/Print nodes, so the - // equivalent part (Sets) is checked on a Debug-free copy and the - // debug recompilation is verified structurally. - let mut comparable = program.clone(); - let rule_id = wright_ir::ids::Id::from_index(0); - comparable.rules.get_mut(rule_id).unwrap().actions.pop(); - round_trip(&comparable); - let recompiled = recompile(&source); - assert!( - recompiled.dump().contains("debug result"), - "debug recompiles to the Debug node:\n{}", - recompiled.dump() - ); - } - - #[test] - fn emits_manifest_calls_with_full_arity() { - let mut program = Program::default(); - file_registry(&mut program); - let (result, result_read) = global(&mut program, "result"); - let receiver = event_player(&mut program); - let two = number(&mut program, 2.0); - let team_all = value( - &mut program, - Value::Enum { - value_type: "Team".to_string(), - value: "ALL".to_string(), - }, - ); - let los_off = value( - &mut program, - Value::Enum { - value_type: "LosCheck".to_string(), - value: "OFF".to_string(), - }, - ); - let radius = value( - &mut program, - Value::Call { - name: "getPlayersInRadius".to_string(), - args: vec![result_read, two, team_all, los_off], - }, - ); - let wait_time = number(&mut program, 0.016); - let wait_behavior = value( - &mut program, - Value::Enum { - value_type: "Wait".to_string(), - value: "IGNORE_CONDITION".to_string(), - }, - ); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![wait_time, wait_behavior], - span: None, - }); - let set_radius = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: radius, - span: None, - target_span: None, - }); - let move_speed = number(&mut program, 100.0); - let member_call = program.actions.push(Action::Call { - name: "setMoveSpeed".to_string(), - args: vec![receiver, move_speed], - span: None, - }); - global_rule(&mut program, "main", vec![set_radius, member_call, wait]); - let source = emit(&program).expect("reconstructs"); - assert!(source.contains("getPlayersInRadius(result, 2, Team.ALL, LosCheck.OFF)")); - assert!(source.contains("eventPlayer.setMoveSpeed(100)")); - assert!(source.contains("wait(0.016, Wait.IGNORE_CONDITION)")); - round_trip(&program); - } - - #[test] - fn emits_modify_forms() { - let mut program = Program::default(); - file_registry(&mut program); - let (score, score_read) = global(&mut program, "score"); - let one = number(&mut program, 1.0); - let five = number(&mut program, 5.0); - let actions = vec![ - program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::Add, - value: one, - span: None, - target_span: None, - }), - program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::AppendToArray, - value: five, - span: None, - target_span: None, - }), - program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::RaiseToPower, - value: score_read, - span: None, - target_span: None, - }), - ]; - global_rule(&mut program, "main", actions); - let source = emit(&program).expect("reconstructs"); - assert!(source.contains("score = score + 1")); - assert!(source.contains("score.append(5)")); - assert!(source.contains("score = score ** score")); - round_trip(&program); - } - - #[test] - fn reconstructs_initializer_rules_and_defs() { - let mut program = Program::default(); - file_registry(&mut program); - let (score, _score_read) = global(&mut program, "score"); - let (points, _points_read) = global(&mut program, "points"); - let (kills, _) = player(&mut program, "kills"); - let (has_started, _) = player(&mut program, "hasStarted"); - - // Leading "Initialize global variables" rule: score = 5, points = 0. - let init_value = number(&mut program, 5.0); - let zero_value = number(&mut program, 0.0); - let one_more = number(&mut program, 1.0); - let init_actions = vec![ - program.actions.push(Action::SetGlobalVariable { - variable: score, - value: init_value, - span: None, - target_span: None, - }), - program.actions.push(Action::SetGlobalVariable { - variable: points, - value: zero_value, - span: None, - target_span: None, - }), - ]; - program.rules.push(wir::Rule { - name: "Initialize global variables".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Global, - conditions: Vec::new(), - actions: init_actions, - }); - // Leading "Initialize player variables" rule: kills = 3. - let player_event = event_player(&mut program); - let kills_three = number(&mut program, 3.0); - let player_init = program.actions.push(Action::SetPlayerVariable { - player: player_event, - variable: kills, - value: kills_three, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "Initialize player variables".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::EachPlayer, - conditions: Vec::new(), - actions: vec![player_init], - }); - // Subroutine body. - let sub_id = program.subroutines.push(wir::WorkshopSubroutine { - name: "tick".to_string(), - index: 0, - span: None, - name_span: None, - }); - let sub_action = program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::Add, - value: one_more, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "Subroutine tick".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Subroutine(sub_id), - conditions: Vec::new(), - actions: vec![sub_action], - }); - // Normal rule. - let set2_event = event_player(&mut program); - let one_value = number(&mut program, 1.0); - let set2 = program.actions.push(Action::SetPlayerVariable { - player: set2_event, - variable: has_started, - value: one_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set2]); - - let source = emit(&program).expect("reconstructs"); - assert!(source.contains("globalvar score = 5")); - assert!(source.contains("globalvar points = 0.0")); - assert!(source.contains("playervar kills = 3")); - assert!(source.contains("subroutine tick")); - assert!(source.contains("def tick():")); - round_trip(&program); - } - - #[test] - fn rejects_non_representable_constructs() { - // Per-player loop. - let mut program = Program::default(); - file_registry(&mut program); - let (has_started, _) = player(&mut program, "hasStarted"); - let loop_player = event_player(&mut program); - let loop_start = number(&mut program, 0.0); - let loop_stop = number(&mut program, 3.0); - let loop_step = number(&mut program, 1.0); - let loop_action = program.actions.push(Action::ForPlayerVariable { - player: loop_player, - variable: has_started, - start: loop_start, - stop: loop_stop, - step: loop_step, - body: Vec::new(), - span: None, - }); - global_rule(&mut program, "loop", vec![loop_action]); - let error = emit(&program).expect_err("per-player loop must be rejected"); - assert_eq!(error.issues[0].code, "unsupported-per-player-loop"); - - // Disabled rule. - let mut program = Program::default(); - file_registry(&mut program); - let action = program.actions.push(Action::Call { - name: "disableInspector".to_string(), - args: Vec::new(), - span: None, - }); - program.rules.push(wir::Rule { - name: "off".to_string(), - span: None, - name_span: None, - disabled: true, - event: Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - let error = emit(&program).expect_err("disabled rules must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-disabled-rule") - ); - - // Arbitrary player target. - let mut program = Program::default(); - file_registry(&mut program); - let (result, _) = global(&mut program, "result"); - let (has_started, _) = player(&mut program, "hasStarted"); - let player_expr = value(&mut program, Value::GlobalVariable(result)); - let one_value = number(&mut program, 1.0); - let set = program.actions.push(Action::SetPlayerVariable { - player: player_expr, - variable: has_started, - value: one_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("arbitrary player target must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-arbitrary-player-target") - ); - - // Unrepresentable call name. - let mut program = Program::default(); - file_registry(&mut program); - let (result, _) = global(&mut program, "result"); - let (_, has_started) = player(&mut program, "hasStarted"); - let value_id = value( - &mut program, - Value::Call { - name: "countOf".to_string(), - args: vec![has_started], - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: value_id, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("unknown value call must be rejected"); - assert!(error.issues.iter().any( - |issue| issue.code == "unsupported-value-call" && issue.message.contains("countOf") - )); - - // Invalid identifier. - let mut program = Program::default(); - file_registry(&mut program); - program.global_variables.push(wir::WorkshopVariable { - name: "two words".to_string(), - index: 0, - span: None, - name_span: None, - }); - let error = emit(&program).expect_err("invalid identifier must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-name") - ); - - // Negative number. - let mut program = Program::default(); - file_registry(&mut program); - let (result, _) = global(&mut program, "result"); - let negative = number(&mut program, -1.0); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: negative, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("negative numbers must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-negative-number") - ); - - // Unknown enum domain. - let mut program = Program::default(); - file_registry(&mut program); - let (result, _) = global(&mut program, "result"); - let enum_value = value( - &mut program, - Value::Enum { - value_type: "NotADomain".to_string(), - value: "X".to_string(), - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: enum_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("unknown enum domain must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-enum-domain") - ); - - // Unknown enum member. - let mut program = Program::default(); - file_registry(&mut program); - let (result, _) = global(&mut program, "result"); - let enum_value = value( - &mut program, - Value::Enum { - value_type: "Color".to_string(), - value: "CYAN".to_string(), - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: enum_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("unknown enum member must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-enum-member") - ); - - // Remove-from-array modify. - let mut program = Program::default(); - file_registry(&mut program); - let (score, _) = global(&mut program, "score"); - let one_value = number(&mut program, 1.0); - let modify = program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::RemoveFromArray, - value: one_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![modify]); - let error = emit(&program).expect_err("remove-from-array must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-modify-op") - ); - - // Missing required argument (default filling would change the WIR). - let mut program = Program::default(); - file_registry(&mut program); - let wait_time = number(&mut program, 1.0); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![wait_time], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - let error = emit(&program).expect_err("short wait must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-missing-argument") - ); - - // Invalid arity. - let mut program = Program::default(); - file_registry(&mut program); - let wait_time = number(&mut program, 1.0); - let wait_behavior = value( - &mut program, - Value::Enum { - value_type: "Wait".to_string(), - value: "IGNORE_CONDITION".to_string(), - }, - ); - let wait_two = number(&mut program, 2.0); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![wait_time, wait_behavior, wait_two], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - let error = emit(&program).expect_err("wait with 3 args must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-invalid-arity") - ); - - // Enum-domain mismatch at a manifest parameter. - let mut program = Program::default(); - file_registry(&mut program); - let wait_time = number(&mut program, 1.0); - let yellow = value( - &mut program, - Value::Enum { - value_type: "Color".to_string(), - value: "YELLOW".to_string(), - }, - ); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![wait_time, yellow], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - let error = emit(&program).expect_err("domain mismatch must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-enum-domain-mismatch") - ); - - // Set with a same-variable binary (would re-lower to a Modify). - let mut program = Program::default(); - file_registry(&mut program); - let (score, score_read) = global(&mut program, "score"); - let one_value = number(&mut program, 1.0); - let sum = value( - &mut program, - Value::Call { - name: "+".to_string(), - args: vec![score_read, one_value], - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: score, - value: sum, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - let error = emit(&program).expect_err("set-with-same-variable-binary must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-set-binary") - ); - - // Non-canonical rule order: a normal rule before a subroutine body. - let mut program = Program::default(); - file_registry(&mut program); - let sub_id = program.subroutines.push(wir::WorkshopSubroutine { - name: "tick".to_string(), - index: 0, - span: None, - name_span: None, - }); - global_rule(&mut program, "normal", Vec::new()); - program.rules.push(wir::Rule { - name: "Subroutine tick".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Subroutine(sub_id), - conditions: Vec::new(), - actions: Vec::new(), - }); - let error = emit(&program).expect_err("non-canonical rule order must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-rule-order") - ); - - // Reserved name. - let mut program = Program::default(); - file_registry(&mut program); - program.global_variables.push(wir::WorkshopVariable { - name: "if".to_string(), - index: 0, - span: None, - name_span: None, - }); - let error = emit(&program).expect_err("reserved name must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-name") - ); - - // Subroutine index mismatch. - let mut program = Program::default(); - file_registry(&mut program); - program.subroutines.push(wir::WorkshopSubroutine { - name: "tick".to_string(), - index: 5, - span: None, - name_span: None, - }); - let error = emit(&program).expect_err("subroutine index mismatch must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-subroutine-index") - ); - - // Unsorted global slots. - let mut program = Program::default(); - file_registry(&mut program); - program.global_variables.push(wir::WorkshopVariable { - name: "a".to_string(), - index: 5, - span: None, - name_span: None, - }); - program.global_variables.push(wir::WorkshopVariable { - name: "b".to_string(), - index: 0, - span: None, - name_span: None, - }); - let error = emit(&program).expect_err("unsorted globals must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-global-order") - ); - - // Dedicated call names. - let mut program = Program::default(); - file_registry(&mut program); - let (_score, _) = global(&mut program, "score"); - let one_value = number(&mut program, 1.0); - let call = program.actions.push(Action::Call { - name: "debug".to_string(), - args: vec![one_value], - span: None, - }); - global_rule(&mut program, "main", vec![call]); - let error = emit(&program).expect_err("debug call must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-action-call") - ); - - // Settings. - let mut program = Program::default(); - file_registry(&mut program); - program.settings = Some(workshop_rs::settings::Settings { - span: None, - children: Vec::new(), - }); - let error = emit(&program).expect_err("settings must be rejected"); - assert!( - error - .issues - .iter() - .any(|issue| issue.code == "unsupported-settings") - ); - } -} diff --git a/crates/wright-opy/src/settings.rs b/crates/wright-opy/src/settings.rs deleted file mode 100644 index 4e28344..0000000 --- a/crates/wright-opy/src/settings.rs +++ /dev/null @@ -1,841 +0,0 @@ -//! Scoped `settings { ... }` extraction and JSONC parsing (#86). -//! -//! The settings block is recognized and consumed *before* lexing, so the -//! lexer never gains global `{`/`}` tokens (meipocalypse's dict literal keeps -//! failing as a `lex-error`). [`find_blocks`] locates a top-of-file block -//! with a logical-line keyword scan, [`sanitize_for_lex`] blanks the block -//! region out of the text handed to the lexer (newlines preserved, so -//! positions after the block are unchanged), and [`parse_block`] turns the -//! JSONC text into a typed [`cst::Settings`] tree with source spans. - -use crate::cst; -use crate::diag::{FrontendError, FrontendResult, Position, Span}; - -/// A top-of-file `settings { ... }` block. -#[derive(Debug, Clone)] -pub struct SettingsBlock { - /// The raw JSONC text between the braces (braces excluded). - pub text: String, - /// The whole block: the `settings` keyword through the closing brace. - pub span: Span, - /// The `settings` keyword token (diagnostic anchor). - pub keyword_span: Span, - /// The char offset of the `settings` keyword (for sanitization). - pub start: usize, - /// The char offset just past the closing brace (for sanitization). - pub end: usize, - /// The position of the first char of `text` (just past the opening brace). - pub text_start: Position, -} - -/// Locate every `settings { ... }` block in a source text. -/// -/// Rules: 0 blocks -> `Ok(vec![])`; the first block must be the first -/// non-comment construct (`settings-placement` otherwise); after `settings` -/// a `{` is required (`settings "file"` form -> `settings-invalid`); -/// a second/later block is `settings-placement` at its keyword span; brace -/// matching respects `"`/`'` strings, `\` escapes, and nesting; an -/// unterminated block is `settings-invalid`. -pub fn find_blocks(text: &str, file_id: u32) -> FrontendResult> { - let chars: Vec = text.chars().collect(); - let mut scanner = Scanner { - chars: &chars, - pos: 0, - line: 1, - col: 1, - }; - let mut blocks = Vec::new(); - let mut in_block_comment = false; - let mut seen_first_construct = false; - while scanner.pos < scanner.chars.len() { - let ch = scanner.chars[scanner.pos]; - if in_block_comment { - if ch == '*' && scanner.peek(1) == Some('/') { - in_block_comment = false; - scanner.advance(2); - } else { - scanner.advance(1); - } - continue; - } - if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' { - scanner.advance(1); - continue; - } - if ch == '#' { - scanner.skip_to_eol(); - continue; - } - if ch == '/' && scanner.peek(1) == Some('*') { - scanner.advance(2); - in_block_comment = true; - continue; - } - // A construct token: the first non-comment token of a logical line. - if is_ident_start(ch) { - let keyword_start = scanner.here(); - let keyword_offset = scanner.pos; - let word = scanner.read_word(); - if word == "settings" { - let keyword_span = Span::new(file_id, keyword_start, scanner.here()); - if seen_first_construct || !blocks.is_empty() { - return Err(FrontendError::at( - "settings-placement", - "settings block must be the first construct in the file".to_string(), - keyword_span, - )); - } - let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?; - blocks.push(block); - seen_first_construct = true; - continue; - } - seen_first_construct = true; - continue; - } - seen_first_construct = true; - scanner.advance(1); - } - Ok(blocks) -} - -/// Match the braces of one `settings { ... }` block, returning the extracted -/// block. `scanner` is positioned just past the `settings` keyword. -fn match_block( - scanner: &mut Scanner<'_>, - keyword_start: Position, - keyword_offset: usize, - keyword_span: Span, -) -> FrontendResult { - scanner.skip_whitespace(); - if scanner.chars.get(scanner.pos) != Some(&'{') { - return Err(FrontendError::at( - "settings-invalid", - "settings block must be a `settings { ... }` block (the `settings \"file\"` form is not supported)" - .to_string(), - keyword_span, - )); - } - let mut depth = 0usize; - let mut string_quote: Option = None; - let mut escaped = false; - let mut text_start_offset = None; - let mut text_start = None; - loop { - let Some(ch) = scanner.chars.get(scanner.pos).copied() else { - return Err(FrontendError::at( - "settings-invalid", - "unterminated settings block (missing closing brace)".to_string(), - keyword_span, - )); - }; - if let Some(quote) = string_quote { - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == quote { - string_quote = None; - } - scanner.advance(1); - continue; - } - match ch { - '"' | '\'' => string_quote = Some(ch), - '{' => { - if depth == 0 { - text_start_offset = Some(scanner.pos + 1); - text_start = Some(scanner.here_after(1)); - } - depth += 1; - } - '}' => { - depth -= 1; - if depth == 0 { - let text = scanner - .chars - .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos) - .map(|slice| slice.iter().collect::()) - .unwrap_or_default(); - return Ok(SettingsBlock { - text, - span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)), - keyword_span, - start: keyword_offset, - end: scanner.pos + 1, - text_start: text_start.expect("text start set on '{'"), - }); - } - } - _ => {} - } - scanner.advance(1); - } -} - -/// Replace every char of the block region with a space, preserving newlines, -/// so tokens after the block keep their exact original line/col. -pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String { - let mut out = String::with_capacity(text.len()); - for (index, ch) in text.chars().enumerate() { - if index >= block.start && index < block.end { - out.push(if ch == '\n' { '\n' } else { ' ' }); - } else { - out.push(ch); - } - } - out -} - -/// Parse a settings block's JSONC text into a typed CST settings tree. -/// -/// Grammar: quoted keys, `"`/`'` strings with `\` escapes, int/float numbers -/// (f64), `true`/`false`, arrays of strings, nested objects, trailing commas -/// in objects and arrays. Rejections (`settings-invalid`): duplicate keys, -/// non-object root, missing `gamemodes` group, malformed values. -pub fn parse_block(block: &SettingsBlock) -> FrontendResult { - let mut parser = Jsonc { - text: &block.text, - pos: 0, - line: block.text_start.line, - col: block.text_start.col, - file: block.span.file, - }; - parser.skip_whitespace(); - // The block's own braces delimit the root object; the text between them - // parses as its members. - let children = parser.parse_members(true)?; - parser.skip_whitespace(); - if parser.pos < parser.text.len() { - return Err(parser.error( - "settings-invalid", - "unexpected content after the settings object".to_string(), - )); - } - if !children - .iter() - .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes")) - { - return Err(FrontendError::at( - "settings-invalid", - "settings block must contain a gamemodes group".to_string(), - block.span, - )); - } - Ok(cst::Settings { - span: block.span, - children, - }) -} - -/// A char scanner with 1-based line/col tracking. -struct Scanner<'a> { - chars: &'a [char], - pos: usize, - line: u32, - col: u32, -} - -impl Scanner<'_> { - fn peek(&self, ahead: usize) -> Option { - self.chars.get(self.pos + ahead).copied() - } - - fn here(&self) -> Position { - Position::new(self.line, self.col) - } - - fn here_after(&self, n: usize) -> Position { - let mut line = self.line; - let mut col = self.col; - for i in 0..n { - if self.chars.get(self.pos + i) == Some(&'\n') { - line += 1; - col = 1; - } else { - col += 1; - } - } - Position::new(line, col) - } - - fn advance(&mut self, n: usize) { - for _ in 0..n { - if self.pos >= self.chars.len() { - return; - } - if self.chars[self.pos] == '\n' { - self.line += 1; - self.col = 1; - } else { - self.col += 1; - } - self.pos += 1; - } - } - - fn skip_to_eol(&mut self) { - while self.pos < self.chars.len() && self.chars[self.pos] != '\n' { - self.advance(1); - } - } - - fn skip_whitespace(&mut self) { - while self.pos < self.chars.len() && matches!(self.chars[self.pos], ' ' | '\t' | '\r') { - self.advance(1); - } - } - - fn read_word(&mut self) -> String { - let mut word = String::new(); - while self.pos < self.chars.len() && is_ident_continue(self.chars[self.pos]) { - word.push(self.chars[self.pos]); - self.advance(1); - } - word - } -} - -/// A JSONC parser over the block text. -struct Jsonc<'a> { - text: &'a str, - pos: usize, - line: u32, - col: u32, - file: u32, -} - -impl Jsonc<'_> { - fn here(&self) -> Position { - Position::new(self.line, self.col) - } - - fn peek(&self) -> Option { - self.text[self.pos..].chars().next() - } - - fn advance(&mut self) -> Option { - let ch = self.peek()?; - self.pos += ch.len_utf8(); - if ch == '\n' { - self.line += 1; - self.col = 1; - } else { - self.col += 1; - } - Some(ch) - } - - fn skip_whitespace(&mut self) { - while let Some(ch) = self.peek() { - if ch.is_whitespace() { - self.advance(); - } else { - break; - } - } - } - - fn error(&self, code: &str, message: String) -> FrontendError { - FrontendError::at( - code, - message, - Span::new(self.file, self.here(), self.here()), - ) - } - - fn error_at(&self, code: &str, message: String, span: Span) -> FrontendError { - FrontendError::at(code, message, span) - } - - fn parse_object(&mut self) -> FrontendResult<(Vec, Span)> { - let open = self.here(); - if self.advance() != Some('{') { - return Err(self.error( - "settings-invalid", - "settings block must be a JSONC object".to_string(), - )); - } - let members = self.parse_members(false)?; - let span = Span::new(self.file, open, self.here()); - Ok((members, span)) - } - - /// Parse `key: value, ...` members. `root` is true when the enclosing - /// object's braces are the settings block's own braces (the text runs to - /// the end of the block, and a trailing comma before it is allowed). - fn parse_members(&mut self, root: bool) -> FrontendResult> { - let mut nodes = Vec::new(); - let mut names = Vec::new(); - self.skip_whitespace(); - if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) { - if !root { - self.advance(); - } - return Ok(nodes); - } - loop { - self.skip_whitespace(); - let key_start = self.here(); - let key = match self.parse_string_value() { - Some(value) => value, - None => { - return Err(self.error( - "settings-invalid", - "settings keys must be quoted strings".to_string(), - )); - } - }; - let key_span = Span::new(self.file, key_start, self.here()); - if names.contains(&key) { - return Err(self.error_at( - "settings-invalid", - format!("duplicate settings key '{key}'"), - key_span, - )); - } - names.push(key.clone()); - self.skip_whitespace(); - if self.advance() != Some(':') { - return Err(self.error_at( - "settings-invalid", - format!("expected ':' after settings key '{key}'"), - key_span, - )); - } - self.skip_whitespace(); - let (node, value_end) = self.parse_value()?; - let node = build_node(key, node, value_end, key_start, self.file); - nodes.push(node); - self.skip_whitespace(); - match self.peek() { - Some(',') => { - self.advance(); - self.skip_whitespace(); - if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) - { - if !root { - self.advance(); - } - return Ok(nodes); - } - } - Some('}') if !root => { - self.advance(); - return Ok(nodes); - } - None if root => return Ok(nodes), - _ => { - return Err(self.error( - "settings-invalid", - "expected ',' or '}' in settings object".to_string(), - )); - } - } - } - } - - /// Parse one value; returns the built node (name placeholder) and the - /// position after it. - fn parse_value(&mut self) -> FrontendResult<(cst::SettingsNode, Position)> { - let start = self.here(); - let ch = self.peek(); - let node = match ch { - Some('"') | Some('\'') => { - let value = self.parse_string_value().ok_or_else(|| { - self.error( - "settings-invalid", - "unterminated string in settings value".to_string(), - ) - })?; - cst::SettingsNode::String { - name: String::new(), - value, - span: Span::new(self.file, start, self.here()), - } - } - Some('t') => { - self.expect_word("true")?; - cst::SettingsNode::Bool { - name: String::new(), - value: true, - span: Span::new(self.file, start, self.here()), - } - } - Some('f') => { - self.expect_word("false")?; - cst::SettingsNode::Bool { - name: String::new(), - value: false, - span: Span::new(self.file, start, self.here()), - } - } - Some(c) if c.is_ascii_digit() || c == '-' => { - let value = self.parse_number()?; - cst::SettingsNode::Number { - name: String::new(), - value, - span: Span::new(self.file, start, self.here()), - } - } - Some('[') => { - let elements = self.parse_list()?; - cst::SettingsNode::List { - name: String::new(), - elements, - span: Span::new(self.file, start, self.here()), - } - } - Some('{') => { - let (children, _) = self.parse_object()?; - cst::SettingsNode::Group { - name: String::new(), - children, - span: Span::new(self.file, start, self.here()), - } - } - _ => { - return Err(self.error( - "settings-invalid", - "expected a value in settings block".to_string(), - )); - } - }; - let end = self.here(); - Ok((node, end)) - } - - fn expect_word(&mut self, word: &str) -> FrontendResult<()> { - let start = self.here(); - for expected in word.chars() { - if self.advance() != Some(expected) { - return Err(self.error_at( - "settings-invalid", - format!("expected '{word}' in settings block"), - Span::new(self.file, start, self.here()), - )); - } - } - Ok(()) - } - - fn parse_number(&mut self) -> FrontendResult { - let start = self.here(); - let mut text = String::new(); - if self.peek() == Some('-') { - text.push(self.advance().unwrap()); - } - while let Some(c) = self.peek() { - if c.is_ascii_digit() { - text.push(self.advance().unwrap()); - } else { - break; - } - } - if self.peek() == Some('.') { - text.push(self.advance().unwrap()); - while let Some(c) = self.peek() { - if c.is_ascii_digit() { - text.push(self.advance().unwrap()); - } else { - break; - } - } - } - text.parse::().map_err(|_| { - self.error_at( - "settings-invalid", - format!("invalid number '{text}' in settings block"), - Span::new(self.file, start, self.here()), - ) - }) - } - - fn parse_list(&mut self) -> FrontendResult> { - self.advance(); // '[' - let mut elements = Vec::new(); - self.skip_whitespace(); - if self.peek() == Some(']') { - self.advance(); - return Ok(elements); - } - loop { - self.skip_whitespace(); - let start = self.here(); - let value = match self.parse_string_value() { - Some(value) => value, - None => { - return Err(self.error( - "settings-invalid", - "settings list elements must be strings".to_string(), - )); - } - }; - let span = Span::new(self.file, start, self.here()); - elements.push(cst::SettingsListElement { value, span }); - self.skip_whitespace(); - match self.peek() { - Some(',') => { - self.advance(); - self.skip_whitespace(); - if self.peek() == Some(']') { - self.advance(); - return Ok(elements); - } - } - Some(']') => { - self.advance(); - return Ok(elements); - } - _ => { - return Err(self.error( - "settings-invalid", - "expected ',' or ']' in settings list".to_string(), - )); - } - } - } - } - - /// Parse a quoted string value; `None` when no string is here or the - /// string is unterminated before end-of-line. - fn parse_string_value(&mut self) -> Option { - let quote = self.peek()?; - if quote != '"' && quote != '\'' { - return None; - } - self.advance(); - let mut value = String::new(); - loop { - let ch = self.advance()?; - if ch == '\n' { - return None; - } - if ch == quote { - return Some(value); - } - if ch == '\\' { - let escaped = self.advance()?; - match escaped { - 'n' => value.push('\n'), - 't' => value.push('\t'), - 'r' => value.push('\r'), - other => value.push(other), - } - } else { - value.push(ch); - } - } - } -} - -/// Attach the key name and a key..value span to a parsed value node. -fn build_node( - key: String, - node: cst::SettingsNode, - value_end: Position, - key_start: Position, - file: u32, -) -> cst::SettingsNode { - let span = Span::new(file, key_start, value_end); - match node { - cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group { - name: key, - children, - span, - }, - cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number { - name: key, - value, - span, - }, - cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool { - name: key, - value, - span, - }, - cst::SettingsNode::String { value, .. } => cst::SettingsNode::String { - name: key, - value, - span, - }, - cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List { - name: key, - elements, - span, - }, - } -} - -fn is_ident_start(c: char) -> bool { - c.is_ascii_alphabetic() || c == '_' -} - -fn is_ident_continue(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' -} - -#[cfg(test)] -mod tests { - use super::*; - - fn block(text: &str) -> SettingsBlock { - let mut blocks = find_blocks(text, 0).unwrap(); - assert_eq!(blocks.len(), 1, "one block expected in: {text}"); - blocks.pop().unwrap() - } - - #[test] - fn finds_block_after_comments_and_blanks() { - let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n"; - let found = block(text); - assert!(found.text.contains("gamemodes")); - assert_eq!(found.keyword_span.start.line, 4); - assert_eq!(found.span.end.line, 6); - } - - #[test] - fn no_blocks_for_plain_program() { - assert!( - find_blocks("rule \"r\":\n pass\n", 0) - .unwrap() - .is_empty() - ); - } - - #[test] - fn settings_not_first_construct_is_placement_error() { - let error = find_blocks("rule \"r\":\n pass\nsettings {\n}\n", 0).unwrap_err(); - assert_eq!(error.code, "settings-placement"); - assert_eq!(error.span.unwrap().start.line, 3); - } - - #[test] - fn second_block_is_placement_error() { - let error = - find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err(); - assert_eq!(error.code, "settings-placement"); - assert_eq!(error.span.unwrap().start.line, 4); - } - - #[test] - fn settings_file_form_is_invalid() { - let error = find_blocks("settings \"file.opy\"\n", 0).unwrap_err(); - assert_eq!(error.code, "settings-invalid"); - } - - #[test] - fn unterminated_block_is_invalid() { - let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err(); - assert_eq!(error.code, "settings-invalid"); - } - - #[test] - fn braces_inside_strings_do_not_unbalance() { - let found = - block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n"); - assert!(found.text.contains("a { b }")); - } - - #[test] - fn sanitize_preserves_post_block_positions() { - let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n"; - let found = block(text); - let sanitized = sanitize_for_lex(text, &found); - let lines: Vec<&str> = sanitized.lines().collect(); - assert_eq!(lines.len(), 5); - assert_eq!(lines[3], "rule \"r\":"); - assert_eq!(lines[4], " pass"); - // The rule keyword is at the same char offset as in the original. - assert_eq!(sanitized.find("rule"), text.find("rule")); - } - - #[test] - fn non_object_after_settings_is_invalid() { - // `settings [..]` is rejected at extraction (a `{` is required). - let error = find_blocks("settings [1, 2]\n", 0).unwrap_err(); - assert_eq!(error.code, "settings-invalid"); - } - - #[test] - fn parse_block_rejects_missing_gamemodes() { - let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n"); - let error = parse_block(&found).unwrap_err(); - assert_eq!(error.code, "settings-invalid"); - assert!(error.message.contains("gamemodes")); - } - - #[test] - fn parse_block_rejects_duplicate_keys() { - let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n"); - let error = parse_block(&found).unwrap_err(); - assert_eq!(error.code, "settings-invalid"); - assert!(error.message.contains("duplicate")); - } - - #[test] - fn parse_block_accepts_trailing_commas() { - let found = block( - "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n", - ); - let parsed = parse_block(&found).unwrap(); - assert_eq!(parsed.children.len(), 1); - } - - #[test] - fn parse_block_handles_escapes_and_quotes() { - let found = block( - "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n", - ); - let parsed = parse_block(&found).unwrap(); - let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else { - panic!("main group"); - }; - let cst::SettingsNode::String { value, .. } = &children[0] else { - panic!("description"); - }; - assert_eq!(value, "line\n\t\"quoted\""); - } - - #[test] - fn parse_block_types_values() { - let found = block( - "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n", - ); - let parsed = parse_block(&found).unwrap(); - let lobby = match &parsed.children[0] { - cst::SettingsNode::Group { name, children, .. } => { - assert_eq!(name, "lobby"); - children - } - other => panic!("{other:?}"), - }; - assert!(matches!( - lobby[0], - cst::SettingsNode::Number { value: 6.0, .. } - )); - } - - #[test] - fn spans_are_computed_from_block_base() { - let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n"; - let found = block(text); - let parsed = parse_block(&found).unwrap(); - let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else { - panic!("lobby"); - }; - let cst::SettingsNode::Number { span, .. } = &children[0] else { - panic!("ffaSlots"); - }; - assert_eq!(span.start.line, 3); - assert_eq!(span.start.col, 9); - } - - #[test] - fn keyword_span_carries_the_file_id() { - let found = block("settings {\n \"gamemodes\": {}\n}\n"); - assert_eq!(found.keyword_span.file, 0); - assert_eq!(found.keyword_span.start.col, 1); - assert_eq!(found.keyword_span.end.col, 9); - } -} diff --git a/crates/wright-opy/tests/differential.rs b/crates/wright-opy/tests/differential.rs deleted file mode 100644 index 7422443..0000000 --- a/crates/wright-opy/tests/differential.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Native-vs-reference frontend differential suite (#46). -//! -//! Runs the declared production corpus through the native frontend and the -//! pinned OverPy adapter (recorded HIR fixtures) and compares at the Wright -//! HIR boundary. Normalization removes span endpoints, the producer -//! `generator` identity, and the adapter's `isFunction` key spelling — all -//! frontend-internal representation differences — while preserving node -//! structure, literal values, names, references, and control flow. Every -//! supported-surface divergence fails the suite (regressions break CI); the -//! diagnostics fixture asserts both frontends reject with a parse error. - -use std::path::{Path, PathBuf}; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -/// The corpus fixtures with native `.opy` sources and adapter HIR fixtures. -const PARITY_CASES: &[(&str, &str)] = &[ - ( - "synthetic/basic-rule", - "adapter/fixtures/synthetic/basic-rule.json", - ), - ( - "synthetic/control-flow", - "adapter/fixtures/synthetic/control-flow.json", - ), - ( - "synthetic/for-range-agentlab", - "adapter/fixtures/synthetic/for-range-agentlab.json", - ), - ( - "synthetic/declarations-numbers", - "adapter/fixtures/synthetic/declarations-numbers.json", - ), - ( - "synthetic/declarations-rules", - "adapter/fixtures/synthetic/declarations-rules.json", - ), - ( - "synthetic/expressions-values", - "adapter/fixtures/synthetic/expressions-values.json", - ), - ( - "synthetic/chase-enums", - "adapter/fixtures/synthetic/chase-enums.json", - ), - ( - "synthetic/chase-keywords", - "adapter/fixtures/synthetic/chase-keywords.json", - ), - ( - "synthetic/preprocessing", - "adapter/fixtures/synthetic/preprocessing.json", - ), - ( - "synthetic/settings", - "adapter/fixtures/synthetic/settings.json", - ), - ( - "real-world/overpy-cake", - "adapter/fixtures/real-world/overpy-cake.json", - ), -]; - -/// Remove spans and the producer identity: the documented normalization. -/// `name_span` is frontend-internal provenance (the adapter reference does -/// not carry exact identifier spans), so it is normalized away alongside the -/// other span endpoints. -fn normalize(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - map.remove("span"); - map.remove("name_span"); - map.remove("generator"); - for nested in map.values_mut() { - normalize(nested); - } - } - serde_json::Value::Array(items) => { - for item in items { - normalize(item); - } - } - _ => {} - } -} - -fn fixture_dir(id: &str) -> PathBuf { - workspace_root().join("compatibility/fixtures").join(id) -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())) -} - -#[test] -fn native_and_reference_agree_on_the_production_corpus() { - let mut report = serde_json::Map::new(); - let mut failures = Vec::new(); - - for (id, adapter_fixture) in PARITY_CASES { - let dir = fixture_dir(id); - let source = read(&dir.join("source.opy")); - let native = match wright_opy::compile(&source, "source.opy", &dir) { - Ok(program) => program, - Err(error) => { - failures.push(format!("{id}: native frontend error: {error}")); - report.insert( - id.to_string(), - serde_json::json!({ "status": "native-error", "error": error.to_string() }), - ); - continue; - } - }; - - // Reference side: the pinned adapter fixture, with the adapter's - // `isFunction` spelling normalized to the consumer's `is_function`. - let mut reference_value: serde_json::Value = - serde_json::from_str(&read(&workspace_root().join(adapter_fixture))).unwrap(); - if let Some(defines) = reference_value - .get_mut("defines") - .and_then(|d| d.as_array_mut()) - { - for define in defines { - if let Some(object) = define.as_object_mut() { - if let Some(value) = object.remove("isFunction") { - object.insert("is_function".into(), value); - } - } - } - } - let reference = match wright_core::hir::parse_value(reference_value) { - Ok(program) => program, - Err(error) => { - failures.push(format!( - "{id}: reference fixture cannot be consumed: {error}" - )); - continue; - } - }; - - let mut native_json = serde_json::to_value(&native).unwrap(); - let mut reference_json = serde_json::to_value(&reference).unwrap(); - normalize(&mut native_json); - normalize(&mut reference_json); - - if native_json == reference_json { - report.insert(id.to_string(), serde_json::json!({ "status": "parity" })); - } else { - failures.push(format!("{id}: HIR divergence")); - report.insert( - id.to_string(), - serde_json::json!({ "status": "divergence" }), - ); - let out_dir = workspace_root().join("target/wright-differential"); - std::fs::create_dir_all(&out_dir).unwrap(); - std::fs::write( - out_dir.join(format!("{}.native.json", id.replace('/', "-"))), - serde_json::to_string_pretty(&native_json).unwrap(), - ) - .unwrap(); - std::fs::write( - out_dir.join(format!("{}.reference.json", id.replace('/', "-"))), - serde_json::to_string_pretty(&reference_json).unwrap(), - ) - .unwrap(); - } - } - - // Diagnostics fixture: both frontends must reject with a parse error. - let diagnostics_dir = fixture_dir("synthetic/diagnostics"); - let source = read(&diagnostics_dir.join("source.opy")); - let native_diagnostic = wright_opy::compile(&source, "source.opy", &diagnostics_dir) - .expect_err("the diagnostics fixture must fail natively"); - assert_eq!( - native_diagnostic.code, "parse-error", - "the native frontend classifies missing-colon as parse-error" - ); - let fixture_manifest: serde_json::Value = - serde_json::from_str(&read(&diagnostics_dir.join("fixture.json"))).unwrap(); - let reference_status = fixture_manifest["expectedStatus"].as_str().unwrap(); - assert_eq!( - reference_status, "failure", - "oracle records expected failure" - ); - report.insert( - "synthetic/diagnostics".to_string(), - serde_json::json!({ - "status": "parity", - "native": { "code": native_diagnostic.code, "line": native_diagnostic.span.map(|s| s.start.line) }, - "reference": { "expectedStatus": reference_status }, - }), - ); - - // Machine-readable report for CI/release gating. - let report_path = workspace_root().join("target/wright-differential-report.json"); - std::fs::write( - &report_path, - serde_json::to_string_pretty(&serde_json::Value::Object(report)).unwrap(), - ) - .unwrap(); - - assert!( - failures.is_empty(), - "supported-surface divergences are not allowed:\n{}", - failures.join("\n") - ); -} diff --git a/crates/wright-opy/tests/reconstruct.rs b/crates/wright-opy/tests/reconstruct.rs deleted file mode 100644 index bb5c117..0000000 --- a/crates/wright-opy/tests/reconstruct.rs +++ /dev/null @@ -1,921 +0,0 @@ -//! WIR → OPY reconstruction round-trip suite (issue #124). -//! -//! Proves `Workshop → WIR → reconstructed OPY → native frontend → HIR → WIR` -//! semantic equivalence for every deterministic reconstruction fixture: -//! the native OPY frontend accepts the reconstructed source, the recompiled -//! WIR is structurally equivalent to the parsed Workshop program under -//! `workshop_rs::roundtrip::equivalent`, and the recompiled WIR still -//! emits to Workshop text through the shipped emitter (the trailing -//! `→ Workshop` hop). Determinism, the machine-readable support boundary, -//! and the explicit rejection surface are all asserted here through the -//! shipped API. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use workshop_rs::catalog::{Catalog, Locale}; -use workshop_rs::parser; -use workshop_rs::source::{Position, Span}; -use workshop_rs::wir::{self, Action, Event, ModifyOp, Program, Value, ValueNode}; - -/// (fixture id, constructs covered) — the machine-readable coverage map that -/// the support boundary consistency test cross-checks. -const ROUND_TRIP_FIXTURES: &[(&str, &[&str])] = &[ - ( - "variables-declarations", - &[ - "global-variable-declaration", - "player-variable-declaration", - "variable-index", - "global-initializer-rule", - "player-initializer-rule", - "subroutine-declaration", - "global-event", - "each-player-event", - "number-value", - "bool-value", - "global-variable-access", - "player-variable-access", - "binary-expression", - "set-global-variable", - "set-player-variable", - "call-subroutine", - ], - ), - ( - "subroutine-control-flow", - &[ - "subroutine-body", - "modify-global-variable", - "append-modify", - "modify-player-variable", - "if-statement", - "while-statement", - "for-range-loop", - "wait-action", - "call-subroutine", - ], - ), - ( - "player-events", - &[ - "each-player-event", - "condition", - "null-value", - "global-variable-access", - "player-variable-access", - "member-action", - "member-value-call", - "subroutine-body", - "set-player-variable", - ], - ), - ( - "values-enums", - &[ - "condition", - "string-value", - "null-value", - "enum-value", - "global-variable-access", - "player-variable-access", - "event-player-value", - "binary-expression", - "unary-expression", - "value-call", - "member-value-call", - ], - ), - ( - "actions-surface", - &[ - "wait-action", - "disable-inspector-action", - "play-effect-action", - "chase-over-time-action", - "subroutine-body", - "call-subroutine", - ], - ), -]; - -/// (test case, expected first diagnostic code, rejected constructs) — the -/// machine-readable rejection map for the support boundary. -const REJECTION_CASES: &[(&str, &str, &[&str])] = &[ - ( - "rejects_per_player_loop", - "unsupported-per-player-loop", - &["per-player-loop"], - ), - ( - "rejects_disabled_rule", - "unsupported-disabled-rule", - &["disabled-rule"], - ), - ( - "rejects_arbitrary_player_target", - "unsupported-arbitrary-player-target", - &["arbitrary-player-target"], - ), - ( - "rejects_unrepresentable_value_call", - "unsupported-value-call", - &["unrepresentable-value-call"], - ), - ( - "rejects_unrepresentable_action_call", - "unsupported-action-call", - &["unrepresentable-action-call"], - ), - ( - "rejects_dedicated_action_call", - "unsupported-action-call", - &["dedicated-action-call"], - ), - ( - "rejects_dedicated_value_call", - "unsupported-value-call", - &["dedicated-value-call"], - ), - ( - "rejects_invalid_identifier", - "unsupported-name", - &["invalid-identifier-name"], - ), - ( - "rejects_reserved_name", - "unsupported-name", - &["reserved-name"], - ), - ( - "rejects_duplicate_name", - "unsupported-duplicate-name", - &["duplicate-name"], - ), - ( - "rejects_negative_number", - "unsupported-negative-number", - &["negative-number"], - ), - ( - "rejects_non_finite_number", - "unsupported-non-finite-number", - &["non-finite-number"], - ), - ( - "rejects_unknown_enum_domain", - "unsupported-enum-domain", - &["unknown-enum-domain"], - ), - ( - "rejects_unknown_enum_member", - "unsupported-enum-member", - &["unknown-enum-member"], - ), - ( - "rejects_remove_from_array_modify", - "unsupported-modify-op", - &["remove-from-array-modify"], - ), - ( - "rejects_missing_argument", - "unsupported-missing-argument", - &["missing-argument"], - ), - ( - "rejects_invalid_arity", - "unsupported-invalid-arity", - &["invalid-arity"], - ), - ( - "rejects_enum_domain_mismatch", - "unsupported-enum-domain-mismatch", - &["enum-domain-mismatch"], - ), - ( - "rejects_invalid_argument", - "unsupported-invalid-argument", - &["invalid-argument"], - ), - ( - "rejects_set_with_same_variable_binary", - "unsupported-set-binary", - &["set-same-variable-binary"], - ), - ( - "rejects_rule_order", - "unsupported-rule-order", - &["rule-order"], - ), - ( - "rejects_non_canonical_init_rule", - "unsupported-init-rule", - &["non-canonical-init-rule"], - ), - ( - "rejects_subroutine_index_mismatch", - "unsupported-subroutine-index", - &["subroutine-index-mismatch"], - ), - ( - "rejects_unsorted_global_slots", - "unsupported-global-order", - &["global-index-order"], - ), - ( - "rejects_indexed_initializer", - "unsupported-indexed-initializer", - &["indexed-initializer"], - ), - ("rejects_settings", "unsupported-settings", &["settings"]), -]; - -// ---- support boundary ---- - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct BoundaryFile { - schema_version: u32, - supported: Vec, - rejected: Vec, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct BoundaryEntry { - id: String, - #[serde(default)] - unit_only: bool, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct RejectedEntry { - id: String, - code: String, -} - -fn boundary() -> BoundaryFile { - let path = fixtures_dir().join("boundary.json"); - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()) - .expect("boundary.json must parse") -} - -// ---- helpers ---- - -fn fixtures_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/reconstruct") -} - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -fn catalog() -> Catalog { - Catalog::builtin().unwrap() -} - -fn en() -> Locale { - Locale::new("en-US") -} - -fn span() -> Span { - Span::new( - wright_ir::ids::Id::from_index(0), - Position::new(1, 1), - Position::new(1, 1), - ) -} - -fn value(program: &mut Program, value: Value) -> wir::ValueId { - program.values.push(ValueNode::new(value, Some(span()))) -} - -fn number(program: &mut Program, number: f64) -> wir::ValueId { - value( - program, - Value::Number { - value: number, - text: wright_ir::format::format_number(number), - }, - ) -} - -fn global(program: &mut Program, name: &str) -> (wir::GlobalVarId, wir::ValueId) { - let id = program.global_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index: program.global_variables.len() as u32, - span: None, - name_span: None, - }); - let read = value(program, Value::GlobalVariable(id)); - (id, read) -} - -fn event_player(program: &mut Program) -> wir::ValueId { - value(program, Value::EventPlayer) -} - -fn player(program: &mut Program, name: &str) -> (wir::PlayerVarId, wir::ValueId) { - let id = program.player_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index: program.player_variables.len() as u32, - span: None, - name_span: None, - }); - let player = event_player(program); - let read = value( - program, - Value::PlayerVariable { - player, - variable: id, - }, - ); - (id, read) -} - -fn enum_value(program: &mut Program, domain: &str, member: &str) -> wir::ValueId { - value( - program, - Value::Enum { - value_type: domain.to_string(), - value: member.to_string(), - }, - ) -} - -fn global_rule(program: &mut Program, name: &str, actions: Vec) { - program.rules.push(wir::Rule { - name: name.to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Global, - conditions: Vec::new(), - actions, - }); -} - -fn reconstruct(program: &Program) -> Result { - wright_opy::reconstruct::reconstruct(program) -} - -// ---- round-trip suite ---- - -#[test] -fn every_fixture_round_trips_through_the_shipped_path() { - let catalog = catalog(); - let locale = en(); - let mut report: BTreeMap = BTreeMap::new(); - let mut failures = Vec::new(); - - for (fixture, _constructs) in ROUND_TRIP_FIXTURES { - let source = std::fs::read_to_string(fixtures_dir().join(format!("{fixture}.ws"))).unwrap(); - let parsed = match parser::parse(&source, &catalog, &locale) { - Ok(program) => program, - Err(error) => { - failures.push(format!("{fixture}: Workshop parse failed: {error}")); - continue; - } - }; - let opy = match wright_opy::reconstruct::reconstruct(&parsed) { - Ok(opy) => opy, - Err(error) => { - failures.push(format!("{fixture}: reconstruction failed: {error}")); - continue; - } - }; - // The native frontend must accept the reconstructed OPY… - let hir = match wright_opy::compile(&opy, &format!("{fixture}.opy"), Path::new("")) { - Ok(hir) => hir, - Err(error) => { - failures.push(format!( - "{fixture}: the native frontend rejected the reconstructed OPY: {error}" - )); - continue; - } - }; - // …and the recompiled WIR must be equivalent to the parsed WIR. - let recompiled = match wright_ir::lower::lower(&hir.to_ir().unwrap()) { - Ok(program) => program, - Err(error) => { - failures.push(format!("{fixture}: re-lowering failed: {error}")); - continue; - } - }; - let equivalent = workshop_rs::roundtrip::equivalent(&parsed, &recompiled); - // The trailing `→ Workshop` hop: the recompiled WIR still emits to - // Workshop text through the shipped emitter. - let workshop_emit = workshop_rs::emitter::emit(&recompiled, &catalog, &locale) - .map(|_| ()) - .map_err(|error| error.to_string()); - if !equivalent { - failures.push(format!("{fixture}: recompiled WIR is not equivalent")); - } - if let Err(error) = &workshop_emit { - failures.push(format!( - "{fixture}: Workshop emission of the recompiled WIR failed: {error}" - )); - } - - let report_entry = serde_json::json!({ - "input": source, - "inputSha256": sha256(&source), - "reconstructedOpy": opy, - "frontendAccepted": true, - "equivalent": equivalent, - "workshopEmit": workshop_emit.is_ok(), - }); - report.insert(fixture.to_string(), report_entry); - - // Evidence: one reconstructed OPY source per fixture. - let out_dir = workspace_root().join("target/wright-reconstruction"); - std::fs::create_dir_all(&out_dir).unwrap(); - std::fs::write(out_dir.join(format!("{fixture}.opy")), &opy).unwrap(); - } - - let report_path = workspace_root().join("target/wright-reconstruction-report.json"); - std::fs::create_dir_all(report_path.parent().unwrap()).unwrap(); - std::fs::write( - &report_path, - serde_json::to_string_pretty(&serde_json::Value::Object(report.into_iter().collect())) - .unwrap(), - ) - .unwrap(); - - assert!( - failures.is_empty(), - "every reconstruction fixture must round-trip:\n{}", - failures.join("\n") - ); -} - -#[test] -fn reconstruction_is_deterministic() { - let catalog = catalog(); - let locale = en(); - for (fixture, _constructs) in ROUND_TRIP_FIXTURES { - let source = std::fs::read_to_string(fixtures_dir().join(format!("{fixture}.ws"))).unwrap(); - let parsed = parser::parse(&source, &catalog, &locale).unwrap(); - let first = wright_opy::reconstruct::reconstruct(&parsed).unwrap(); - let second = wright_opy::reconstruct::reconstruct(&parsed).unwrap(); - assert_eq!( - first, second, - "{fixture}: reconstruction must be byte-stable" - ); - } -} - -// ---- support boundary ---- - -#[test] -fn support_boundary_is_consistent_with_the_tests() { - let boundary = boundary(); - assert_eq!(boundary.schema_version, 1); - - let supported: std::collections::HashSet<&str> = boundary - .supported - .iter() - .map(|entry| entry.id.as_str()) - .collect(); - let _rejected: std::collections::HashSet<&str> = boundary - .rejected - .iter() - .map(|entry| entry.id.as_str()) - .collect(); - - let mut fixture_constructs: Vec<&str> = Vec::new(); - for (_fixture, constructs) in ROUND_TRIP_FIXTURES { - for construct in *constructs { - assert!( - supported.contains(*construct), - "fixture construct '{construct}' is missing from the support boundary" - ); - fixture_constructs.push(construct); - } - } - let mut rejection_constructs: Vec<&str> = Vec::new(); - for (_case, code, constructs) in REJECTION_CASES { - for construct in *constructs { - let entry = boundary - .rejected - .iter() - .find(|entry| entry.id == *construct) - .unwrap_or_else(|| { - panic!("rejection construct '{construct}' is missing from the boundary") - }); - assert_eq!( - entry.code, *code, - "rejection construct '{construct}' records code '{}' but the test expects '{code}'", - entry.code - ); - rejection_constructs.push(construct); - } - } - - // No silent coverage drift: every fixture-covered supported construct is - // exercised by a fixture, and every rejected construct is exercised by a - // rejection case. - for entry in &boundary.supported { - if entry.unit_only { - continue; - } - assert!( - fixture_constructs.contains(&entry.id.as_str()), - "supported construct '{}' is covered by no fixture", - entry.id - ); - } - for entry in &boundary.rejected { - assert!( - rejection_constructs.contains(&entry.id.as_str()), - "rejected construct '{}' is covered by no rejection case", - entry.id - ); - } -} - -// ---- explicit rejection ---- - -#[test] -fn non_representable_constructs_fail_deterministically() { - for (case, expected_code, _constructs) in REJECTION_CASES { - let program = rejection_program(case); - let error = match reconstruct(&program) { - Ok(_) => panic!("{case}: the program must be rejected"), - Err(error) => error, - }; - let first = &error.issues[0]; - assert_eq!( - first.code, *expected_code, - "{case}: expected code '{}' but got '{}' with message '{}'", - expected_code, first.code, first.message - ); - assert!( - !first.message.is_empty(), - "{case}: the diagnostic must name the construct" - ); - } -} - -/// Build the minimal WIR program for one rejection case. -fn rejection_program(case: &str) -> Program { - let mut program = Program::default(); - match case { - "rejects_per_player_loop" => { - let (has_started, _) = player(&mut program, "hasStarted"); - let loop_player = event_player(&mut program); - let loop_start = number(&mut program, 0.0); - let loop_stop = number(&mut program, 3.0); - let loop_step = number(&mut program, 1.0); - let action = program.actions.push(Action::ForPlayerVariable { - player: loop_player, - variable: has_started, - start: loop_start, - stop: loop_stop, - step: loop_step, - body: Vec::new(), - span: None, - }); - global_rule(&mut program, "loop", vec![action]); - } - "rejects_disabled_rule" => { - program.rules.push(wir::Rule { - name: "off".to_string(), - span: None, - name_span: None, - disabled: true, - event: Event::Global, - conditions: Vec::new(), - actions: Vec::new(), - }); - } - "rejects_arbitrary_player_target" => { - let (result, _) = global(&mut program, "result"); - let (has_started, _) = player(&mut program, "hasStarted"); - let player_expr = value(&mut program, Value::GlobalVariable(result)); - let one = number(&mut program, 1.0); - let set = program.actions.push(Action::SetPlayerVariable { - player: player_expr, - variable: has_started, - value: one, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_unrepresentable_value_call" => { - let (result, _) = global(&mut program, "result"); - let (_, has_started) = player(&mut program, "hasStarted"); - let call = value( - &mut program, - Value::Call { - name: "countOf".to_string(), - args: vec![has_started], - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: call, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_unrepresentable_action_call" => { - let action = program.actions.push(Action::Call { - name: "createBeamEffect".to_string(), - args: Vec::new(), - span: None, - }); - global_rule(&mut program, "main", vec![action]); - } - "rejects_dedicated_action_call" => { - let one = number(&mut program, 1.0); - let action = program.actions.push(Action::Call { - name: "debug".to_string(), - args: vec![one], - span: None, - }); - global_rule(&mut program, "main", vec![action]); - } - "rejects_dedicated_value_call" => { - let (result, _) = global(&mut program, "result"); - let one = number(&mut program, 1.0); - let call = value( - &mut program, - Value::Call { - name: "vect".to_string(), - args: vec![one], - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: call, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_invalid_identifier" => { - program.global_variables.push(wir::WorkshopVariable { - name: "two words".to_string(), - index: 0, - span: None, - name_span: None, - }); - } - "rejects_reserved_name" => { - program.global_variables.push(wir::WorkshopVariable { - name: "if".to_string(), - index: 0, - span: None, - name_span: None, - }); - } - "rejects_duplicate_name" => { - for index in 0..2 { - program.global_variables.push(wir::WorkshopVariable { - name: "dup".to_string(), - index, - span: None, - name_span: None, - }); - } - } - "rejects_negative_number" => { - let (result, _) = global(&mut program, "result"); - let negative = number(&mut program, -1.0); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: negative, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_non_finite_number" => { - let (result, _) = global(&mut program, "result"); - let nan = value( - &mut program, - Value::Number { - value: f64::NAN, - text: "NaN".to_string(), - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: nan, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_unknown_enum_domain" => { - let (result, _) = global(&mut program, "result"); - let enum_value = enum_value(&mut program, "NotADomain", "X"); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: enum_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_unknown_enum_member" => { - let (result, _) = global(&mut program, "result"); - let enum_value = enum_value(&mut program, "Color", "CYAN"); - let set = program.actions.push(Action::SetGlobalVariable { - variable: result, - value: enum_value, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_remove_from_array_modify" => { - let (score, _) = global(&mut program, "score"); - let one = number(&mut program, 1.0); - let modify = program.actions.push(Action::ModifyGlobalVariable { - variable: score, - op: ModifyOp::RemoveFromArray, - value: one, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![modify]); - } - "rejects_missing_argument" => { - let one = number(&mut program, 1.0); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![one], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - } - "rejects_invalid_arity" => { - let one = number(&mut program, 1.0); - let ignore = enum_value(&mut program, "Wait", "IGNORE_CONDITION"); - let two = number(&mut program, 2.0); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![one, ignore, two], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - } - "rejects_enum_domain_mismatch" => { - let one = number(&mut program, 1.0); - let yellow = enum_value(&mut program, "Color", "YELLOW"); - let wait = program.actions.push(Action::Call { - name: "wait".to_string(), - args: vec![one, yellow], - span: None, - }); - global_rule(&mut program, "main", vec![wait]); - } - "rejects_invalid_argument" => { - let ten = number(&mut program, 10.0); - let three = number(&mut program, 3.0); - let none = enum_value(&mut program, "ChaseTimeReeval", "NONE"); - let chase = program.actions.push(Action::Call { - name: "chaseOverTime".to_string(), - args: vec![ten, ten, three, none], - span: None, - }); - global_rule(&mut program, "main", vec![chase]); - } - "rejects_set_with_same_variable_binary" => { - let (score, score_read) = global(&mut program, "score"); - let one = number(&mut program, 1.0); - let sum = value( - &mut program, - Value::Call { - name: "+".to_string(), - args: vec![score_read, one], - }, - ); - let set = program.actions.push(Action::SetGlobalVariable { - variable: score, - value: sum, - span: None, - target_span: None, - }); - global_rule(&mut program, "main", vec![set]); - } - "rejects_rule_order" => { - let sub_id = program.subroutines.push(wir::WorkshopSubroutine { - name: "tick".to_string(), - index: 0, - span: None, - name_span: None, - }); - global_rule(&mut program, "normal", Vec::new()); - program.rules.push(wir::Rule { - name: "Subroutine tick".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Subroutine(sub_id), - conditions: Vec::new(), - actions: Vec::new(), - }); - } - "rejects_non_canonical_init_rule" => { - let (score, _) = global(&mut program, "score"); - let one = number(&mut program, 1.0); - let set = program.actions.push(Action::SetGlobalVariable { - variable: score, - value: one, - span: None, - target_span: None, - }); - let call = program.actions.push(Action::Call { - name: "disableInspector".to_string(), - args: Vec::new(), - span: None, - }); - program.rules.push(wir::Rule { - name: "Initialize global variables".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Global, - conditions: Vec::new(), - actions: vec![set, call], - }); - } - "rejects_subroutine_index_mismatch" => { - program.subroutines.push(wir::WorkshopSubroutine { - name: "tick".to_string(), - index: 5, - span: None, - name_span: None, - }); - } - "rejects_unsorted_global_slots" => { - program.global_variables.push(wir::WorkshopVariable { - name: "a".to_string(), - index: 5, - span: None, - name_span: None, - }); - program.global_variables.push(wir::WorkshopVariable { - name: "b".to_string(), - index: 0, - span: None, - name_span: None, - }); - } - "rejects_indexed_initializer" => { - // `a` claims the lowest free slot 0 without an initializer; - // initializer-bearing `b` at slot 5 cannot be spelled in OPY - // (the `globalvar name = value` form drops the index and would - // re-lower `b` to slot 1). - program.global_variables.push(wir::WorkshopVariable { - name: "a".to_string(), - index: 0, - span: None, - name_span: None, - }); - let b = program.global_variables.push(wir::WorkshopVariable { - name: "b".to_string(), - index: 5, - span: None, - name_span: None, - }); - let init_value = number(&mut program, 5.0); - let set = program.actions.push(Action::SetGlobalVariable { - variable: b, - value: init_value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "Initialize global variables".to_string(), - span: None, - name_span: None, - disabled: false, - event: Event::Global, - conditions: Vec::new(), - actions: vec![set], - }); - } - "rejects_settings" => { - program.settings = Some(workshop_rs::settings::Settings { - span: None, - children: Vec::new(), - }); - } - other => panic!("unknown rejection case '{other}'"), - } - program -} - -fn sha256(input: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(input.as_bytes()); - format!("{:x}", hasher.finalize()) -} diff --git a/crates/wright-ostw/Cargo.toml b/crates/wright-ostw/Cargo.toml index 219ce65..9d1ac7e 100644 --- a/crates/wright-ostw/Cargo.toml +++ b/crates/wright-ostw/Cargo.toml @@ -4,12 +4,13 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "Wright's native OSTW frontend: lexer, CST/parser, and ds.toml project model (syntax/project infrastructure only)." +description = "Wright's narrow adapter for the owner-side del-rs implementation." [lints] workspace = true [dependencies] +del-rs.workspace = true wright-ir.workspace = true # Single pinned reference: `[workspace.dependencies]` in the root Cargo.toml. workshop-rs.workspace = true diff --git a/crates/wright-ostw/src/cst.rs b/crates/wright-ostw/src/cst.rs deleted file mode 100644 index 1e5919e..0000000 --- a/crates/wright-ostw/src/cst.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! The frontend's concrete syntax tree (CST). -//! -//! Source-preserving syntax structure with a span on every node, produced by -//! [`crate::parser`]. This task ships syntax/project infrastructure only: -//! names, members, calls, and types remain unresolved syntax nodes until the -//! #118 semantic/type system exists. The tree covers the syntax forms the -//! committed protect-ban corpus exercises; other OSTW forms are rejected -//! explicitly by the parser rather than guessed. - -use workshop_rs::source::Span; - -/// A parsed source file: imports and top-level items. -#[derive(Debug, Clone)] -pub struct File { - pub imports: Vec, - pub items: Vec, - pub span: Span, -} - -/// A quoted `import "path";` statement. -#[derive(Debug, Clone)] -pub struct Import { - /// The quoted path exactly as written (forward-slash relative). - pub path: String, - pub span: Span, -} - -/// One top-level item. -#[derive(Debug, Clone)] -pub enum Item { - GlobalVar(VarDecl), - PlayerVar(VarDecl), - /// `define name: expr;` or `define name(params): expr;`. - Define(DefineDecl), - /// `Type name: expr;` or `Type name(params): expr;` (non-`define`). - TypedDecl(TypedDecl), - /// `[Type] name(params) [rule-name] { body }`. - Function(FunctionDecl), - Enum(EnumDecl), - Rule(RuleDecl), - Class(ClassDecl), -} - -/// A `globalvar` / `playervar` declaration. -#[derive(Debug, Clone)] -pub struct VarDecl { - /// The declared type, when present (`Number`, `Hero[]`, `Team | Number`, - /// `define`, `Any`, ...). - pub type_name: Option, - pub name: String, - /// The explicit variable index form (`globalvar Number i 127;`). - pub index: Option, - /// The initializer, when present (`= expr`). - pub value: Option, - pub span: Span, - /// The exact span of the declared identifier (the rename target, #129). - pub name_span: Span, -} - -/// A `define` constant or define-macro. -#[derive(Debug, Clone)] -pub struct DefineDecl { - pub name: String, - /// The parameters, when the define is function-like. - pub params: Vec, - pub value: Expr, - pub span: Span, -} - -/// A typed non-`define` declaration (`Number x: 1;`, -/// `Number TeamIndex(Team team): expr;`). -#[derive(Debug, Clone)] -pub struct TypedDecl { - pub type_name: TypeRef, - pub name: String, - /// Present when the declaration is function-like. - pub params: Option>, - pub value: Expr, - pub span: Span, -} - -/// A brace-bodied function declaration (`void f(params) { ... }`, with the -/// optional quoted rule-name between the parameter list and the body). -#[derive(Debug, Clone)] -pub struct FunctionDecl { - pub return_type: Option, - pub name: String, - pub params: Vec, - /// The optional quoted rule name (`void f() "Rule name" { ... }`). - pub rule_name: Option, - pub body: Vec, - pub span: Span, - /// The exact span of the declared identifier (the rename target, #129). - pub name_span: Span, -} - -/// An `enum Name { Member, ... }` declaration. -#[derive(Debug, Clone)] -pub struct EnumDecl { - pub name: String, - pub members: Vec, - pub span: Span, -} - -/// A `rule: "name" ... { body }` declaration with its modifiers. -#[derive(Debug, Clone)] -pub struct RuleDecl { - pub disabled: bool, - /// The quoted rule name, when present. - pub name: Option, - /// The exact span of the quoted rule-name content (the rename target, - /// #129), when a name is present. - pub name_span: Option, - /// The rule priority (`rule: "name" -1`), when present. - pub priority: Option, - /// The event expression (`Event.OngoingPlayer`), when present. - pub event: Option, - /// The `if (expr)` conditions preceding the body. - pub conditions: Vec, - pub body: Vec, - pub span: Span, -} - -/// A `class Name { ... }` declaration (syntax only; no class semantics). -#[derive(Debug, Clone)] -pub struct ClassDecl { - pub name: String, - pub members: Vec, - pub span: Span, -} - -/// One member of a class body. -#[derive(Debug, Clone)] -pub enum ClassMember { - /// `public Type name;`. - Field { - type_name: TypeRef, - name: String, - span: Span, - }, - /// `public constructor(params) { body }`. - Constructor { - params: Vec, - body: Vec, - span: Span, - }, - /// `public Type name(params): expr;` or `public Type name(params) { body }`. - Method { - type_name: TypeRef, - name: String, - params: Vec, - value: Option, - body: Option>, - span: Span, - }, -} - -/// A type reference: a name with optional array depth and pipe unions. -#[derive(Debug, Clone)] -pub struct TypeRef { - pub name: String, - /// The number of trailing `[]` array markers. - pub array_depth: u32, - /// Additional union alternatives (`A | B`). - pub unions: Vec, - pub span: Span, -} - -/// A function/define parameter. -#[derive(Debug, Clone)] -pub struct Param { - /// The parameter type, when declared (`Number`, `Button[]`, `define`). - pub type_name: Option, - pub name: String, - /// The default value (`= expr`), when present. - pub default: Option, - pub span: Span, -} - -/// A statement inside a rule/function/class body. -#[derive(Debug, Clone)] -pub enum Stmt { - /// An expression statement (`f();`, `Wait(1);`). - Expr { - expr: Expr, - span: Span, - }, - /// An assignment (`x = e;`, `x += e;`, `x -= e;`). - Assign { - target: Expr, - op: AssignOp, - value: Expr, - span: Span, - }, - If { - branches: Vec, - else_body: Option>, - span: Span, - }, - /// `for (init; condition; increment) { body }`. - For { - init: Option, - condition: Option, - increment: Option, - body: Vec, - span: Span, - }, - /// `foreach (Type var in iterable) { body }`. - Foreach { - var_type: Option, - var: String, - iterable: Expr, - body: Vec, - span: Span, - }, - While { - condition: Expr, - body: Vec, - span: Span, - }, - Switch { - value: Expr, - cases: Vec, - span: Span, - }, - /// A local `define name = expr;` inside a body. - LocalDefine { - name: String, - value: Expr, - span: Span, - }, - /// A local typed declaration inside a body (`Any x = expr;`). - LocalDecl { - type_name: TypeRef, - name: String, - value: Expr, - span: Span, - }, - Return { - value: Option, - span: Span, - }, - Break { - span: Span, - }, - Continue { - span: Span, - }, - /// A bare block `{ ... }`. - Block { - body: Vec, - span: Span, - }, -} - -/// One `if`/`else if` branch. -#[derive(Debug, Clone)] -pub struct IfBranch { - pub condition: Expr, - pub body: Vec, - pub span: Span, -} - -/// One `case`/`default` arm of a `switch`. -#[derive(Debug, Clone)] -pub struct SwitchCase { - /// The case value expression; `None` for `default`. - pub value: Option, - /// The arm body; `None` for a fallthrough marker with no statements yet. - pub body: Vec, - pub span: Span, -} - -/// An expression. -#[derive(Debug, Clone)] -pub enum Expr { - Number { - value: f64, - text: String, - span: Span, - }, - /// A `"..."` string with its unescaped value. - String { - value: String, - span: Span, - }, - /// An `@"..."` verbatim string with its unescaped value. - VerbatimString { - value: String, - span: Span, - }, - Ident { - name: String, - span: Span, - }, - Null { - span: Span, - }, - Bool { - value: bool, - span: Span, - }, - /// `receiver.name` (any depth). - Member { - receiver: Box, - name: String, - span: Span, - }, - /// `callee(args)` with positional and named arguments. - Call { - callee: Box, - args: Vec, - span: Span, - }, - /// `array[index]`. - Index { - array: Box, - index: Box, - span: Span, - }, - Array { - elements: Vec, - span: Span, - }, - /// `<"format", args...>` string interpolation. - FormatString { - format: Box, - args: Vec, - span: Span, - }, - /// `value` type cast (syntax only). - Cast { - type_name: TypeRef, - value: Box, - span: Span, - }, - Unary { - op: UnaryOp, - operand: Box, - span: Span, - }, - Binary { - op: BinaryOp, - left: Box, - right: Box, - span: Span, - }, - Ternary { - condition: Box, - then_value: Box, - else_value: Box, - span: Span, - }, - /// `new Type(args)`. - New { - type_name: String, - args: Vec, - span: Span, - }, - /// An assignment in expression position (for-loop initializers). - Assign { - target: Box, - op: AssignOp, - value: Box, - span: Span, - }, - /// `i++` / `i--`. - Postfix { - op: PostfixOp, - operand: Box, - span: Span, - }, -} - -/// One call argument: positional or named (`Name: value`). -#[derive(Debug, Clone)] -pub enum CallArg { - Positional { - value: Expr, - span: Span, - }, - Named { - name: String, - value: Expr, - span: Span, - }, -} - -/// The unary operators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnaryOp { - Negate, - Not, -} - -/// The binary operators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BinaryOp { - Add, - Subtract, - Multiply, - Divide, - Modulo, - Power, - Equal, - NotEqual, - Less, - LessEqual, - Greater, - GreaterEqual, - And, - Or, -} - -/// The assignment operators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AssignOp { - Assign, - AddAssign, - SubtractAssign, - MultiplyAssign, - DivideAssign, - ModuloAssign, -} - -/// The postfix operators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PostfixOp { - Increment, - Decrement, -} diff --git a/crates/wright-ostw/src/diag.rs b/crates/wright-ostw/src/diag.rs deleted file mode 100644 index 8969dca..0000000 --- a/crates/wright-ostw/src/diag.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Frontend diagnostics: structured, source-located failures. -//! -//! Every frontend failure is a [`FrontendError`] with a stable `code`, a -//! human message, and an optional source span. Spans use the shared -//! `workshop_rs::source` registry types, so the driver maps them into the -//! `wright-result/v1` diagnostic contract with the same provenance rules as -//! the other frontends; wording is not part of the machine contract. - -use workshop_rs::source::Span; - -/// A structured frontend error. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrontendError { - /// A stable machine-readable code, e.g. `ostw-parse-error`. - pub code: String, - /// Human-readable message (not part of the machine contract). - pub message: String, - /// The offending source region, when known. - pub span: Option, -} - -/// A crate-wide result alias. -pub type FrontendResult = Result; - -impl FrontendError { - /// An error without a source span. - pub fn new(code: impl Into, message: impl Into) -> FrontendError { - FrontendError { - code: code.into(), - message: message.into(), - span: None, - } - } - - /// An error at a source position. - pub fn at(code: impl Into, message: impl Into, span: Span) -> FrontendError { - FrontendError { - code: code.into(), - message: message.into(), - span: Some(span), - } - } -} - -impl std::fmt::Display for FrontendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for FrontendError {} diff --git a/crates/wright-ostw/src/lexer.rs b/crates/wright-ostw/src/lexer.rs deleted file mode 100644 index 318738f..0000000 --- a/crates/wright-ostw/src/lexer.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! The native OSTW lexer. -//! -//! Produces a flat token stream from one source file. OSTW is a -//! brace/semicolon language (not indentation-based), so newlines are -//! insignificant and skipped with other whitespace. Comments (`#`, `//`, -//! `/* */`) are skipped; string literals preserve their unescaped value and -//! exact span. Positions are 1-based line/column, matching the shared -//! `workshop_rs::source` registry. - -use workshop_rs::source::{FileId, Position, Span}; - -use crate::diag::{FrontendError, FrontendResult}; - -/// The kind of a token. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TokenKind { - /// An identifier or keyword (keywords are resolved by the parser). - Ident, - /// A numeric literal (`text` holds the source spelling). - Number, - /// A `"..."` string (`text` holds the unescaped value). - String, - /// An `@"..."` verbatim string (`text` holds the unescaped value). - VerbatimString, - /// End of file. - Eof, - // Punctuation and operators. - LParen, - RParen, - LBracket, - RBracket, - LBrace, - RBrace, - Comma, - Colon, - Semi, - Dot, - Pipe, - At, - Assign, - PlusAssign, - MinusAssign, - StarAssign, - SlashAssign, - PercentAssign, - Plus, - Minus, - Star, - Slash, - Percent, - Power, - PlusPlus, - MinusMinus, - Eq, - Ne, - Lt, - Le, - Gt, - Ge, - And, - Or, - Bang, - Question, -} - -/// One token with its source span and payload text. -#[derive(Debug, Clone, PartialEq)] -pub struct Token { - pub kind: TokenKind, - /// The source text of this token (numbers keep their spelling; strings - /// keep their unescaped value; identifiers keep their name). - pub text: String, - pub span: Span, -} - -impl Token { - fn new(kind: TokenKind, text: impl Into, span: Span) -> Token { - Token { - kind, - text: text.into(), - span, - } - } -} - -/// The lexer input: one file's text with its file id. -pub struct LexInput<'a> { - pub file_id: FileId, - pub text: &'a str, -} - -/// Lex one source file into a token stream. -pub fn lex(input: LexInput<'_>) -> FrontendResult> { - Lexer::new(input.file_id, input.text).run() -} - -struct Lexer { - file_id: FileId, - chars: Vec, - pos: usize, - line: u32, - col: u32, - tokens: Vec, -} - -impl Lexer { - fn new(file_id: FileId, text: &str) -> Lexer { - Lexer { - file_id, - chars: text.chars().collect(), - pos: 0, - line: 1, - col: 1, - tokens: Vec::new(), - } - } - - fn run(mut self) -> FrontendResult> { - while self.pos < self.chars.len() { - let ch = self.chars[self.pos]; - match ch { - ' ' | '\t' | '\r' | '\n' => self.advance(), - '#' => self.line_comment(), - '/' if self.peek(1) == Some('/') => self.line_comment(), - '/' if self.peek(1) == Some('*') => self.block_comment()?, - 'a'..='z' | 'A'..='Z' | '_' => self.identifier(), - '0'..='9' => self.number(), - '"' => self.string(false)?, - '@' if self.peek(1) == Some('"') => { - self.advance(); - self.string(true)?; - } - '(' => self.punct(TokenKind::LParen, 1), - ')' => self.punct(TokenKind::RParen, 1), - '[' => self.punct(TokenKind::LBracket, 1), - ']' => self.punct(TokenKind::RBracket, 1), - '{' => self.punct(TokenKind::LBrace, 1), - '}' => self.punct(TokenKind::RBrace, 1), - ',' => self.punct(TokenKind::Comma, 1), - ':' => self.punct(TokenKind::Colon, 1), - ';' => self.punct(TokenKind::Semi, 1), - '.' => self.punct(TokenKind::Dot, 1), - '|' if self.peek(1) == Some('|') => self.punct(TokenKind::Or, 2), - '|' => self.punct(TokenKind::Pipe, 1), - '@' => self.punct(TokenKind::At, 1), - '=' if self.peek(1) == Some('=') => self.punct(TokenKind::Eq, 2), - '=' => self.punct(TokenKind::Assign, 1), - '+' if self.peek(1) == Some('=') => self.punct(TokenKind::PlusAssign, 2), - '+' if self.peek(1) == Some('+') => self.punct(TokenKind::PlusPlus, 2), - '+' => self.punct(TokenKind::Plus, 1), - '-' if self.peek(1) == Some('=') => self.punct(TokenKind::MinusAssign, 2), - '-' if self.peek(1) == Some('-') => self.punct(TokenKind::MinusMinus, 2), - '-' => self.punct(TokenKind::Minus, 1), - '*' if self.peek(1) == Some('=') => self.punct(TokenKind::StarAssign, 2), - '*' => self.punct(TokenKind::Star, 1), - '/' if self.peek(1) == Some('=') => self.punct(TokenKind::SlashAssign, 2), - '/' => self.punct(TokenKind::Slash, 1), - '%' if self.peek(1) == Some('=') => self.punct(TokenKind::PercentAssign, 2), - '%' => self.punct(TokenKind::Percent, 1), - '^' => self.punct(TokenKind::Power, 1), - '!' if self.peek(1) == Some('=') => self.punct(TokenKind::Ne, 2), - '!' => self.punct(TokenKind::Bang, 1), - '<' if self.peek(1) == Some('=') => self.punct(TokenKind::Le, 2), - '<' => self.punct(TokenKind::Lt, 1), - '>' if self.peek(1) == Some('=') => self.punct(TokenKind::Ge, 2), - '>' => self.punct(TokenKind::Gt, 1), - '&' if self.peek(1) == Some('&') => self.punct(TokenKind::And, 2), - '?' => self.punct(TokenKind::Question, 1), - other => { - return Err(self.error_at( - "ostw-lex-error", - format!("unexpected character '{other}'"), - 1, - )); - } - } - } - let here = self.here(0); - let span = Span::new(self.file_id, here, here); - self.tokens.push(Token::new(TokenKind::Eof, "", span)); - Ok(self.tokens) - } - - fn identifier(&mut self) { - let start = self.here(0); - let mut text = String::new(); - while self.pos < self.chars.len() { - let ch = self.chars[self.pos]; - if ch.is_ascii_alphanumeric() || ch == '_' { - text.push(ch); - self.advance(); - } else { - break; - } - } - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::Ident, - text, - Span::new(self.file_id, start, end), - )); - } - - fn number(&mut self) { - let start = self.here(0); - let mut text = String::new(); - while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { - text.push(self.chars[self.pos]); - self.advance(); - } - if self.pos < self.chars.len() - && self.chars[self.pos] == '.' - && self.peek(1).is_some_and(|c| c.is_ascii_digit()) - { - text.push('.'); - self.advance(); - while self.pos < self.chars.len() && self.chars[self.pos].is_ascii_digit() { - text.push(self.chars[self.pos]); - self.advance(); - } - } - let end = self.here(0); - self.tokens.push(Token::new( - TokenKind::Number, - text, - Span::new(self.file_id, start, end), - )); - } - - fn string(&mut self, verbatim: bool) -> FrontendResult<()> { - let start = self.here(0); - // Consume the opening quote (the `@` was already consumed). - self.advance(); - let mut value = String::new(); - loop { - if self.pos >= self.chars.len() { - return Err(self.error_at( - "ostw-lex-error", - "unterminated string literal", - self.chars.len().saturating_sub(1), - )); - } - let ch = self.chars[self.pos]; - if ch == '"' { - self.advance(); - break; - } - if ch == '\\' { - // Escapes: advance past the backslash and translate. - self.advance(); - if self.pos >= self.chars.len() { - return Err(self.error_at( - "ostw-lex-error", - "unterminated string escape", - self.chars.len().saturating_sub(1), - )); - } - let escaped = self.chars[self.pos]; - self.advance(); - match escaped { - 'n' => value.push('\n'), - 't' => value.push('\t'), - 'r' => value.push('\r'), - '"' => value.push('"'), - '\\' => value.push('\\'), - '\'' => value.push('\''), - other => { - value.push('\\'); - value.push(other); - } - } - continue; - } - value.push(ch); - self.advance(); - } - let end = self.here(0); - let kind = if verbatim { - TokenKind::VerbatimString - } else { - TokenKind::String - }; - self.tokens - .push(Token::new(kind, value, Span::new(self.file_id, start, end))); - Ok(()) - } - - fn line_comment(&mut self) { - while self.pos < self.chars.len() && self.chars[self.pos] != '\n' { - self.advance(); - } - } - - fn block_comment(&mut self) -> FrontendResult<()> { - self.advance(); - self.advance(); - loop { - if self.pos >= self.chars.len() { - return Err(FrontendError::at( - "ostw-lex-error", - "unterminated block comment", - Span::new(self.file_id, self.here(0), self.here(1)), - )); - } - if self.chars[self.pos] == '*' && self.peek(1) == Some('/') { - self.advance(); - self.advance(); - return Ok(()); - } - self.advance(); - } - } - - fn punct(&mut self, kind: TokenKind, len: usize) { - let start = self.here(0); - for _ in 0..len { - self.advance(); - } - let end = self.here(0); - self.tokens - .push(Token::new(kind, "", Span::new(self.file_id, start, end))); - } - - fn peek(&self, offset: usize) -> Option { - self.chars.get(self.pos + offset).copied() - } - - fn advance(&mut self) { - if self.pos >= self.chars.len() { - return; - } - if self.chars[self.pos] == '\n' { - self.line += 1; - self.col = 1; - } else { - self.col += 1; - } - self.pos += 1; - } - - fn here(&self, offset: usize) -> Position { - Position::new(self.line, self.col + offset as u32) - } - - fn error_at( - &self, - code: &str, - message: impl Into, - char_offset: usize, - ) -> FrontendError { - let mut line = self.line; - let mut col = self.col; - // Walk `char_offset` characters forward for a more accurate position. - let mut walked = 0; - let mut index = self.pos; - while walked < char_offset && index < self.chars.len() { - if self.chars[index] == '\n' { - line += 1; - col = 1; - } else { - col += 1; - } - index += 1; - walked += 1; - } - FrontendError::at( - code, - message, - Span::new( - self.file_id, - Position::new(line, col), - Position::new(line, col + 1), - ), - ) - } -} diff --git a/crates/wright-ostw/src/lib.rs b/crates/wright-ostw/src/lib.rs index f10cd6e..05554c7 100644 --- a/crates/wright-ostw/src/lib.rs +++ b/crates/wright-ostw/src/lib.rs @@ -1,66 +1,305 @@ -//! Wright's native OSTW frontend (issues #117/#118). +//! Narrow Wright adapter for the owner-side `del-rs` implementation. //! -//! Owns the OSTW surface evidenced by the pinned protect-ban corpus: a -//! lexer, a CST/parser with structured diagnostics, the `ds.toml` project -//! model (`entry_point`) with quoted-import resolution (#117), and a -//! semantic phase that resolves the entry-point reachable graph into -//! frontend-neutral Wright HIR (#118). Workshop actions/values/enums resolve -//! through the canonical Wright-owned Workshop catalog -//! (`workshop-rs`'s `catalog`), with only OSTW source-name bindings kept -//! here; no OSTW game-derived table is imported. Upstream .NET/OSTW remains a -//! reference-only oracle and never enters the production dependency graph. -//! -//! Pipeline: [`lexer::lex`] → [`parser::parse`] → [`project::compile`] → -//! [`semantic::compile`]. - -pub mod cst; -pub mod diag; -pub mod lexer; -pub mod parser; -pub mod project; -pub mod reconstruct; -pub mod semantic; -pub mod signature; - -use std::path::Path; - -pub use diag::{FrontendError, FrontendResult}; -pub use project::{FileRecord, OstwOutcome, Project, ResolvedImport}; -pub use semantic::{SemanticOutcome, compile as compile_semantic}; - -/// The frontend's supported identity. -pub const FRONTEND_NAME: &str = "wright/ostw-native"; -pub const FRONTEND_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Load and parse an OSTW project rooted at `root`. -/// -/// `main_text` is the input file's text; `main_path` is its project-relative -/// path when the input is a file under `root` (`None` for stdin). The -/// outcome retains the file registry and every structured diagnostic, so the -/// driver maps spans to file identities through the shared provenance -/// contracts even when the project does not load cleanly. -pub fn compile(main_text: &str, main_path: Option<&str>, root: &Path) -> OstwOutcome { - project::compile(main_text, main_path, root) +//! `del-rs` owns OSTW/DeltinScript parsing, project loading, semantic +//! analysis, lowering, diagnostics, and reconstruction. This crate only maps +//! those owner contracts to the historical Wright driver boundaries. + +use std::path::{Path, PathBuf}; + +use workshop_rs::source::{FileId as WorkshopFileId, Position, Span}; + +pub mod diag { + use workshop_rs::source::Span; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct SourceError { + pub code: String, + pub message: String, + pub span: Option, + } + + pub type SourceResult = Result; + + impl SourceError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + span: None, + } + } + } + + impl std::fmt::Display for SourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } + } + + impl std::error::Error for SourceError {} } -/// Compile with in-memory source overlays (#128). -/// -/// `overlay` maps project-relative source paths to replacement text and takes -/// precedence over `main_text` and the filesystem, so a proposed multi-file -/// edit can be validated without rewriting the user's files. -pub fn compile_with_overlay( - main_text: &str, - main_path: Option<&str>, - root: &Path, - overlay: &std::collections::BTreeMap, -) -> OstwOutcome { - project::compile_with_overlay(main_text, main_path, root, overlay) +pub use diag::{SourceError, SourceResult}; + +pub mod lexer { + use super::{SourceError, SourceResult}; + use del_rs::span::FileId as DelFileId; + use workshop_rs::source::{FileId, Position, Span}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum TokenKind { + Ident, + Number, + String, + VerbatimString, + Eof, + LParen, + RParen, + LBracket, + RBracket, + LBrace, + RBrace, + Comma, + Colon, + Semi, + Dot, + Pipe, + At, + Assign, + PlusAssign, + MinusAssign, + StarAssign, + SlashAssign, + PercentAssign, + Plus, + Minus, + Star, + Slash, + Percent, + Power, + PlusPlus, + MinusMinus, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + And, + Or, + Bang, + Question, + } + + #[derive(Debug, Clone, PartialEq)] + pub struct Token { + pub kind: TokenKind, + pub text: String, + pub span: Span, + } + + pub struct LexInput<'a> { + pub file_id: FileId, + pub text: &'a str, + } + + pub fn lex(input: LexInput<'_>) -> SourceResult> { + let file = DelFileId(input.file_id.index() as u32); + let (tokens, diagnostics) = del_rs::syntax::lexer::lex(file, input.text); + if let Some(diagnostic) = diagnostics.first() { + return Err(error(input.file_id, input.text, diagnostic)); + } + Ok(tokens + .into_iter() + .map(|token| Token { + kind: map_kind(token.kind), + text: input + .text + .get(token.span.start as usize..token.span.end as usize) + .unwrap_or_default() + .to_string(), + span: map_span(input.file_id, input.text, token.span.start, token.span.end), + }) + .collect()) + } + + fn error(file: FileId, text: &str, diagnostic: &del_rs::Diagnostic) -> SourceError { + SourceError { + code: diagnostic.code.clone(), + message: diagnostic.message.clone(), + span: Some(map_span( + file, + text, + diagnostic.primary.start, + diagnostic.primary.end, + )), + } + } + + fn map_span(file: FileId, text: &str, start: u32, end: u32) -> Span { + Span::new( + file, + position(text, start as usize), + position(text, end as usize), + ) + } + + fn position(text: &str, offset: usize) -> Position { + let mut line = 1; + let mut col = 1; + for ch in text[..offset.min(text.len())].chars() { + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + Position::new(line, col) + } + + fn map_kind(kind: del_rs::TokenKind) -> TokenKind { + use del_rs::TokenKind as K; + match kind { + K::Ident + | K::KwRule + | K::KwDefine + | K::KwGlobalVar + | K::KwPlayerVar + | K::KwIf + | K::KwElse + | K::KwFor + | K::KwForeach + | K::KwWhile + | K::KwSwitch + | K::KwCase + | K::KwDefault + | K::KwBreak + | K::KwContinue + | K::KwReturn + | K::KwClass + | K::KwStruct + | K::KwEnum + | K::KwConstructor + | K::KwNew + | K::KwDelete + | K::KwIn + | K::KwRef + | K::KwRecursive + | K::KwAsync + | K::KwConst + | K::KwImport + | K::KwAs + | K::KwIs + | K::KwPublic + | K::KwPrivate + | K::KwProtected + | K::KwStatic + | K::KwVirtual + | K::KwOverride + | K::KwSingle + | K::KwThis + | K::KwRoot + | K::KwTrue + | K::KwFalse + | K::KwNull + | K::KwType + | K::KwDisabled + | K::KwPersist + | K::KwVoid + | K::KwJson => TokenKind::Ident, + K::Int | K::Real => TokenKind::Number, + K::Str | K::Bool => TokenKind::String, + K::LParen => TokenKind::LParen, + K::RParen => TokenKind::RParen, + K::LBrace => TokenKind::LBrace, + K::RBrace => TokenKind::RBrace, + K::LBracket => TokenKind::LBracket, + K::RBracket => TokenKind::RBracket, + K::Comma => TokenKind::Comma, + K::Semicolon => TokenKind::Semi, + K::Colon => TokenKind::Colon, + K::Dot => TokenKind::Dot, + K::Arrow => TokenKind::Minus, + K::Plus => TokenKind::Plus, + K::Minus => TokenKind::Minus, + K::Star => TokenKind::Star, + K::Slash => TokenKind::Slash, + K::Percent => TokenKind::Percent, + K::Caret => TokenKind::Power, + K::PlusPlus => TokenKind::PlusPlus, + K::MinusMinus => TokenKind::MinusMinus, + K::PlusEq => TokenKind::PlusAssign, + K::MinusEq => TokenKind::MinusAssign, + K::StarEq => TokenKind::StarAssign, + K::SlashEq => TokenKind::SlashAssign, + K::PercentEq => TokenKind::PercentAssign, + K::CaretEq => TokenKind::Power, + K::Eq => TokenKind::Assign, + K::EqEq => TokenKind::Eq, + K::Bang => TokenKind::Bang, + K::BangEq => TokenKind::Ne, + K::Lt => TokenKind::Lt, + K::Gt => TokenKind::Gt, + K::LtEq => TokenKind::Le, + K::GtEq => TokenKind::Ge, + K::AmpAmp => TokenKind::And, + K::PipePipe => TokenKind::Or, + K::Pipe => TokenKind::Pipe, + K::Question => TokenKind::Question, + K::At => TokenKind::At, + K::Tilde + | K::DotDot + | K::Error + | K::Whitespace + | K::LineComment + | K::BlockComment + | K::DocComment => TokenKind::Ident, + K::Eof => TokenKind::Eof, + } + } +} + +pub mod reconstruct { + pub use del_rs::reconstruct::{ReconstructError, reconstruct}; +} + +#[derive(Debug, Clone)] +pub struct ResolvedImport { + pub path: String, + pub span: Span, + pub target: Option, +} + +#[derive(Debug, Clone)] +pub struct FileRecord { + pub id: u32, + pub path: String, + pub source: bool, + pub parsed: bool, + pub imports: Vec, +} + +#[derive(Debug, Clone)] +pub struct Project { + pub entry: String, + pub files: Vec, + pub inventory: Vec, +} + +#[derive(Debug, Clone)] +pub struct OstwOutcome { + pub project: Option, + pub error: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone)] +pub struct SemanticOutcome { + pub wir: Option, + pub diagnostics: Vec, } -/// Load the project and resolve its semantic surface into frontend-neutral -/// HIR (#118). The returned HIR validates structurally; boundary forms -/// (missing imports, Cursor/Math, classes, define function macros) surface -/// as deterministic structured diagnostics in the outcome. pub fn compile_with_semantics( main_text: &str, main_path: Option<&str>, @@ -74,31 +313,164 @@ pub fn compile_with_semantics( ) } -/// Load the project with in-memory source overlays and resolve its semantic -/// surface into frontend-neutral HIR (#128). -/// -/// `overlay` maps project-relative source paths to replacement text and takes -/// precedence over `main_text` and the filesystem, so a proposed multi-file -/// edit can be validated through the real project/frontend semantics without -/// rewriting the user's files. pub fn compile_with_semantics_overlay( main_text: &str, main_path: Option<&str>, root: &Path, overlay: &std::collections::BTreeMap, ) -> (OstwOutcome, SemanticOutcome) { - let project_outcome = project::compile_with_overlay(main_text, main_path, root, overlay); - match &project_outcome.project { - Some(project) => { - let semantic = semantic::compile(project); - (project_outcome, semantic) + let mut source_overlay = overlay.clone(); + if let Some(main_path) = main_path { + source_overlay.insert(main_path.replace('\\', "/"), main_text.to_string()); + } + let project = del_rs::project::load_project_with_overlay( + del_rs::project::ProjectOptions { + root: root.to_path_buf(), + entry: main_path.map(PathBuf::from), + config: None, + }, + &source_overlay, + ); + let project_view = project_view(&project); + let project_diagnostics = project + .diagnostics + .iter() + .map(|diagnostic| map_diagnostic(&project, diagnostic)) + .collect::>(); + let outcome = OstwOutcome { + project: Some(project_view), + error: None, + diagnostics: project_diagnostics.clone(), + }; + let provider = match del_rs::semantic::provider::CatalogProvider::new() { + Ok(provider) => provider, + Err(error) => { + let diagnostic = SourceError::new("catalog-error", error.to_string()); + return ( + outcome, + SemanticOutcome { + wir: None, + diagnostics: vec![diagnostic], + }, + ); + } + }; + let semantic = del_rs::api::check_project_api(&project, &provider); + let mut diagnostics = semantic + .diagnostics + .iter() + .map(|diagnostic| map_diagnostic(&project, diagnostic)) + .collect::>(); + let (program, lowering) = del_rs::workshop::lower_project_to_wir_best_effort(&semantic); + diagnostics.extend( + lowering + .iter() + .map(|diagnostic| map_diagnostic(&project, diagnostic)), + ); + ( + outcome, + SemanticOutcome { + wir: Some(program), + diagnostics, + }, + ) +} + +fn project_view(project: &del_rs::project::Project) -> Project { + let mut files: Vec = project + .files + .iter() + .map(|file| { + let source = project.sources.get(*file); + let has_source = !source.text.is_empty(); + FileRecord { + id: file.0, + path: source.name.to_string_lossy().replace('\\', "/"), + source: has_source, + parsed: has_source, + imports: project + .imports + .iter() + .filter(|edge| edge.importer == *file) + .map(|edge| ResolvedImport { + path: project + .sources + .get(edge.imported) + .name + .to_string_lossy() + .into(), + span: map_span(project, edge.span), + target: Some(edge.imported.0), + }) + .collect(), + } + }) + .collect(); + files.sort_by_key(|file| file.id); + Project { + entry: project + .sources + .get(project.entry) + .name + .to_string_lossy() + .replace('\\', "/"), + files, + inventory: inventory(&project.root), + } +} + +fn inventory(root: &Path) -> Vec { + fn visit(root: &Path, dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + visit(root, &path, out); + } else if matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("del" | "ostw") + ) { + if let Ok(relative) = path.strip_prefix(root) { + out.push(relative.to_string_lossy().replace('\\', "/")); + } + } } - None => { - let semantic = SemanticOutcome { - hir: None, - diagnostics: Vec::new(), - }; - (project_outcome, semantic) + } + + let mut files = Vec::new(); + visit(root, root, &mut files); + files.sort(); + files +} + +fn map_diagnostic( + project: &del_rs::project::Project, + diagnostic: &del_rs::Diagnostic, +) -> SourceError { + let code = match diagnostic.phase { + del_rs::Phase::Lex => "ostw-lex-error".to_string(), + del_rs::Phase::Parse => "ostw-parse-error".to_string(), + del_rs::Phase::Project if diagnostic.code == "PJ002" => "ostw-missing-import".to_string(), + del_rs::Phase::Project => "ostw-project-error".to_string(), + del_rs::Phase::Semantic | del_rs::Phase::Hir | del_rs::Phase::Oracle => { + "ostw-unsupported".to_string() } + }; + SourceError { + code, + message: diagnostic.message.clone(), + span: Some(map_span(project, diagnostic.primary)), } } + +fn map_span(project: &del_rs::project::Project, span: del_rs::Span) -> Span { + let start = project.sources.line_col(span, span.start); + let end = project.sources.line_col(span, span.end); + Span::new( + WorkshopFileId::from_index(span.file.0 as usize), + Position::new(start.line, start.col), + Position::new(end.line, end.col), + ) +} diff --git a/crates/wright-ostw/src/parser.rs b/crates/wright-ostw/src/parser.rs deleted file mode 100644 index 6ef8b31..0000000 --- a/crates/wright-ostw/src/parser.rs +++ /dev/null @@ -1,1174 +0,0 @@ -//! The native OSTW parser: CST construction over the lexer token stream. -//! -//! Parses the syntax forms the committed protect-ban corpus exercises: -//! `import` statements, `globalvar`/`playervar` declarations (with explicit -//! indexes and pipe-union types), `define` constants/macros, typed -//! declarations (`Type name: expr;`, `Type name(params): expr;`), brace and -//! expression-bodied functions, `enum`, `rule:` blocks with `Event.`/`if` -//! modifiers and priorities, `class` bodies (syntax only), and statements -//! (`if`/`else`, `for`, `foreach`, `while`, `switch`/`case`/`default`, -//! `return`, `break`, `continue`, assignment, expression statements). -//! -//! Angle brackets are disambiguated like the reference: at expression start, -//! `<` followed by a string is a formatted string (`<"fmt", args>`), `<` -//! followed by a type then `>` is a cast (`expr`); in binary position -//! `<`/`<=` are comparisons. Every node carries its exact source span. - -use workshop_rs::source::{Position, Span}; - -use crate::cst::*; -use crate::diag::{FrontendError, FrontendResult}; -use crate::lexer::{Token, TokenKind}; - -/// Parse one file's token stream into a CST. -pub fn parse(tokens: Vec) -> FrontendResult { - Parser::new(tokens).parse_file() -} - -struct Parser { - tokens: Vec, - pos: usize, - /// Depth of enclosing `<"..."` formatted strings. While greater than - /// zero, a bare `>` terminates the enclosing formatted string instead of - /// parsing as the `Greater` comparison operator. - format_depth: u32, -} - -impl Parser { - fn new(tokens: Vec) -> Parser { - Parser { - tokens, - pos: 0, - format_depth: 0, - } - } - - // -- token helpers ----------------------------------------------------- - - fn peek(&self) -> &Token { - &self.tokens[self.pos.min(self.tokens.len() - 1)] - } - - fn peek_kind(&self, offset: usize) -> TokenKind { - self.tokens[(self.pos + offset).min(self.tokens.len() - 1)].kind - } - - fn peek_ident(&self, offset: usize) -> Option<&str> { - let token = &self.tokens[(self.pos + offset).min(self.tokens.len() - 1)]; - if token.kind == TokenKind::Ident { - Some(&token.text) - } else { - None - } - } - - fn advance(&mut self) -> Token { - let token = self.tokens[self.pos.min(self.tokens.len() - 1)].clone(); - if self.pos < self.tokens.len() - 1 { - self.pos += 1; - } - token - } - - fn at(&self, kind: TokenKind) -> bool { - self.peek().kind == kind - } - - fn at_ident(&self, name: &str) -> bool { - self.peek().kind == TokenKind::Ident && self.peek().text == name - } - - fn eat(&mut self, kind: TokenKind) -> bool { - if self.at(kind) { - self.advance(); - true - } else { - false - } - } - - fn eat_ident(&mut self, name: &str) -> bool { - if self.at_ident(name) { - self.advance(); - true - } else { - false - } - } - - fn expect(&mut self, kind: TokenKind, what: &str) -> FrontendResult { - if self.at(kind) { - Ok(self.advance()) - } else { - Err(self.error(format!("expected {what}"))) - } - } - - fn expect_ident(&mut self, what: &str) -> FrontendResult { - let token = self.peek().clone(); - if token.kind == TokenKind::Ident { - self.advance(); - Ok(token.text) - } else { - Err(self.error(format!("expected {what}"))) - } - } - - fn error(&self, message: impl Into) -> FrontendError { - FrontendError::at("ostw-parse-error", message, self.peek().span) - } - - fn span_from(&self, start: Span) -> Span { - Span::new(start.file, start.start, self.peek().span.start) - } - - // -- file -------------------------------------------------------------- - - fn parse_file(&mut self) -> FrontendResult { - let start = self.peek().span; - let mut imports = Vec::new(); - let mut items = Vec::new(); - while !self.at(TokenKind::Eof) { - if self.at_ident("import") { - imports.push(self.parse_import()?); - } else if self.at_ident("disabled") && self.peek_ident(1) == Some("rule") { - self.advance(); - items.push(Item::Rule(self.parse_rule(true)?)); - } else if self.at_ident("rule") && self.peek_kind(1) == TokenKind::Colon { - items.push(Item::Rule(self.parse_rule(false)?)); - } else if self.at_ident("globalvar") { - self.advance(); - items.push(Item::GlobalVar(self.parse_var_decl(false)?)); - } else if self.at_ident("playervar") { - self.advance(); - items.push(Item::PlayerVar(self.parse_var_decl(false)?)); - } else if self.at_ident("define") { - items.push(Item::Define(self.parse_define()?)); - } else if self.at_ident("enum") { - items.push(Item::Enum(self.parse_enum()?)); - } else if self.at_ident("class") { - items.push(Item::Class(self.parse_class()?)); - } else { - items.push(self.parse_typed_or_function()?); - } - } - let span = Span::new(start.file, start.start, self.peek().span.start); - Ok(File { - imports, - items, - span, - }) - } - - fn parse_import(&mut self) -> FrontendResult { - let start = self.advance().span; - let path_token = self.peek().clone(); - if !matches!( - path_token.kind, - TokenKind::String | TokenKind::VerbatimString - ) { - return Err(self.error("expected a quoted import path")); - } - self.advance(); - self.expect(TokenKind::Semi, "';' after import")?; - let span = Span::new(start.file, start.start, path_token.span.end); - Ok(Import { - path: path_token.text, - span, - }) - } - - // -- declarations ------------------------------------------------------ - - fn parse_var_decl(&mut self, _player: bool) -> FrontendResult { - let start = self.peek().span; - let type_name = if self.at(TokenKind::Ident) && self.peek_kind(1) != TokenKind::Semi { - Some(self.parse_type_ref()?) - } else { - None - }; - // The exact declared-identifier occurrence (the rename target, #129). - let name_token = self.peek().clone(); - let name = self.expect_ident("a variable name")?; - let name_span = name_token.span; - let mut index = None; - let mut value = None; - if self.eat(TokenKind::Assign) { - value = Some(self.parse_expr()?); - } else if !self.at(TokenKind::Semi) { - // The explicit-index form: `globalvar Number i 127;`. - index = Some(self.parse_expr()?); - } - self.expect(TokenKind::Semi, "';' after variable declaration")?; - let span = self.span_from(start); - Ok(VarDecl { - type_name, - name, - index, - value, - span, - name_span, - }) - } - - fn parse_define(&mut self) -> FrontendResult { - let start = self.advance().span; - let name = self.expect_ident("a define name")?; - let mut params = Vec::new(); - if self.eat(TokenKind::LParen) { - params = self.parse_params_until_rparen()?; - } - self.expect(TokenKind::Colon, "':' after define")?; - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after define")?; - let span = self.span_from(start); - Ok(DefineDecl { - name, - params, - value, - span, - }) - } - - fn parse_enum(&mut self) -> FrontendResult { - let start = self.advance().span; - let name = self.expect_ident("an enum name")?; - self.expect(TokenKind::LBrace, "'{' after enum name")?; - let mut members = Vec::new(); - while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { - members.push(self.expect_ident("an enum member")?); - if !self.eat(TokenKind::Comma) { - break; - } - } - self.expect(TokenKind::RBrace, "'}' to close enum")?; - let span = self.span_from(start); - Ok(EnumDecl { - name, - members, - span, - }) - } - - /// Parse `Type name: expr;`, `Type name(params): expr;`, or - /// `Type name(params) ["rule-name"] { body }` (function). - fn parse_typed_or_function(&mut self) -> FrontendResult { - let start = self.peek().span; - let type_name = self.parse_type_ref()?; - // The exact declared-identifier occurrence (the rename target, #129). - let name_token = self.peek().clone(); - let name = self.expect_ident("a declaration name")?; - let name_span = name_token.span; - if self.at(TokenKind::Colon) { - // Expression-bodied declaration. - self.advance(); - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after declaration")?; - let span = self.span_from(start); - return Ok(Item::TypedDecl(TypedDecl { - type_name, - name, - params: None, - value, - span, - })); - } - self.expect(TokenKind::LParen, "'(' before parameters")?; - let params = self.parse_params_until_rparen()?; - if self.at(TokenKind::Colon) { - // Expression-bodied function-like declaration. - self.advance(); - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after declaration")?; - let span = self.span_from(start); - return Ok(Item::TypedDecl(TypedDecl { - type_name, - name, - params: Some(params), - value, - span, - })); - } - let rule_name = if self.at(TokenKind::String) || self.at(TokenKind::VerbatimString) { - Some(self.advance().text) - } else { - None - }; - self.expect(TokenKind::LBrace, "'{' to open function body")?; - let body = self.parse_block_body()?; - let span = self.span_from(start); - Ok(Item::Function(FunctionDecl { - return_type: Some(type_name), - name, - params, - rule_name, - body, - span, - name_span, - })) - } - - fn parse_class(&mut self) -> FrontendResult { - let start = self.advance().span; - let name = self.expect_ident("a class name")?; - self.expect(TokenKind::LBrace, "'{' after class name")?; - let mut members = Vec::new(); - while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { - members.push(self.parse_class_member()?); - } - self.expect(TokenKind::RBrace, "'}' to close class")?; - let span = self.span_from(start); - Ok(ClassDecl { - name, - members, - span, - }) - } - - fn parse_class_member(&mut self) -> FrontendResult { - // Members are written with a leading `public` in the corpus. - let start = self.peek().span; - self.eat_ident("public"); - let type_name = self.parse_type_ref()?; - if type_name.name == "constructor" - && type_name.array_depth == 0 - && self.at(TokenKind::LParen) - { - self.advance(); - let params = self.parse_params_until_rparen()?; - self.expect(TokenKind::LBrace, "'{' to open constructor body")?; - let body = self.parse_block_body()?; - let span = self.span_from(start); - return Ok(ClassMember::Constructor { params, body, span }); - } - let name = self.expect_ident("a class member name")?; - if self.eat(TokenKind::Semi) { - let span = self.span_from(start); - return Ok(ClassMember::Field { - type_name, - name, - span, - }); - } - self.expect(TokenKind::LParen, "'(' before parameters")?; - let params = self.parse_params_until_rparen()?; - if self.at(TokenKind::Colon) { - self.advance(); - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after class method")?; - let span = self.span_from(start); - return Ok(ClassMember::Method { - type_name, - name, - params, - value: Some(value), - body: None, - span, - }); - } - self.expect(TokenKind::LBrace, "'{' to open method body")?; - let body = self.parse_block_body()?; - let span = self.span_from(start); - Ok(ClassMember::Method { - type_name, - name, - params, - value: None, - body: Some(body), - span, - }) - } - - fn parse_rule(&mut self, disabled: bool) -> FrontendResult { - let start = self.advance().span; // `rule` - self.expect(TokenKind::Colon, "':' after rule")?; - let mut name = None; - let mut name_span = None; - let mut priority = None; - let mut event = None; - let mut conditions = Vec::new(); - if self.at(TokenKind::String) || self.at(TokenKind::VerbatimString) { - let name_token = self.advance(); - name = Some(name_token.text); - // The exact rule-name occurrence is the string content between - // the quotes (the token itself spans the quotes). - name_span = Some(Span::new( - name_token.span.file, - Position::new(name_token.span.start.line, name_token.span.start.col + 1), - Position::new( - name_token.span.end.line, - name_token - .span - .end - .col - .saturating_sub(1) - .max(name_token.span.start.col + 1), - ), - )); - } - loop { - if self.at(TokenKind::LBrace) { - break; - } - if self.at(TokenKind::Eof) || self.at(TokenKind::Semi) { - break; - } - if self.at_ident("if") { - self.advance(); - self.expect(TokenKind::LParen, "'(' after if")?; - let condition = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after condition")?; - conditions.push(condition); - } else if self.at_ident("Event") && self.peek_kind(1) == TokenKind::Dot { - event = Some(self.parse_postfix_expr()?); - } else if self.at(TokenKind::Minus) || self.at(TokenKind::Number) { - priority = Some(self.parse_unary()?); - } else { - return Err(self.error(format!( - "unexpected token '{}' in rule modifiers", - self.peek().text - ))); - } - } - let mut body = Vec::new(); - if self.eat(TokenKind::LBrace) { - body = self.parse_block_body()?; - } - let span = self.span_from(start); - Ok(RuleDecl { - disabled, - name, - name_span, - priority, - event, - conditions, - body, - span, - }) - } - - // -- parameters -------------------------------------------------------- - - fn parse_params_until_rparen(&mut self) -> FrontendResult> { - let mut params = Vec::new(); - while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { - params.push(self.parse_param()?); - if !self.eat(TokenKind::Comma) { - break; - } - } - self.expect(TokenKind::RParen, "')' after parameters")?; - Ok(params) - } - - fn parse_param(&mut self) -> FrontendResult { - let start = self.peek().span; - // The `in` marker (corpus: `in Player | Player[] VisibleTo = ...`). - self.eat_ident("in"); - // Parameters in the corpus always declare a type (including array and - // pipe-union types); a missing type is rejected explicitly. - let type_name = Some(self.parse_type_ref()?); - let name = self.expect_ident("a parameter name")?; - let default = if self.eat(TokenKind::Assign) { - Some(self.parse_expr()?) - } else { - None - }; - let span = self.span_from(start); - Ok(Param { - type_name, - name, - default, - span, - }) - } - - fn parse_type_ref(&mut self) -> FrontendResult { - let start = self.peek().span; - let name = self.expect_ident("a type name")?; - let mut array_depth = 0; - while self.eat(TokenKind::LBracket) { - self.expect(TokenKind::RBracket, "']' in array type")?; - array_depth += 1; - } - let mut unions = Vec::new(); - while self.at(TokenKind::Pipe) { - self.advance(); - let union_start = self.peek().span; - let union_name = self.expect_ident("a union type name")?; - let mut union_depth = 0; - while self.eat(TokenKind::LBracket) { - self.expect(TokenKind::RBracket, "']' in union array type")?; - union_depth += 1; - } - let span = Span::new(union_start.file, union_start.start, self.peek().span.start); - unions.push(TypeRef { - name: union_name, - array_depth: union_depth, - unions: Vec::new(), - span, - }); - } - let span = self.span_from(start); - Ok(TypeRef { - name, - array_depth, - unions, - span, - }) - } - - // -- statements -------------------------------------------------------- - - fn parse_block_body(&mut self) -> FrontendResult> { - let mut statements = Vec::new(); - while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { - statements.push(self.parse_stmt()?); - } - self.expect(TokenKind::RBrace, "'}' to close block")?; - Ok(statements) - } - - fn parse_stmt(&mut self) -> FrontendResult { - let start = self.peek().span; - if self.at_ident("if") { - return self.parse_if_stmt(start); - } - if self.at_ident("for") && self.peek_kind(1) == TokenKind::LParen { - return self.parse_for_stmt(start); - } - if self.at_ident("foreach") { - return self.parse_foreach_stmt(start); - } - if self.at_ident("while") { - return self.parse_while_stmt(start); - } - if self.at_ident("switch") { - return self.parse_switch_stmt(start); - } - if self.at_ident("return") { - self.advance(); - let value = if self.at(TokenKind::Semi) { - None - } else { - Some(self.parse_expr()?) - }; - self.expect(TokenKind::Semi, "';' after return")?; - return Ok(Stmt::Return { - value, - span: self.span_from(start), - }); - } - if self.eat_ident("break") { - self.expect(TokenKind::Semi, "';' after break")?; - return Ok(Stmt::Break { - span: self.span_from(start), - }); - } - if self.eat_ident("continue") { - self.expect(TokenKind::Semi, "';' after continue")?; - return Ok(Stmt::Continue { - span: self.span_from(start), - }); - } - if self.at(TokenKind::LBrace) { - self.advance(); - let body = self.parse_block_body()?; - return Ok(Stmt::Block { - body, - span: self.span_from(start), - }); - } - if self.at_ident("define") && self.peek_kind(1) == TokenKind::Ident { - // Local define: `define name = expr;`. - self.advance(); - let name = self.expect_ident("a define name")?; - self.expect(TokenKind::Assign, "'=' in local define")?; - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after define")?; - return Ok(Stmt::LocalDefine { - name, - value, - span: self.span_from(start), - }); - } - if self.at(TokenKind::Ident) && self.peek_kind(1) == TokenKind::Ident { - // Local typed declaration: `Type name = expr;`. - let type_name = self.parse_type_ref()?; - let name = self.expect_ident("a declaration name")?; - self.expect(TokenKind::Assign, "'=' in local declaration")?; - let value = self.parse_expr()?; - self.expect(TokenKind::Semi, "';' after declaration")?; - return Ok(Stmt::LocalDecl { - type_name, - name, - value, - span: self.span_from(start), - }); - } - // Expression or assignment statement. - let expression = self.parse_assign_expr()?; - self.expect(TokenKind::Semi, "';' after statement")?; - let span = self.span_from(start); - match expression { - Expr::Assign { - target, op, value, .. - } => Ok(Stmt::Assign { - target: *target, - op, - value: *value, - span, - }), - other => Ok(Stmt::Expr { expr: other, span }), - } - } - - fn parse_if_stmt(&mut self, start: Span) -> FrontendResult { - self.advance(); // if - self.expect(TokenKind::LParen, "'(' after if")?; - let condition = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after if condition")?; - let body = self.parse_stmt_or_block()?; - let mut branches = vec![IfBranch { - condition, - body, - span: self.span_from(start), - }]; - let mut else_body = None; - if self.at_ident("else") { - self.advance(); - if self.at_ident("if") { - // else if (...) {...} — fold into the next branch. - let next = self.parse_if_stmt(start)?; - if let Stmt::If { - branches: nested, - else_body: nested_else, - .. - } = next - { - branches.extend(nested); - else_body = nested_else; - } - } else { - let body = self.parse_stmt_or_block()?; - else_body = Some(body); - } - } - Ok(Stmt::If { - branches, - else_body, - span: self.span_from(start), - }) - } - - fn parse_for_stmt(&mut self, start: Span) -> FrontendResult { - self.advance(); // for - self.expect(TokenKind::LParen, "'(' after for")?; - let init = if self.at(TokenKind::Semi) { - None - } else { - Some(self.parse_assign_expr()?) - }; - self.expect(TokenKind::Semi, "';' after for initializer")?; - let condition = if self.at(TokenKind::Semi) { - None - } else { - Some(self.parse_expr()?) - }; - self.expect(TokenKind::Semi, "';' after for condition")?; - let increment = if self.at(TokenKind::RParen) { - None - } else { - Some(self.parse_assign_expr()?) - }; - self.expect(TokenKind::RParen, "')' after for header")?; - let body = self.parse_stmt_or_block()?; - Ok(Stmt::For { - init, - condition, - increment, - body, - span: self.span_from(start), - }) - } - - fn parse_foreach_stmt(&mut self, start: Span) -> FrontendResult { - self.advance(); // foreach - self.expect(TokenKind::LParen, "'(' after foreach")?; - let var_type = if self.at(TokenKind::Ident) && self.peek_ident(1) != Some("in") { - Some(self.parse_type_ref()?) - } else { - None - }; - let var = self.expect_ident("a foreach variable name")?; - self.expect_ident("in")?; - let iterable = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after foreach header")?; - let body = self.parse_stmt_or_block()?; - Ok(Stmt::Foreach { - var_type, - var, - iterable, - body, - span: self.span_from(start), - }) - } - - fn parse_while_stmt(&mut self, start: Span) -> FrontendResult { - self.advance(); // while - self.expect(TokenKind::LParen, "'(' after while")?; - let condition = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after while condition")?; - let body = self.parse_stmt_or_block()?; - Ok(Stmt::While { - condition, - body, - span: self.span_from(start), - }) - } - - fn parse_switch_stmt(&mut self, start: Span) -> FrontendResult { - self.advance(); // switch - self.expect(TokenKind::LParen, "'(' after switch")?; - let value = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after switch value")?; - self.expect(TokenKind::LBrace, "'{' to open switch")?; - let mut cases = Vec::new(); - while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { - let case_start = self.peek().span; - let case_value = if self.eat_ident("default") { - None - } else { - self.expect_ident("case")?; - Some(self.parse_expr()?) - }; - self.expect(TokenKind::Colon, "':' after switch case")?; - let mut body = Vec::new(); - while !self.at(TokenKind::RBrace) - && !self.at(TokenKind::Eof) - && !self.at_ident("case") - && !self.at_ident("default") - { - body.push(self.parse_stmt()?); - } - let span = self.span_from(case_start); - cases.push(SwitchCase { - value: case_value, - body, - span, - }); - } - self.expect(TokenKind::RBrace, "'}' to close switch")?; - Ok(Stmt::Switch { - value, - cases, - span: self.span_from(start), - }) - } - - /// A statement body: a `{ ... }` block or a single statement. - fn parse_stmt_or_block(&mut self) -> FrontendResult> { - if self.at(TokenKind::LBrace) { - self.advance(); - self.parse_block_body() - } else { - Ok(vec![self.parse_stmt()?]) - } - } - - // -- expressions ------------------------------------------------------- - - /// Parse a full expression (ternary level). - fn parse_expr(&mut self) -> FrontendResult { - self.parse_ternary_expr() - } - - /// An assignment expression (`x = e`, `x += e`) — used by statements and - /// for-loop headers. - fn parse_assign_expr(&mut self) -> FrontendResult { - let start = self.peek().span; - let target = self.parse_ternary_expr()?; - let op = match self.peek().kind { - TokenKind::Assign => AssignOp::Assign, - TokenKind::PlusAssign => AssignOp::AddAssign, - TokenKind::MinusAssign => AssignOp::SubtractAssign, - TokenKind::StarAssign => AssignOp::MultiplyAssign, - TokenKind::SlashAssign => AssignOp::DivideAssign, - TokenKind::PercentAssign => AssignOp::ModuloAssign, - _ => return Ok(target), - }; - self.advance(); - let value = self.parse_assign_expr()?; - let span = self.span_from(start); - Ok(Expr::Assign { - target: Box::new(target), - op, - value: Box::new(value), - span, - }) - } - - fn parse_ternary_expr(&mut self) -> FrontendResult { - let start = self.peek().span; - let condition = self.parse_binary_expr(0)?; - if !self.eat(TokenKind::Question) { - return Ok(condition); - } - let then_value = self.parse_ternary_expr()?; - self.expect(TokenKind::Colon, "':' in ternary expression")?; - let else_value = self.parse_ternary_expr()?; - let span = self.span_from(start); - Ok(Expr::Ternary { - condition: Box::new(condition), - then_value: Box::new(then_value), - else_value: Box::new(else_value), - span, - }) - } - - fn parse_binary_expr(&mut self, min_precedence: u8) -> FrontendResult { - let mut left = self.parse_unary_expr()?; - loop { - // A bare `>` while inside a `<"..."` formatted string closes it - // (the closing `>` is consumed by parse_format_string). - if self.format_depth > 0 && self.at(TokenKind::Gt) { - break; - } - let Some((op, precedence)) = self.binary_op() else { - break; - }; - if precedence < min_precedence { - break; - } - self.advance(); - let right = self.parse_binary_expr(precedence + 1)?; - let span = Span::new(left.span().file, left.span().start, right.span().end); - left = Expr::Binary { - op, - left: Box::new(left), - right: Box::new(right), - span, - }; - } - Ok(left) - } - - fn binary_op(&self) -> Option<(BinaryOp, u8)> { - let (op, precedence) = match self.peek().kind { - TokenKind::Or => (BinaryOp::Or, 1), - TokenKind::And => (BinaryOp::And, 2), - TokenKind::Eq => (BinaryOp::Equal, 3), - TokenKind::Ne => (BinaryOp::NotEqual, 3), - TokenKind::Lt => (BinaryOp::Less, 4), - TokenKind::Le => (BinaryOp::LessEqual, 4), - TokenKind::Gt => (BinaryOp::Greater, 4), - TokenKind::Ge => (BinaryOp::GreaterEqual, 4), - TokenKind::Plus => (BinaryOp::Add, 5), - TokenKind::Minus => (BinaryOp::Subtract, 5), - TokenKind::Star => (BinaryOp::Multiply, 6), - TokenKind::Slash => (BinaryOp::Divide, 6), - TokenKind::Percent => (BinaryOp::Modulo, 6), - TokenKind::Power => (BinaryOp::Power, 7), - _ => return None, - }; - Some((op, precedence)) - } - - fn parse_unary_expr(&mut self) -> FrontendResult { - let start = self.peek().span; - if self.eat(TokenKind::Minus) { - let operand = self.parse_unary_expr()?; - let span = self.span_from(start); - return Ok(Expr::Unary { - op: UnaryOp::Negate, - operand: Box::new(operand), - span, - }); - } - if self.eat(TokenKind::Bang) { - let operand = self.parse_unary_expr()?; - let span = self.span_from(start); - return Ok(Expr::Unary { - op: UnaryOp::Not, - operand: Box::new(operand), - span, - }); - } - self.parse_postfix_expr() - } - - /// Alias used by the rule-priority modifier (`-1`). - fn parse_unary(&mut self) -> FrontendResult { - self.parse_unary_expr() - } - - fn parse_postfix_expr(&mut self) -> FrontendResult { - let mut expression = self.parse_primary_expr()?; - loop { - let start = expression.span(); - if self.eat(TokenKind::Dot) { - let name = self.expect_ident("a member name")?; - let span = self.span_from(start); - expression = Expr::Member { - receiver: Box::new(expression), - name, - span, - }; - } else if self.at(TokenKind::LParen) { - let args = self.parse_call_args()?; - let span = self.span_from(start); - expression = Expr::Call { - callee: Box::new(expression), - args, - span, - }; - } else if self.eat(TokenKind::LBracket) { - let index = self.parse_expr()?; - self.expect(TokenKind::RBracket, "']' after index")?; - let span = self.span_from(start); - expression = Expr::Index { - array: Box::new(expression), - index: Box::new(index), - span, - }; - } else if self.at(TokenKind::PlusPlus) { - self.advance(); - let span = self.span_from(start); - expression = Expr::Postfix { - op: PostfixOp::Increment, - operand: Box::new(expression), - span, - }; - } else if self.at(TokenKind::MinusMinus) { - self.advance(); - let span = self.span_from(start); - expression = Expr::Postfix { - op: PostfixOp::Decrement, - operand: Box::new(expression), - span, - }; - } else { - break; - } - } - Ok(expression) - } - - fn parse_primary_expr(&mut self) -> FrontendResult { - let token = self.peek().clone(); - match token.kind { - TokenKind::Number => { - self.advance(); - let value = token.text.parse::().unwrap_or(0.0); - Ok(Expr::Number { - value, - text: token.text, - span: token.span, - }) - } - TokenKind::String => { - self.advance(); - Ok(Expr::String { - value: token.text, - span: token.span, - }) - } - TokenKind::VerbatimString => { - self.advance(); - Ok(Expr::VerbatimString { - value: token.text, - span: token.span, - }) - } - TokenKind::Ident => { - if token.text == "new" { - self.advance(); - let type_name = self.expect_ident("a type name after 'new'")?; - let args = self.parse_call_args()?; - let span = Span::new(token.span.file, token.span.start, self.peek().span.start); - return Ok(Expr::New { - type_name, - args, - span, - }); - } - self.advance(); - match token.text.as_str() { - "null" => Ok(Expr::Null { span: token.span }), - "true" => Ok(Expr::Bool { - value: true, - span: token.span, - }), - "false" => Ok(Expr::Bool { - value: false, - span: token.span, - }), - _ => Ok(Expr::Ident { - name: token.text, - span: token.span, - }), - } - } - TokenKind::LParen => { - self.advance(); - let inner = self.parse_expr()?; - self.expect(TokenKind::RParen, "')' after expression")?; - Ok(inner) - } - TokenKind::LBracket => { - self.advance(); - let mut elements = Vec::new(); - while !self.at(TokenKind::RBracket) && !self.at(TokenKind::Eof) { - elements.push(self.parse_expr()?); - if !self.eat(TokenKind::Comma) { - break; - } - } - self.expect(TokenKind::RBracket, "']' to close array")?; - let span = Span::new(token.span.file, token.span.start, self.peek().span.start); - Ok(Expr::Array { elements, span }) - } - TokenKind::Lt => { - // `<"..."` → formatted string; `` → cast. - if matches!( - self.peek_kind(1), - TokenKind::String | TokenKind::VerbatimString - ) { - self.parse_format_string(token.span) - } else if self.is_type_cast_lookahead() { - self.advance(); // `<` - let type_name = self.parse_type_ref()?; - self.expect(TokenKind::Gt, "'>' to close cast")?; - let value = self.parse_unary_expr()?; - let span = Span::new(token.span.file, token.span.start, value.span().end); - Ok(Expr::Cast { - type_name, - value: Box::new(value), - span, - }) - } else { - Err(self.error("expected a formatted string or type cast after '<'")) - } - } - TokenKind::Eof => Err(self.error("unexpected end of file in expression")), - _ => Err(self.error("unexpected token in expression")), - } - } - - fn parse_format_string(&mut self, start: Span) -> FrontendResult { - self.advance(); // `<` - self.format_depth += 1; - let format = self.parse_primary_expr()?; - let mut args = Vec::new(); - while self.eat(TokenKind::Comma) { - args.push(self.parse_expr()?); - } - self.format_depth -= 1; - self.expect(TokenKind::Gt, "'>' to close formatted string")?; - let span = Span::new(start.file, start.start, self.peek().span.start); - Ok(Expr::FormatString { - format: Box::new(format), - args, - span, - }) - } - - fn parse_call_args(&mut self) -> FrontendResult> { - self.advance(); // `(` - let mut args = Vec::new(); - while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { - args.push(self.parse_call_arg()?); - if !self.eat(TokenKind::Comma) { - break; - } - } - self.expect(TokenKind::RParen, "')' after arguments")?; - Ok(args) - } - - fn parse_call_arg(&mut self) -> FrontendResult { - let start = self.peek().span; - // `Name: value` — a named argument (an identifier directly followed - // by a colon at argument position). - if self.at(TokenKind::Ident) && self.peek_kind(1) == TokenKind::Colon { - let name = self.advance().text; - self.advance(); // `:` - let value = self.parse_expr()?; - let span = Span::new(start.file, start.start, value.span().end); - return Ok(CallArg::Named { name, value, span }); - } - let value = self.parse_expr()?; - let span = Span::new(start.file, start.start, value.span().end); - Ok(CallArg::Positional { value, span }) - } - - /// Lookahead: is the current `<` the start of a `` cast? - fn is_type_cast_lookahead(&self) -> bool { - // `<` Ident ... `>` with optional array markers and pipe unions. - let mut index = self.pos + 1; - let Some(first) = self.tokens.get(index) else { - return false; - }; - if first.kind != TokenKind::Ident { - return false; - } - index += 1; - loop { - match self.tokens.get(index).map(|token| token.kind) { - Some(TokenKind::LBracket) => { - if self.tokens.get(index + 1).map(|token| token.kind) - != Some(TokenKind::RBracket) - { - return false; - } - index += 2; - } - Some(TokenKind::Pipe) => { - if self.tokens.get(index + 1).map(|token| token.kind) != Some(TokenKind::Ident) - { - return false; - } - index += 2; - // Allow array markers after a union member. - while let Some(TokenKind::LBracket) = - self.tokens.get(index).map(|token| token.kind) - { - if self.tokens.get(index + 1).map(|token| token.kind) - != Some(TokenKind::RBracket) - { - return false; - } - index += 2; - } - } - Some(TokenKind::Gt) => return true, - _ => return false, - } - } - } -} - -impl Expr { - fn span(&self) -> Span { - match self { - Expr::Number { span, .. } - | Expr::String { span, .. } - | Expr::VerbatimString { span, .. } - | Expr::Ident { span, .. } - | Expr::Null { span } - | Expr::Bool { span, .. } - | Expr::Member { span, .. } - | Expr::Call { span, .. } - | Expr::Index { span, .. } - | Expr::Array { span, .. } - | Expr::FormatString { span, .. } - | Expr::Cast { span, .. } - | Expr::Unary { span, .. } - | Expr::Binary { span, .. } - | Expr::Ternary { span, .. } - | Expr::New { span, .. } - | Expr::Assign { span, .. } - | Expr::Postfix { span, .. } => *span, - } - } -} diff --git a/crates/wright-ostw/src/project.rs b/crates/wright-ostw/src/project.rs deleted file mode 100644 index 50871c0..0000000 --- a/crates/wright-ostw/src/project.rs +++ /dev/null @@ -1,459 +0,0 @@ -//! The `ds.toml` project model, compilation graph, and quoted-import -//! resolution. -//! -//! An OSTW project is defined by a `ds.toml` in the project root. This task -//! supports the `entry_point` key (the project-relative entry source file) -//! and rejects other configuration keys with a structured diagnostic. -//! -//! Compilation membership follows the pinned reference's entry-point -//! semantics (#117): the project's compilation graph starts at -//! `ds.toml.entry_point` and recursively includes exactly the files reachable -//! through resolved import statements (a visited set makes cycles and -//! duplicate imports include each file once). Only reachable files are parsed -//! and only their imports resolved for project/check diagnostics; an -//! unreachable source with broken syntax or a missing import cannot make the -//! entry-point project fail. The workspace/source inventory (every -//! `.ostw`/`.del` under the root) is retained as a distinct, non-diagnostic -//! structure for tooling. - -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::path::Path; - -use workshop_rs::source::{FileId, Position, Span}; - -use crate::cst; -use crate::diag::{FrontendError, FrontendResult}; -use crate::lexer::{self, LexInput}; -use crate::parser; - -/// A compilation-graph file: `ds.toml` (id 0) and every entry-point -/// import-reachable source. -#[derive(Debug, Clone)] -pub struct FileRecord { - /// The registry id used by spans and import edges. - pub id: u32, - /// The project-relative path (`ds.toml`, `main.ostw`, - /// `interface/HeroSelect.del`). - pub path: String, - /// Whether this is a `.ostw`/`.del` source file (`false` for `ds.toml`). - pub source: bool, - /// Whether the source file lexed and parsed cleanly. - pub parsed: bool, - /// Resolved import edges, in source order. - pub imports: Vec, - /// The parsed CST, when the file is a source that parsed. - pub cst: Option, -} - -/// One resolved `import "path";` edge. -#[derive(Debug, Clone)] -pub struct ResolvedImport { - /// The path exactly as written in the import statement. - pub path: String, - pub span: Span, - /// The target file id when the import resolves inside the project, - /// `None` when it points outside it (a missing-import diagnostic). - pub target: Option, -} - -/// The loaded OSTW project. -#[derive(Debug, Clone)] -pub struct Project { - /// The `ds.toml` `entry_point` value. - pub entry: String, - /// The compilation graph: `ds.toml` at id 0, then the entry-point - /// import-reachable closure in deterministic walk order. - pub files: Vec, - /// The independent workspace/source inventory: every `.ostw`/`.del` - /// under the project root, in sorted order. Tooling only — it never - /// feeds project diagnostics or compilation membership. - pub inventory: Vec, -} - -/// The outcome of a project compile: the registry is retained even when -/// diagnostics exist, so the driver can map spans to file identities. -#[derive(Debug, Clone)] -pub struct OstwOutcome { - /// The project compilation graph (always populated when `ds.toml` - /// loads). - pub project: Option, - /// A fatal project-load error (`ds.toml` missing or invalid). - pub error: Option, - /// Non-fatal structured diagnostics (parse errors, missing imports, - /// unsupported `ds.toml` keys, invalid entry point). - pub diagnostics: Vec, -} - -/// Compile an OSTW project rooted at `root`. -/// -/// `main_text` is the input file's text and `main_path` its project-relative -/// path when the input is a file under `root` (`None` for stdin, in which -/// case `main_text` is used as the `entry_point` file's content). -pub fn compile(main_text: &str, main_path: Option<&str>, root: &Path) -> OstwOutcome { - compile_with_overlay(main_text, main_path, root, &BTreeMap::new()) -} - -/// Compile an OSTW project rooted at `root` with in-memory source overlays. -/// -/// The overlay maps project-relative source paths to replacement text and -/// takes precedence over both `main_text` and the filesystem, so a proposed -/// multi-file edit can be validated without rewriting the user's files -/// (#128). Overlay keys are normalized project-relative paths exactly as -/// [`Project::files`] reports them (e.g. `interface/HeroSelect.del`). The -/// `ds.toml` project file itself is always read from the filesystem. -pub fn compile_with_overlay( - main_text: &str, - main_path: Option<&str>, - root: &Path, - overlay: &BTreeMap, -) -> OstwOutcome { - match load(main_text, main_path, root, overlay) { - Ok(outcome) => outcome, - Err(error) => OstwOutcome { - project: None, - error: Some(error), - diagnostics: Vec::new(), - }, - } -} - -fn load( - main_text: &str, - main_path: Option<&str>, - root: &Path, - overlay: &BTreeMap, -) -> FrontendResult { - let ds_path = root.join("ds.toml"); - if !ds_path.is_file() { - return Err(FrontendError::new( - "ostw-ds-toml-missing", - format!( - "no ds.toml found in '{}'; OSTW projects are defined by a ds.toml project file", - root.display() - ), - )); - } - let ds_text = std::fs::read_to_string(&ds_path).map_err(|error| { - FrontendError::new( - "ostw-ds-toml-unreadable", - format!("cannot read ds.toml: {error}"), - ) - })?; - - let (entry, mut diagnostics) = parse_ds_toml(&ds_text, 0); - - // The workspace/source inventory: every .ostw/.del under the root, in - // sorted order. This is the resolution universe and a tooling inventory; - // it is not compilation membership. - let mut inventory = walk_sources(root); - inventory.sort(); - - let entry = match entry { - Some(entry) => entry, - None => { - diagnostics.push(FrontendError::new( - "ostw-ds-toml-no-entry", - "ds.toml does not declare an entry_point", - )); - return Ok(OstwOutcome { - project: Some(Project { - entry: String::new(), - files: vec![FileRecord { - id: 0, - path: "ds.toml".to_string(), - source: false, - parsed: false, - imports: Vec::new(), - cst: None, - }], - inventory, - }), - error: None, - diagnostics, - }); - } - }; - - // Validate the entry point against the inventory. - if !inventory.iter().any(|path| path == &entry) { - diagnostics.push(FrontendError::new( - "ostw-entry-not-found", - format!("entry_point '{entry}' is not a source file in the project"), - )); - } - - // Build the compilation graph: breadth-first walk from the entry, - // following resolved import targets. A visited set makes cycles and - // duplicate imports include each file exactly once, and unreachable - // sources never enter the graph (so their defects produce no project - // diagnostics). - let mut files = vec![FileRecord { - id: 0, - path: "ds.toml".to_string(), - source: false, - parsed: false, - imports: Vec::new(), - cst: None, - }]; - let mut path_to_id: BTreeMap = BTreeMap::new(); - path_to_id.insert("ds.toml".to_string(), 0); - let mut visited: BTreeSet = BTreeSet::new(); - let mut queue: VecDeque = VecDeque::new(); - if inventory.iter().any(|path| path == &entry) { - queue.push_back(entry.clone()); - } - // Imports whose target id is only known after the walk completes. - let mut pending_imports: Vec = Vec::new(); - - while let Some(path) = queue.pop_front() { - if !visited.insert(path.clone()) { - continue; - } - let id = files.len() as u32; - path_to_id.insert(path.clone(), id); - // An in-memory overlay (a proposed edit preview) takes precedence - // over the passed-in main text and the filesystem (#128). - let text = if let Some(text) = overlay.get(&path) { - text.clone() - } else if (path == entry && main_path.is_none()) || Some(path.as_str()) == main_path { - main_text.to_string() - } else { - match std::fs::read_to_string(root.join(&path)) { - Ok(text) => text, - Err(error) => { - diagnostics.push(FrontendError::new( - "ostw-source-unreadable", - format!("cannot read '{path}': {error}"), - )); - continue; - } - } - }; - let mut record = FileRecord { - id, - path: path.clone(), - source: true, - parsed: false, - imports: Vec::new(), - cst: None, - }; - let tokens = match lexer::lex(LexInput { - file_id: FileId::from_index(id as usize), - text: &text, - }) { - Ok(tokens) => tokens, - Err(error) => { - diagnostics.push(error); - files.push(record); - continue; - } - }; - match parser::parse(tokens) { - Ok(file) => { - let dir = parent_dir(&path); - let mut discovered = Vec::new(); - for import in &file.imports { - match resolve_import_path(&dir, &import.path) { - None => { - diagnostics.push(FrontendError::at( - "ostw-missing-import", - format!( - "import '{}' resolves outside the project closure", - import.path - ), - import.span, - )); - record.imports.push(ResolvedImport { - path: import.path.clone(), - span: import.span, - target: None, - }); - } - Some(resolved) => { - pending_imports.push(PendingImport { - from: id, - path: import.path.clone(), - span: import.span, - resolved: resolved.clone(), - }); - discovered.push(resolved); - } - } - } - record.parsed = true; - record.cst = Some(file); - // Enqueue newly discovered targets (deduplicated on dequeue - // and via the queue membership check). - for target in discovered { - if inventory.iter().any(|inv| inv == &target) - && !visited.contains(&target) - && !queue.contains(&target) - { - queue.push_back(target); - } - } - } - Err(error) => { - diagnostics.push(error); - } - } - files.push(record); - } - - // Resolve import targets against the final id map and surface - // missing-import diagnostics for in-closure import statements whose - // target is not part of the project. - for import in pending_imports { - let target = path_to_id.get(&import.resolved).copied(); - if target.is_none() { - diagnostics.push(FrontendError::at( - "ostw-missing-import", - format!( - "import '{}' does not exist in the project closure", - import.path - ), - import.span, - )); - } - if let Some(record) = files.get_mut(import.from as usize) { - record.imports.push(ResolvedImport { - path: import.path, - span: import.span, - target, - }); - } - } - - Ok(OstwOutcome { - project: Some(Project { - entry, - files, - inventory, - }), - error: None, - diagnostics, - }) -} - -/// An import statement whose target id is resolved after the graph walk. -struct PendingImport { - /// The importing file's registry id. - from: u32, - /// The path exactly as written in the import statement. - path: String, - pub span: Span, - /// The lexically resolved project-relative path. - resolved: String, -} - -/// Walk the project root for `.ostw`/`.del` source files (recursive), -/// returning project-relative paths. -fn walk_sources(root: &Path) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { - if extension.eq_ignore_ascii_case("ostw") || extension.eq_ignore_ascii_case("del") { - if let Ok(relative) = path.strip_prefix(root) { - out.push(relative.to_string_lossy().replace('\\', "/")); - } - } - } - } - } - out -} - -/// Parse a minimal `ds.toml`: `key = "value"` lines. Only `entry_point` is -/// supported; any other key yields a structured unsupported-config -/// diagnostic. Returns the entry point (when present) and diagnostics. -fn parse_ds_toml(text: &str, file_id: u32) -> (Option, Vec) { - let mut entry = None; - let mut diagnostics = Vec::new(); - for (line_index, raw_line) in text.lines().enumerate() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((raw_key, raw_value)) = line.split_once('=') else { - let span = line_span(file_id, line_index, raw_line.len()); - diagnostics.push(FrontendError::at( - "ostw-ds-toml-invalid", - format!("invalid ds.toml line: '{line}'"), - span, - )); - continue; - }; - let key = raw_key.trim(); - let value = raw_value.trim(); - let value = value - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')); - let Some(value) = value else { - let span = line_span(file_id, line_index, raw_line.len()); - diagnostics.push(FrontendError::at( - "ostw-ds-toml-invalid", - format!("ds.toml value for '{key}' must be a quoted string"), - span, - )); - continue; - }; - if key == "entry_point" { - entry = Some(value.to_string()); - } else { - let span = line_span(file_id, line_index, raw_line.len()); - diagnostics.push(FrontendError::at( - "ostw-ds-toml-unsupported-key", - format!("unsupported ds.toml key '{key}' (only entry_point is supported in this milestone)"), - span, - )); - } - } - (entry, diagnostics) -} - -fn line_span(file_id: u32, line_index: usize, line_len: usize) -> Span { - let line = line_index as u32 + 1; - Span::new( - FileId::from_index(file_id as usize), - Position::new(line, 1), - Position::new(line, line_len as u32 + 1), - ) -} - -/// The project-relative directory of a path (`""` for a root file). -fn parent_dir(path: &str) -> String { - match path.rfind('/') { - Some(index) => path[..index].to_string(), - None => String::new(), - } -} - -/// Resolve an import path written in `dir` to a normalized project-relative -/// path. Returns `None` when the path escapes the project root (`..` above -/// the root). `\` separators are normalized to `/`. -fn resolve_import_path(dir: &str, import_path: &str) -> Option { - let mut parts: Vec<&str> = Vec::new(); - for part in dir.split('/') { - if !part.is_empty() { - parts.push(part); - } - } - for part in import_path.split(['/', '\\']) { - match part { - "" | "." => {} - ".." => { - parts.pop()?; - } - other => parts.push(other), - } - } - Some(parts.join("/")) -} diff --git a/crates/wright-ostw/src/reconstruct.rs b/crates/wright-ostw/src/reconstruct.rs deleted file mode 100644 index 3070bf4..0000000 --- a/crates/wright-ostw/src/reconstruct.rs +++ /dev/null @@ -1,1276 +0,0 @@ -//! WIR → OSTW reconstruction (#125). -//! -//! The reverse-compilation direction of the declared Workshop surface: a -//! validated [`wir::Program`] is converted into deterministic, canonical -//! OSTW source that the native [`crate::compile_with_semantics`] frontend -//! accepts and re-lowers to semantically equivalent Workshop (the declared -//! #119 normalization contract; see the integration suite in -//! `crates/wright-ostw/tests/reconstruct.rs`). -//! -//! Design: -//! -//! * **Total classification first.** [`reconstruct`] runs a classification -//! pre-pass over every variable name, subroutine, rule, action, and value -//! before emitting anything. Any WIR construct outside the declared -//! reconstruction surface produces a structured [`ReconstructError`]; the -//! result is an error list and *no* partial or misleading OSTW source is -//! ever produced. -//! * **No speculative recovery.** Classes, macros, functions, project -//! structure, variable types/indexes, and original formatting are not -//! recovered; the emitted text is low-level canonical OSTW over the -//! frontend's accepted surface. Variables declare the permissive -//! universal `Any` type (the WIR carries no type information; both the -//! native frontend and the pinned v3.4.0 reference accept it). -//! Variable-table identity (names, slots) is outside the declared -//! semantic comparison (the #119 contract). -//! * **Canonical bindings only.** OSTW source names are derived by reversing -//! the existing [`crate::signature`] binding table (OSTW source name ↔ -//! canonical catalog id); enum domains/members reverse the enum bindings -//! the same way. Every emitted name is validated against the canonical -//! [`Catalog`], never invented here. Arithmetic (catalog `add`/`subtract`/ -//! `multiply`/`divide`) emits as the real OSTW infix operators -//! `+ - * /` — the pinned reference rejects callable `Add(...)` forms — -//! and the shared Workshop emitter canonicalizes them back to the catalog -//! spellings, so the round trip is byte-stable. -//! * **Determinism.** All arenas are iterated in index order and the output -//! formatting is fixed, so the same validated WIR always yields -//! byte-identical OSTW text. - -use std::collections::HashSet; -use std::fmt::Write; - -use workshop_rs::catalog::{Catalog, Kind}; -use workshop_rs::source::Span; -use workshop_rs::wir::{self, Action, Event, ModifyOp, Value, ValueId}; - -use crate::signature; - -/// A structured reconstruction failure. -/// -/// The `code` is a stable machine-readable identifier; `kind` names the WIR -/// construct that is not representable on the declared reconstruction -/// surface (the machine-readable boundary manifest under -/// `compatibility/ostw/reconstruction/support-boundary.json` uses the same -/// spellings); `span` is the offending source region when the WIR carries -/// one. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReconstructError { - /// A stable machine-readable code, e.g. `reconstruct-unsupported-action`. - pub code: &'static str, - /// The WIR construct kind, e.g. `forPlayerVariable`, `settings`, `debug`. - pub kind: String, - /// Human-readable message (not part of the machine contract). - pub message: String, - /// The offending source region, when known. - pub span: Option, -} - -impl ReconstructError { - fn new(code: &'static str, kind: impl Into, message: impl Into) -> Self { - ReconstructError { - code, - kind: kind.into(), - message: message.into(), - span: None, - } - } - - fn at( - code: &'static str, - kind: impl Into, - message: impl Into, - span: Option, - ) -> Self { - ReconstructError { - code, - kind: kind.into(), - message: message.into(), - span, - } - } -} - -impl std::fmt::Display for ReconstructError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for ReconstructError {} - -/// Reconstruct canonical OSTW source from a validated Workshop IR program. -/// -/// Returns `Err` with **every** structured rejection when any WIR construct -/// lies outside the declared reconstruction surface (never partial output). -/// The input must be structurally valid ([`wir::Program::validate`]). -pub fn reconstruct( - program: &wir::Program, - catalog: &Catalog, -) -> Result> { - let diagnostics = Classifier::new(program, catalog).classify(); - if !diagnostics.is_empty() { - return Err(diagnostics); - } - Ok(Emitter::new(program, catalog).run()) -} - -// --------------------------------------------------------------------------- -// Reverse binding lookups (OSTW source name <-> canonical catalog identity). -// --------------------------------------------------------------------------- - -/// The OSTW source name for a canonical catalog action id, or `None` when no -/// binding exists (the action is not representable on the declared surface). -pub fn action_ostw_name(id: &str) -> Option<&'static str> { - signature::BUILTIN_BINDINGS - .iter() - .find(|(_, (kind, candidate))| *kind == Kind::Action && *candidate == id) - .map(|(source, _)| *source) -} - -/// The OSTW source name for a canonical catalog value id, or `None` when no -/// binding exists (the value is not representable on the declared surface). -pub fn value_ostw_name(id: &str) -> Option<&'static str> { - signature::BUILTIN_BINDINGS - .iter() - .find(|(_, (kind, candidate))| *kind == Kind::Value && *candidate == id) - .map(|(source, _)| *source) -} - -/// The OSTW source domain name and source member name for a canonical -/// catalog enum member, or `None` when no binding covers the domain/member. -pub fn enum_ostw(domain: &str, member: &str) -> Option<(&'static str, &'static str)> { - let (source, binding) = signature::ENUM_DOMAIN_BINDINGS - .iter() - .find(|(_, binding)| binding.domain == domain)?; - let source_member = binding - .members - .iter() - .find(|(_, canonical)| *canonical == member) - .map(|(source, _)| *source)?; - Some((source, source_member)) -} - -/// Every canonical catalog action id with an OSTW binding, in binding order -/// (first binding wins for duplicated ids). Used by the boundary manifest -/// conformance test. -pub fn bound_action_ids() -> Vec<(&'static str, &'static str)> { - let mut seen = HashSet::new(); - signature::BUILTIN_BINDINGS - .iter() - .filter_map(|(source, (kind, id))| { - if *kind != Kind::Action || !seen.insert(*id) { - return None; - } - Some((*id, *source)) - }) - .collect() -} - -/// Every canonical catalog value id with an OSTW binding, in binding order -/// (first binding wins for duplicated ids). Used by the boundary manifest -/// conformance test. -pub fn bound_value_ids() -> Vec<(&'static str, &'static str)> { - let mut seen = HashSet::new(); - signature::BUILTIN_BINDINGS - .iter() - .filter_map(|(source, (kind, id))| { - if *kind != Kind::Value || !seen.insert(*id) { - return None; - } - Some((*id, *source)) - }) - .collect() -} - -/// One reverse enum binding: canonical catalog domain, OSTW source domain -/// name, and the (canonical member, OSTW member) mapping. -pub struct EnumDomainBindingRev { - /// The canonical catalog domain name. - pub domain: &'static str, - /// The OSTW source domain name. - pub source: &'static str, - /// Canonical catalog member id → OSTW source member name. - pub members: Vec<(&'static str, &'static str)>, -} - -/// Every canonical catalog enum domain with an OSTW binding, with the -/// (canonical member, OSTW member) mapping. Used by the boundary manifest -/// conformance test. -pub fn bound_enum_domains() -> Vec { - signature::ENUM_DOMAIN_BINDINGS - .iter() - .map(|(source, binding)| EnumDomainBindingRev { - domain: binding.domain, - source, - members: binding - .members - .iter() - .map(|(source, canonical)| (*canonical, *source)) - .collect(), - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Classification (total pre-pass; collects every structured rejection). -// --------------------------------------------------------------------------- - -/// The comparison operators that render infix in both OSTW and the shared -/// Workshop emitter. -const COMPARISON_OPS: &[&str] = &["==", "!=", "<", "<=", ">", ">="]; - -fn is_comparison_op(name: &str) -> bool { - COMPARISON_OPS.contains(&name) -} - -/// Whether a value node contains a strict-greater comparison anywhere in its -/// subtree. A bare `>` terminates an enclosing `<"..."` formatted string in -/// the OSTW parser, so such a value cannot be an argument of a reconstructed -/// format string. -fn contains_strict_greater(program: &wir::Program, id: ValueId) -> bool { - fn walk(program: &wir::Program, id: ValueId) -> bool { - let Some(node) = program.values.get(id) else { - return false; - }; - let children: Vec = match &node.value { - Value::Array(elements) => elements.clone(), - Value::Vector { x, y, z } => vec![*x, *y, *z], - Value::PlayerVariable { player, .. } => vec![*player], - Value::Call { name, args } => { - if name == ">" && args.len() == 2 { - return true; - } - args.clone() - } - _ => Vec::new(), - }; - children.into_iter().any(|child| walk(program, child)) - } - walk(program, id) -} - -struct Classifier<'a> { - program: &'a wir::Program, - catalog: &'a Catalog, - errors: Vec, - /// Subroutine id → its body rule id, when the program defines one. - subroutine_rules: Vec>, -} - -impl<'a> Classifier<'a> { - fn new(program: &'a wir::Program, catalog: &'a Catalog) -> Self { - let mut subroutine_rules: Vec> = vec![None; program.subroutines.len()]; - for (index, rule) in program.rules.iter().enumerate() { - if let Event::Subroutine(subroutine) = &rule.event { - subroutine_rules[subroutine.index()] = Some(wir::RuleId::from_index(index)); - } - } - Classifier { - program, - catalog, - errors: Vec::new(), - subroutine_rules, - } - } - - fn classify(mut self) -> Vec { - self.check_settings(); - self.check_names(); - self.check_subroutines(); - for rule in self.program.rules.iter() { - self.check_rule(rule); - } - self.errors - } - - fn error(&mut self, error: ReconstructError) { - self.errors.push(error); - } - - fn check_settings(&mut self) { - if self.program.settings.is_some() { - self.error(ReconstructError::new( - "reconstruct-unsupported-program-settings", - "settings", - "custom-game settings (Program.settings) have no OSTW source form on the \ - declared reconstruction surface", - )); - } - } - - /// Variable/subroutine names must be unambiguous OSTW identifiers: a - /// name that collides with a builtin source binding or an enum domain - /// source name would be shadowed by the frontend's resolution (a global - /// and a player variable sharing a name would resolve to the global), - /// producing misleading source instead of a rejection. - fn check_names(&mut self) { - let mut names: Vec<(String, Option)> = Vec::new(); - for variable in self.program.global_variables.iter() { - names.push((variable.name.clone(), variable.span)); - } - for variable in self.program.player_variables.iter() { - names.push((variable.name.clone(), variable.span)); - } - for subroutine in self.program.subroutines.iter() { - names.push((subroutine.name.clone(), subroutine.span)); - } - for (name, span) in &names { - if name.is_empty() { - self.error(ReconstructError::at( - "reconstruct-name-collision", - "empty-name", - "a variable or subroutine with an empty name is not representable in OSTW", - *span, - )); - continue; - } - if signature::builtin(name).is_some() { - self.error(ReconstructError::at( - "reconstruct-name-collision", - "name-collision", - format!( - "name '{name}' collides with the OSTW source name of a Workshop \ - builtin; variable/subroutine references would be shadowed by the \ - frontend's builtin resolution" - ), - *span, - )); - } - if signature::enum_domain(name).is_some() { - self.error(ReconstructError::at( - "reconstruct-name-collision", - "name-collision", - format!( - "name '{name}' collides with an OSTW enum domain source name; \ - member references would be shadowed by the frontend's enum resolution" - ), - *span, - )); - } - } - // A global and a player variable sharing a name resolve to the - // global in the frontend; reject instead of emitting misleading - // source. - let globals: HashSet<&str> = self - .program - .global_variables - .iter() - .map(|variable| variable.name.as_str()) - .collect(); - for variable in self.program.player_variables.iter() { - if globals.contains(variable.name.as_str()) { - self.error(ReconstructError::at( - "reconstruct-name-collision", - "name-collision", - format!( - "player variable '{}' shares its name with a global variable; the \ - frontend resolves the bare name to the global, so the reconstructed \ - source would be misleading", - variable.name - ), - variable.span, - )); - } - } - } - - /// A subroutine is representable exactly when the program defines its - /// body rule: a `void name() "..." { body }` function regenerates one - /// `Subroutine`-event rule from the body. Subroutines without a body - /// (or with an empty one) would be dropped by the Workshop emitter - /// (empty-action rules emit nothing), so they cannot round-trip. - fn check_subroutines(&mut self) { - for (index, subroutine) in self.program.subroutines.iter().enumerate() { - let Some(rule_id) = self.subroutine_rules[index] else { - self.error(ReconstructError::at( - "reconstruct-unsupported-subroutine", - "subroutine", - format!( - "subroutine '{}' has no body rule; a subroutine is representable only \ - through its single Subroutine-event rule body", - subroutine.name - ), - subroutine.span, - )); - continue; - }; - let rule = &self.program.rules.get(rule_id).expect("rule id in range"); - if rule.actions.is_empty() { - self.error(ReconstructError::at( - "reconstruct-unsupported-subroutine", - "subroutine", - format!( - "subroutine '{}' has an empty body rule; the Workshop emitter drops \ - empty-action rules, so the reconstructed Workshop would lose it", - subroutine.name - ), - subroutine.span, - )); - } - if !rule.conditions.is_empty() { - self.error(ReconstructError::at( - "reconstruct-unsupported-subroutine", - "subroutine", - format!( - "subroutine '{}' body rule carries conditions; the native frontend \ - models subroutines as condition-free functions", - subroutine.name - ), - rule.span, - )); - } - } - } - - fn check_rule(&mut self, rule: &wir::Rule) { - // Rule conditions must be two-operand comparison calls: the shared - // Workshop emitter renders comparison conditions infix and renders - // every other condition as `value == True`, so only comparison - // conditions round-trip through the declared normalization. - for condition in &rule.conditions { - let comparison = matches!( - self.program.values.get(*condition).map(|node| &node.value), - Some(Value::Call { name, args }) if is_comparison_op(name) && args.len() == 2 - ); - if !comparison { - self.error(ReconstructError::at( - "reconstruct-unsupported-condition", - "condition", - "a rule condition must be a two-operand comparison call on the declared \ - reconstruction surface (the shared Workshop emitter renders only those \ - infix; other conditions become `value == True`)", - rule.span, - )); - continue; - } - let Some(Value::Call { args, .. }) = - self.program.values.get(*condition).map(|node| &node.value) - else { - continue; - }; - self.check_value(args[0]); - self.check_value(args[1]); - } - for action in &rule.actions { - self.check_action(*action); - } - } - - fn check_action(&mut self, id: wir::ActionId) { - let Some(action) = self.program.actions.get(id) else { - return; - }; - match action { - Action::SetGlobalVariable { value, .. } => self.check_value(*value), - Action::ModifyGlobalVariable { op, value, .. } => { - self.check_modify_op(*op, action.span()); - self.check_value(*value); - } - Action::SetPlayerVariable { player, value, .. } => { - self.check_value(*player); - self.check_value(*value); - } - Action::ModifyPlayerVariable { - player, op, value, .. - } => { - self.check_modify_op(*op, action.span()); - // A non-Event-Player receiver cannot round-trip: the - // frontend's augmented assignment only recognizes the - // Event Player receiver as a modify target (`p += v`), so a - // `(receiver).p += v` would lower to a Set with a binary - // value. - let event_player = matches!( - self.program.values.get(*player).map(|node| &node.value), - Some(Value::EventPlayer) - ); - if !event_player { - self.error(ReconstructError::at( - "reconstruct-unsupported-player-receiver", - "playerModifyReceiver", - "a player-variable modify with a non-Event-Player receiver is not \ - representable on the declared surface (the frontend's augmented \ - assignment only recognizes the Event Player receiver as a modify \ - target)", - action.span(), - )); - } - self.check_value(*player); - self.check_value(*value); - } - Action::AssignMember { - target, op, value, .. - } => { - self.error(ReconstructError::at( - "reconstruct-unsupported-action", - "assignMember", - "dynamic member assignment is outside the declared OSTW reconstruction surface", - action.span(), - )); - if let Some(op) = op { - self.check_modify_op(*op, action.span()); - } - self.check_value(*target); - self.check_value(*value); - } - Action::CallSubroutine { .. } => {} - Action::If { - branches, - else_body, - .. - } => { - for branch in branches { - self.check_value(branch.condition); - for action in &branch.body { - self.check_action(*action); - } - } - if let Some(else_body) = else_body { - for action in else_body { - self.check_action(*action); - } - } - } - Action::While { - condition, body, .. - } => { - self.check_value(*condition); - for action in body { - self.check_action(*action); - } - } - Action::ForGlobalVariable { - start, - stop, - step, - body, - .. - } => { - self.check_value(*start); - self.check_value(*stop); - self.check_value(*step); - for action in body { - self.check_action(*action); - } - } - Action::ForPlayerVariable { .. } => { - self.error(ReconstructError::at( - "reconstruct-unsupported-action", - "forPlayerVariable", - "'For Player Variable' is outside the declared reconstruction surface \ - (the frontend lowers loop counters as globals; the per-player loop form \ - has no OSTW source form on this surface)", - action.span(), - )); - } - Action::Debug { .. } => { - self.error(ReconstructError::at( - "reconstruct-unsupported-action", - "debug", - "the Wright 'debug' action has no OSTW source binding on the declared \ - reconstruction surface", - action.span(), - )); - } - Action::Print { .. } => { - self.error(ReconstructError::at( - "reconstruct-unsupported-action", - "print", - "the Wright 'print' action has no OSTW source binding on the declared \ - reconstruction surface", - action.span(), - )); - } - Action::Call { name, args, .. } => { - if name == "abort" && args.is_empty() { - return; // representable as `return;` - } - match action_ostw_name(name) { - Some(_) => { - let arity = self - .catalog - .entry(Kind::Action, name) - .map(|entry| entry.params.len()) - .unwrap_or(0); - if args.len() != arity { - self.error(ReconstructError::at( - "reconstruct-arity", - format!("action:{name}"), - format!( - "action call '{name}' supplies {} of {arity} canonical \ - arguments; the declared reconstruction surface requires \ - the full canonical arity so the frontend re-resolution is \ - byte-stable", - args.len() - ), - action.span(), - )); - } - for arg in args { - self.check_value(*arg); - } - } - None => { - self.error(ReconstructError::at( - "reconstruct-unbound-call", - format!("action:{name}"), - format!( - "action call '{name}' has no OSTW source binding on the \ - declared reconstruction surface" - ), - action.span(), - )); - for arg in args { - self.check_value(*arg); - } - } - } - } - } - } - - fn check_modify_op(&mut self, op: ModifyOp, span: Option) { - match op { - ModifyOp::Add - | ModifyOp::Subtract - | ModifyOp::Multiply - | ModifyOp::Divide - | ModifyOp::Modulo => {} - ModifyOp::AppendToArray => {} // representable as `receiver.append(value)` - ModifyOp::RaiseToPower - | ModifyOp::RemoveFromArray - | ModifyOp::RemoveFromArrayByIndex => { - self.error(ReconstructError::at( - "reconstruct-unsupported-modify-op", - format!("modifyOp:{}", op.as_str()), - format!( - "modify operator '{}' has no OSTW assignment form on the declared \ - reconstruction surface", - op.as_str() - ), - span, - )); - } - } - } - - fn check_value(&mut self, id: ValueId) { - let Some(node) = self.program.values.get(id) else { - return; - }; - match &node.value { - Value::Number { text, .. } => { - // The OSTW lexer accepts `[0-9]+(\.[0-9]+)?` only; a - // different spelling (signs, exponents, computed forms) - // would not round-trip through the frontend. - let valid = !text.is_empty() - && text.chars().all(|ch| ch.is_ascii_digit() || ch == '.') - && text.chars().any(|ch| ch.is_ascii_digit()); - if !valid { - self.error(ReconstructError::at( - "reconstruct-unsupported-number", - "number", - format!("number literal '{text}' is not a valid OSTW number spelling"), - node.span, - )); - } - } - Value::String(_) | Value::Bool(_) | Value::Null | Value::EventPlayer => {} - Value::LocalizedString(value) => { - self.error(ReconstructError::at( - "reconstruct-unsupported-localized-string", - format!("localized-string:{value}"), - "localized Workshop preset strings have no OSTW source representation", - node.span, - )); - } - Value::Array(elements) => { - for element in elements { - self.check_value(*element); - } - } - Value::Vector { x, y, z } => { - self.check_value(*x); - self.check_value(*y); - self.check_value(*z); - } - Value::Enum { value_type, value } => { - let bound = enum_ostw(value_type, value); - let catalog_member = self - .catalog - .enum_domain(value_type) - .is_some_and(|domain| domain.members.iter().any(|m| m.member == *value)); - if bound.is_none() || !catalog_member { - self.error(ReconstructError::at( - "reconstruct-unbound-enum", - format!("enum:{value_type}.{value}"), - format!( - "enum value '{value_type}.{value}' has no OSTW source binding on \ - the declared reconstruction surface" - ), - node.span, - )); - } - } - Value::GlobalVariable(_) => {} - Value::PlayerVariable { player, .. } => self.check_value(*player), - Value::Subroutine(subroutine) => { - if self.program.subroutines.get(*subroutine).is_none() { - self.error(ReconstructError::at( - "reconstruct-dangling-subroutine", - "subroutine", - format!("subroutine value '{subroutine}' does not reference a declared subroutine"), - node.span, - )); - } - } - Value::Call { name, args } => self.check_value_call(name, args, node.span), - } - } - - fn check_value_call(&mut self, name: &str, args: &[ValueId], span: Option) { - let check_operands = |classifier: &mut Classifier<'_>, count: usize, what: &str| { - if args.len() != count { - classifier.error(ReconstructError::at( - "reconstruct-arity", - format!("value:{name}"), - format!( - "value call '{name}' must take {count} arguments for the declared \ - reconstruction surface ({what}), got {}", - args.len() - ), - span, - )); - } - for arg in args { - classifier.check_value(*arg); - } - }; - if is_comparison_op(name) { - return check_operands(self, 2, "two operands"); - } - match name { - "and" | "or" | "add" | "subtract" | "multiply" | "divide" => { - return check_operands(self, 2, "two operands"); - } - "not" => return check_operands(self, 1, "one operand"), - "array" => { - for arg in args { - self.check_value(*arg); - } - return; - } - "vector" => return check_operands(self, 3, "three components"), - "valueInArray" => return check_operands(self, 2, "an array and an index"), - "ifThenElse" => { - return check_operands(self, 3, "a condition, a then-value, and an else-value"); - } - "customString" | "format" => { - let literal = matches!( - args.first() - .and_then(|arg| self.program.values.get(*arg)) - .map(|node| &node.value), - Some(Value::String(_)) - ); - if !literal { - self.error(ReconstructError::at( - "reconstruct-unsupported-format-text", - "formatText", - "a 'customString'/'format' value must take a string-literal text \ - argument on the declared reconstruction surface", - span, - )); - } - for arg in args.iter().skip(1) { - self.check_value(*arg); - // A strict-greater comparison would terminate the - // enclosing `<"..."` formatted string in the OSTW - // parser. - if contains_strict_greater(self.program, *arg) { - self.error(ReconstructError::at( - "reconstruct-unsupported-format-arg", - "formatArg", - "a '>' comparison inside a reconstructed formatted string is not \ - representable (it would terminate the OSTW `<\"...\"` string)", - span, - )); - } - } - return; - } - _ => {} - } - match value_ostw_name(name) { - Some(_) => { - let arity = self - .catalog - .entry(Kind::Value, name) - .map(|entry| entry.params.len()) - .unwrap_or(0); - if args.len() != arity { - self.error(ReconstructError::at( - "reconstruct-arity", - format!("value:{name}"), - format!( - "value call '{name}' supplies {} of {arity} canonical arguments; \ - the declared reconstruction surface requires the full canonical \ - arity so the frontend re-resolution is byte-stable", - args.len() - ), - span, - )); - } - for arg in args { - self.check_value(*arg); - } - } - None => { - self.error(ReconstructError::at( - "reconstruct-unbound-call", - format!("value:{name}"), - format!( - "value call '{name}' has no OSTW source binding on the declared \ - reconstruction surface" - ), - span, - )); - for arg in args { - self.check_value(*arg); - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Emission (runs only after classification succeeds). -// --------------------------------------------------------------------------- - -struct Emitter<'a> { - program: &'a wir::Program, - out: String, - /// Subroutine id → its body rule id. - subroutine_rules: Vec>, -} - -impl<'a> Emitter<'a> { - fn new(program: &'a wir::Program, _catalog: &'a Catalog) -> Self { - let mut subroutine_rules: Vec> = vec![None; program.subroutines.len()]; - for (index, rule) in program.rules.iter().enumerate() { - if let Event::Subroutine(subroutine) = &rule.event { - subroutine_rules[subroutine.index()] = Some(wir::RuleId::from_index(index)); - } - } - Emitter { - program, - out: String::new(), - subroutine_rules, - } - } - - fn run(mut self) -> String { - // The pinned OSTW v3.4.0 reference requires a declared type on - // `globalvar`/`playervar` declarations; the WIR carries no type - // information, so the permissive universal `Any` type is emitted - // (honest: the variable genuinely may hold any type). Wright's - // native frontend also accepts `Any`. - for variable in self.program.global_variables.iter() { - self.line(0, &format!("globalvar Any {};", variable.name)); - } - for variable in self.program.player_variables.iter() { - self.line(0, &format!("playervar Any {};", variable.name)); - } - if !self.program.global_variables.is_empty() || !self.program.player_variables.is_empty() { - self.out.push('\n'); - } - for subroutine in self.program.subroutines.iter() { - let rule_id = self.subroutine_rules[subroutine.index as usize].expect("classified"); - let rule = self.program.rules.get(rule_id).expect("rule id in range"); - self.line( - 0, - &format!( - "void {}() \"{}\" {{", - subroutine.name, - escape_string(&rule.name) - ), - ); - self.emit_actions(&rule.actions, 1); - self.line(0, "}"); - self.out.push('\n'); - } - for rule in self.program.rules.iter() { - if matches!(rule.event, Event::Subroutine(_)) { - continue; - } - self.emit_rule(rule); - } - self.out - } - - fn emit_rule(&mut self, rule: &wir::Rule) { - let mut header = if rule.disabled { - format!("disabled rule: \"{}\"", escape_string(&rule.name)) - } else { - format!("rule: \"{}\"", escape_string(&rule.name)) - }; - match &rule.event { - Event::Global => {} - Event::EachPlayer => { - write!(header, " Event.OngoingPlayer").unwrap(); - } - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - } => { - write!(header, " Event.OngoingPlayer").unwrap(); - } - Event::EachPlayerWithFilters { .. } | Event::Player { .. } => { - unreachable!("filtered/player events are outside the OSTW reconstruction surface") - } - Event::Subroutine(_) => unreachable!("subroutine rules emit as functions"), - } - for condition in &rule.conditions { - write!(header, " if ({})", self.value(*condition)).unwrap(); - } - self.line(0, &format!("{header} {{")); - self.emit_actions(&rule.actions, 1); - self.line(0, "}"); - self.out.push('\n'); - } - - fn emit_actions(&mut self, actions: &[wir::ActionId], level: usize) { - for action in actions { - self.emit_action(*action, level); - } - } - - fn emit_action(&mut self, id: wir::ActionId, level: usize) { - let action = self.program.actions.get(id).expect("classified"); - match action { - Action::SetGlobalVariable { - variable, value, .. - } => { - self.line( - level, - &format!("{} = {};", self.global_name(*variable), self.value(*value)), - ); - } - Action::ModifyGlobalVariable { - variable, - op, - value, - .. - } => { - let name = self.global_name(*variable); - if *op == ModifyOp::AppendToArray { - self.line(level, &format!("{name}.append({});", self.value(*value))); - } else { - self.line( - level, - &format!("{name} {} {};", assign_op_spelling(*op), self.value(*value)), - ); - } - } - Action::SetPlayerVariable { - player, - variable, - value, - .. - } => { - let name = self.player_name(*variable); - let target = if matches!( - self.program.values.get(*player).map(|node| &node.value), - Some(Value::EventPlayer) - ) { - name - } else { - format!("({}).{name}", self.value(*player)) - }; - self.line(level, &format!("{target} = {};", self.value(*value))); - } - Action::ModifyPlayerVariable { - variable, - op, - value, - .. - } => { - let name = self.player_name(*variable); - self.line( - level, - &format!("{name} {} {};", assign_op_spelling(*op), self.value(*value)), - ); - } - Action::CallSubroutine { subroutine, .. } => { - let name = self.subroutine_name(*subroutine); - self.line(level, &format!("{name}();")); - } - Action::If { - branches, - else_body, - .. - } => { - for (index, branch) in branches.iter().enumerate() { - let keyword = if index == 0 { "if" } else { "else if" }; - self.line( - level, - &format!("{keyword} ({}) {{", self.value(branch.condition)), - ); - self.emit_actions(&branch.body, level + 1); - self.line(level, "}"); - } - if let Some(else_body) = else_body { - self.line(level, "else {"); - self.emit_actions(else_body, level + 1); - self.line(level, "}"); - } - } - Action::While { - condition, body, .. - } => { - self.line(level, &format!("while ({}) {{", self.value(*condition))); - self.emit_actions(body, level + 1); - self.line(level, "}"); - } - Action::ForGlobalVariable { - variable, - start, - stop, - step, - body, - .. - } => { - self.line( - level, - &format!( - "for ({} = {}; {}; {}) {{", - self.global_name(*variable), - self.value(*start), - self.value(*stop), - self.value(*step) - ), - ); - self.emit_actions(body, level + 1); - self.line(level, "}"); - } - Action::AssignMember { .. } - | Action::ForPlayerVariable { .. } - | Action::Debug { .. } - | Action::Print { .. } => { - unreachable!("classified as unsupported") - } - Action::Call { name, args, .. } => { - if name == "abort" && args.is_empty() { - self.line(level, "return;"); - return; - } - let ostw = action_ostw_name(name).expect("classified"); - if args.is_empty() { - self.line(level, &format!("{ostw}();")); - } else { - let args = args - .iter() - .map(|arg| self.value(*arg)) - .collect::>() - .join(", "); - self.line(level, &format!("{ostw}({args});")); - } - } - } - } - - /// Render one value as an OSTW expression. - fn value(&self, id: ValueId) -> String { - let node = self.program.values.get(id).expect("classified"); - match &node.value { - Value::Number { text, .. } => text.clone(), - Value::String(value) => format!("\"{}\"", escape_string(value)), - Value::LocalizedString(_) => unreachable!("classified"), - Value::Bool(true) => "true".to_string(), - Value::Bool(false) => "false".to_string(), - Value::Null => "null".to_string(), - Value::Array(elements) => { - let elements = elements - .iter() - .map(|element| self.value(*element)) - .collect::>() - .join(", "); - format!("[{elements}]") - } - Value::Vector { x, y, z } => format!( - "Vector({}, {}, {})", - self.value(*x), - self.value(*y), - self.value(*z) - ), - Value::Enum { value_type, value } => { - let (source, member) = enum_ostw(value_type, value).expect("classified"); - format!("{source}.{member}") - } - Value::GlobalVariable(variable) => self.global_name(*variable), - Value::PlayerVariable { player, variable } => { - let name = self.player_name(*variable); - if matches!( - self.program.values.get(*player).map(|node| &node.value), - Some(Value::EventPlayer) - ) { - name - } else { - format!("({}).{name}", self.value(*player)) - } - } - Value::Subroutine(subroutine) => self.subroutine_name(*subroutine), - Value::EventPlayer => "EventPlayer()".to_string(), - Value::Call { name, args } => self.value_call(name, args), - } - } - - /// Render a value call using the OSTW source form that re-lowers to the - /// same canonical catalog identity. - fn value_call(&self, name: &str, args: &[ValueId]) -> String { - if is_comparison_op(name) { - return format!("{} {name} {}", self.operand(args[0]), self.operand(args[1])); - } - match name { - "and" => format!("{} && {}", self.operand(args[0]), self.operand(args[1])), - "or" => format!("{} || {}", self.operand(args[0]), self.operand(args[1])), - "add" => format!("{} + {}", self.operand(args[0]), self.operand(args[1])), - "subtract" => format!("{} - {}", self.operand(args[0]), self.operand(args[1])), - "multiply" => format!("{} * {}", self.operand(args[0]), self.operand(args[1])), - "divide" => format!("{} / {}", self.operand(args[0]), self.operand(args[1])), - "not" => format!("!{}", self.operand(args[0])), - "array" => { - let elements = args - .iter() - .map(|arg| self.value(*arg)) - .collect::>() - .join(", "); - format!("[{elements}]") - } - "vector" => format!( - "Vector({}, {}, {})", - self.value(args[0]), - self.value(args[1]), - self.value(args[2]) - ), - "valueInArray" => format!("({})[{}]", self.value(args[0]), self.value(args[1])), - "ifThenElse" => format!( - "{} ? {} : {}", - self.operand(args[0]), - self.operand(args[1]), - self.operand(args[2]) - ), - "customString" | "format" => { - let text = match &self.program.values.get(args[0]).expect("classified").value { - Value::String(text) => text.clone(), - _ => String::new(), - }; - if args.len() == 1 { - format!("<\"{}\">", escape_string(&text)) - } else { - let rest = args[1..] - .iter() - .map(|arg| self.value(*arg)) - .collect::>() - .join(", "); - format!("<\"{}\", {rest}>", escape_string(&text)) - } - } - _ => { - let ostw = value_ostw_name(name).expect("classified"); - let args = args - .iter() - .map(|arg| self.value(*arg)) - .collect::>() - .join(", "); - format!("{ostw}({args})") - } - } - } - - /// Render an operand of an infix/ternary operator: parenthesized when it - /// is itself an operator expression, so the parsed tree is unambiguous. - fn operand(&self, id: ValueId) -> String { - let text = self.value(id); - let needs_parens = matches!( - self.program.values.get(id).map(|node| &node.value), - Some(Value::Call { name, .. }) - if is_comparison_op(name) - || matches!( - name.as_str(), - "and" | "or" | "not" | "ifThenElse" | "add" | "subtract" | "multiply" - | "divide" - ) - ); - if needs_parens { - format!("({text})") - } else { - text - } - } - - fn global_name(&self, id: wir::GlobalVarId) -> String { - self.program - .global_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() - } - - fn player_name(&self, id: wir::PlayerVarId) -> String { - self.program - .player_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() - } - - fn subroutine_name(&self, id: wir::SubroutineId) -> String { - self.program - .subroutines - .get(id) - .map(|subroutine| subroutine.name.clone()) - .unwrap_or_default() - } - - fn line(&mut self, level: usize, text: &str) { - for _ in 0..level { - self.out.push_str(" "); - } - self.out.push_str(text); - self.out.push('\n'); - } -} - -/// The OSTW augmented-assignment operator for a modify op (AppendToArray has -/// no assignment form; it is emitted as `receiver.append(value)`). -fn assign_op_spelling(op: ModifyOp) -> &'static str { - match op { - ModifyOp::Add => "+=", - ModifyOp::Subtract => "-=", - ModifyOp::Multiply => "*=", - ModifyOp::Divide => "/=", - ModifyOp::Modulo => "%=", - ModifyOp::AppendToArray - | ModifyOp::RaiseToPower - | ModifyOp::RemoveFromArray - | ModifyOp::RemoveFromArrayByIndex => { - unreachable!("classified") - } - } -} - -/// Escape a string for an OSTW string literal (the frontend decodes exactly -/// these escapes). -fn escape_string(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\t' => out.push_str("\\t"), - '\r' => out.push_str("\\r"), - other => out.push(other), - } - } - out -} diff --git a/crates/wright-ostw/src/semantic.rs b/crates/wright-ostw/src/semantic.rs deleted file mode 100644 index a03fdcf..0000000 --- a/crates/wright-ostw/src/semantic.rs +++ /dev/null @@ -1,1625 +0,0 @@ -//! The OSTW semantic phase (#118): declaration collection, cross-file name -//! resolution, type resolution, and frontend-neutral HIR construction over -//! the #117 entry-point reachable project graph. -//! -//! Ownership: the resolver lives entirely in `wright-ostw`; the produced HIR -//! is `wright_ir::hir::Program` (with narrow frontend-neutral extensions for -//! user enums, typed functions/parameters, rule priority, ternary/cast, and -//! switch/return/break/foreach/for-loop forms evidenced by the pinned -//! reference probes). Workshop actions/values/enums resolve through the -//! canonical Wright-owned Workshop catalog (via the OSTW-only source bindings -//! in [`crate::signature`]) — no OSTW game-derived table. -//! -//! Unsupported reachable boundaries (classes/`new`, `define` function -//! macros, the missing `../OSTWUtils`/`Cursor`/`Math` surfaces) fail during -//! resolution with deterministic, structured, source-located diagnostics — -//! never deferred to emission. The HIR records best-effort generic calls for -//! those boundary forms so it stays structurally valid. - -use std::collections::HashMap; - -use wright_ir::hir::{self, ExprId, FunctionId, GlobalVarId, PlayerVarId, StmtId, SubroutineId}; - -use workshop_rs::catalog::{Catalog, Kind}; - -use crate::cst; -use crate::diag::FrontendError; -use crate::project::Project; -use crate::signature; -use workshop_rs::source::Span; - -/// The canonical Wright Workshop catalog, loaded once. Workshop builtins and -/// enum domains resolve through it at the consume sites; wright-ostw ships -/// only OSTW source-name bindings ([`crate::signature`]). -fn catalog() -> &'static Catalog { - static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); - CATALOG - .get_or_init(|| Catalog::builtin().expect("the embedded Wright Workshop catalog validates")) -} - -/// The outcome of the semantic phase: validated HIR plus diagnostics. -#[derive(Debug, Clone)] -pub struct SemanticOutcome { - /// The resolved frontend-neutral HIR, when the project loaded. Even with - /// diagnostics the HIR is produced (boundary forms are recorded as - /// generic calls) and validates structurally. - pub hir: Option, - pub diagnostics: Vec, -} - -/// Resolve the semantic surface of a loaded project into HIR. -pub fn compile(project: &Project) -> SemanticOutcome { - Resolver::new(project).run() -} - -/// A local binding visible while resolving a body. -enum Local { - /// A named parameter. - Param, - /// The foreach element: references lower to `Index(iterable, counter)`. - ForeachElement(ExprId), -} - -struct Resolver<'a> { - project: &'a Project, - hir: hir::Program, - diagnostics: Vec, - - global_ids: HashMap, - player_ids: HashMap, - enum_ids: HashMap, - function_ids: HashMap, - subroutine_ids: HashMap, - constant_ids: HashMap, - - /// The rule-named void functions recorded as Workshop subroutines, in - /// declaration order. - pending_subroutines: Vec, - /// Rules whose bodies resolve after declarations are collected. - pending_rules: Vec, - - /// Explicit global/player indexes seen during collection (P3b duplicate - /// id detection). - explicit_global_ids: HashMap, - explicit_player_ids: HashMap, - - /// The current rule's event ("global"/"eachPlayer"). - current_event: String, - /// Local bindings while resolving a body (params, foreach elements). - locals: Vec>, -} - -impl<'a> Resolver<'a> { - fn new(project: &'a Project) -> Self { - Resolver { - project, - hir: hir::Program::default(), - diagnostics: Vec::new(), - global_ids: HashMap::new(), - player_ids: HashMap::new(), - enum_ids: HashMap::new(), - function_ids: HashMap::new(), - subroutine_ids: HashMap::new(), - constant_ids: HashMap::new(), - pending_subroutines: Vec::new(), - pending_rules: Vec::new(), - explicit_global_ids: HashMap::new(), - explicit_player_ids: HashMap::new(), - current_event: "global".to_string(), - locals: Vec::new(), - } - } - - fn run(mut self) -> SemanticOutcome { - // File registry: the project registry (ds.toml at id 0 then sources), - // so every CST span keeps its FileId/provenance. - for file in &self.project.files { - self.hir - .files - .push(workshop_rs::source::SourceFile::new(file.path.clone())); - } - self.collect_declarations(); - self.resolve_bodies(); - let diagnostics = self.diagnostics; - SemanticOutcome { - hir: Some(self.hir), - diagnostics, - } - } - - // -- declaration collection -------------------------------------------- - - fn collect_declarations(&mut self) { - let files = self.project.files.clone(); - for file in &files { - let Some(cst) = &file.cst else { - continue; - }; - let items = cst.items.clone(); - for item in &items { - match item { - cst::Item::GlobalVar(decl) => self.collect_global(decl), - cst::Item::PlayerVar(decl) => self.collect_player(decl), - cst::Item::Enum(decl) => { - let enum_id = self.hir.enums.push(hir::EnumDecl { - name: decl.name.clone(), - span: Some(decl.span), - members: decl - .members - .iter() - .map(|member| hir::EnumMember { - name: member.clone(), - span: None, - }) - .collect(), - }); - self.enum_ids.insert(decl.name.clone(), enum_id); - } - cst::Item::Define(decl) => { - if decl.params.is_empty() { - let constant = hir::Constant { - name: decl.name.clone(), - span: Some(decl.span), - value: placeholder_expr(&mut self.hir, decl.span), - }; - self.constant_ids - .insert(decl.name.clone(), self.hir.constants.push(constant)); - } else { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "define function macros are outside the #118 semantic surface \ - (only the exercised constant defines are supported)", - decl.span, - )); - } - } - cst::Item::TypedDecl(decl) => { - self.collect_function( - decl.name.clone(), - Some(decl.type_name.clone()), - decl.params.clone().unwrap_or_default(), - FunctionBodySpec::Expression(decl.value.clone()), - decl.span, - None, - ); - } - cst::Item::Function(decl) => { - if decl.rule_name.is_some() { - // Rule-named void functions are Workshop subroutines. - self.pending_subroutines.push(decl.clone()); - } else { - self.collect_function( - decl.name.clone(), - decl.return_type.clone(), - decl.params.clone(), - FunctionBodySpec::Statements(decl.body.clone()), - decl.span, - Some(decl.name_span), - ); - } - } - cst::Item::Rule(decl) => self.pending_rules.push(decl.clone()), - cst::Item::Class(decl) => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!("class '{}' is outside the #118 semantic surface", decl.name), - decl.span, - )); - } - } - } - } - // Register the rule-named subroutines. - for decl in &self.pending_subroutines { - if self.subroutine_ids.contains_key(&decl.name) { - continue; - } - let subroutine = self.hir.subroutines.push(hir::Subroutine { - name: decl.name.clone(), - index: None, - decl_span: Some(decl.span), - decl_name_span: Some(decl.name_span), - body: Some(hir::SubroutineBody { - span: Some(decl.span), - name_span: None, - statements: Vec::new(), - }), - }); - self.subroutine_ids.insert(decl.name.clone(), subroutine); - } - } - - fn collect_global(&mut self, decl: &cst::VarDecl) { - let name = decl.name.clone(); - if self.global_ids.contains_key(&name) { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-name", - format!("global variable '{name}' is declared more than once"), - decl.span, - )); - return; - } - let index = decl.index.as_ref().and_then(|expr| match expr { - cst::Expr::Number { value, .. } => Some(*value as u32), - _ => None, - }); - if let Some(index) = index { - if let Some(other_span) = self.explicit_global_ids.get(&index) { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-variable-id", - format!("The id {index} is already reserved in the global collection."), - *other_span, - )); - } else { - self.explicit_global_ids.insert(index, decl.span); - } - } - let initializer = decl - .value - .as_ref() - .map(|_| placeholder_expr(&mut self.hir, decl.span)); - let id = self.hir.globals.push(hir::GlobalVar { - name: name.clone(), - index, - span: Some(decl.span), - name_span: Some(decl.name_span), - initializer, - }); - self.global_ids.insert(name, id); - } - - fn collect_player(&mut self, decl: &cst::VarDecl) { - let name = decl.name.clone(); - if self.player_ids.contains_key(&name) { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-name", - format!("player variable '{name}' is declared more than once"), - decl.span, - )); - return; - } - let index = decl.index.as_ref().and_then(|expr| match expr { - cst::Expr::Number { value, .. } => Some(*value as u32), - _ => None, - }); - if let Some(index) = index { - if let Some(other_span) = self.explicit_player_ids.get(&index) { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-variable-id", - format!("The id {index} is already reserved in the player collection."), - *other_span, - )); - } else { - self.explicit_player_ids.insert(index, decl.span); - } - } - let initializer = decl - .value - .as_ref() - .map(|_| placeholder_expr(&mut self.hir, decl.span)); - let id = self.hir.players.push(hir::PlayerVar { - name: name.clone(), - index, - span: Some(decl.span), - name_span: Some(decl.name_span), - initializer, - }); - self.player_ids.insert(name, id); - } - - fn collect_function( - &mut self, - name: String, - return_type: Option, - _params: Vec, - body: FunctionBodySpec, - span: Span, - name_span: Option, - ) { - if self.function_ids.contains_key(&name) { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-name", - format!("function '{name}' is declared more than once"), - span, - )); - return; - } - let placeholder = match body { - FunctionBodySpec::Expression(_) => { - hir::FunctionBody::Expression(placeholder_expr(&mut self.hir, span)) - } - FunctionBodySpec::Statements(_) => hir::FunctionBody::Statements(Vec::new()), - }; - let function = self.hir.functions.push(hir::Function { - name: name.clone(), - params: Vec::new(), // resolved in pass 2 - return_type: return_type.as_ref().map(cst_type_to_hir), - body: placeholder, - span: Some(span), - name_span, - }); - self.function_ids.insert(name, function); - } - - // -- pass 2: resolve bodies -------------------------------------------- - - fn resolve_bodies(&mut self) { - self.resolve_constant_values(); - self.assign_variable_indexes(); - self.resolve_initializers(); - self.resolve_functions(); - self.resolve_subroutine_bodies(); - self.resolve_rules(); - } - - fn resolve_constant_values(&mut self) { - let files = self.project.files.clone(); - for file in &files { - let Some(cst) = &file.cst else { - continue; - }; - let items = cst.items.clone(); - for item in &items { - if let cst::Item::Define(decl) = item { - if decl.params.is_empty() { - if let Some(id) = self.constant_ids.get(&decl.name).copied() { - let value = self.resolve_expr(&decl.value); - if let Some(constant) = self.hir.constants.get_mut(id) { - constant.value = value; - } - } - } - } - } - } - } - - fn assign_variable_indexes(&mut self) { - let globals_len = self.hir.globals.len(); - let mut used: Vec = self - .hir - .globals - .iter() - .filter_map(|global| global.index) - .collect(); - for id in 0..globals_len { - let id = GlobalVarId::from_index(id); - if self - .hir - .globals - .get(id) - .is_some_and(|global| global.index.is_none()) - { - let mut candidate = 0u32; - while used.contains(&candidate) { - candidate += 1; - } - if let Some(global) = self.hir.globals.get_mut(id) { - global.index = Some(candidate); - } - used.push(candidate); - } - } - let players_len = self.hir.players.len(); - let mut used: Vec = self - .hir - .players - .iter() - .filter_map(|player| player.index) - .collect(); - for id in 0..players_len { - let id = PlayerVarId::from_index(id); - if self - .hir - .players - .get(id) - .is_some_and(|player| player.index.is_none()) - { - let mut candidate = 0u32; - while used.contains(&candidate) { - candidate += 1; - } - if let Some(player) = self.hir.players.get_mut(id) { - player.index = Some(candidate); - } - used.push(candidate); - } - } - } - - fn resolve_initializers(&mut self) { - let files = self.project.files.clone(); - for file in &files { - let Some(cst) = &file.cst else { - continue; - }; - let items = cst.items.clone(); - for item in &items { - match item { - cst::Item::GlobalVar(decl) => { - if let Some(id) = self.global_ids.get(&decl.name).copied() { - if let Some(value) = &decl.value { - let expr = self.resolve_expr(value); - if let Some(global) = self.hir.globals.get_mut(id) { - global.initializer = Some(expr); - } - } - } - } - cst::Item::PlayerVar(decl) => { - if let Some(id) = self.player_ids.get(&decl.name).copied() { - if let Some(value) = &decl.value { - let expr = self.resolve_expr(value); - if let Some(player) = self.hir.players.get_mut(id) { - player.initializer = Some(expr); - } - } - } - } - _ => {} - } - } - } - } - - fn resolve_functions(&mut self) { - // Pass A: resolve every function's parameters (and defaults) first, - // so a call in one body sees the callee's arity/params. - let files = self.project.files.clone(); - let mut specs: Vec<(FunctionId, Vec, FunctionBodySpec)> = Vec::new(); - for file in &files { - let Some(cst) = &file.cst else { - continue; - }; - let items = cst.items.clone(); - for item in &items { - match item { - cst::Item::TypedDecl(decl) => { - if let Some(id) = self.function_ids.get(&decl.name).copied() { - let mut hir_params = Vec::new(); - for param in decl.params.clone().unwrap_or_default().iter() { - let default = - param.default.as_ref().map(|expr| self.resolve_expr(expr)); - hir_params.push(hir::Param { - type_name: param.type_name.as_ref().map(cst_type_to_hir), - name: param.name.clone(), - default, - span: Some(param.span), - }); - } - if let Some(function) = self.hir.functions.get_mut(id) { - function.params = hir_params; - } - specs.push(( - id, - decl.params.clone().unwrap_or_default(), - FunctionBodySpec::Expression(decl.value.clone()), - )); - } - } - cst::Item::Function(decl) => { - // Non-rule-named void functions are user functions. - let id = if decl.rule_name.is_none() { - self.function_ids.get(&decl.name).copied() - } else { - None - }; - if let Some(id) = id { - let mut hir_params = Vec::new(); - for param in &decl.params { - let default = - param.default.as_ref().map(|expr| self.resolve_expr(expr)); - hir_params.push(hir::Param { - type_name: param.type_name.as_ref().map(cst_type_to_hir), - name: param.name.clone(), - default, - span: Some(param.span), - }); - } - if let Some(function) = self.hir.functions.get_mut(id) { - function.params = hir_params; - } - specs.push(( - id, - decl.params.clone(), - FunctionBodySpec::Statements(decl.body.clone()), - )); - } - } - _ => {} - } - } - } - // Pass B: resolve bodies with the parameters in scope. - for (id, params, body) in specs { - self.locals.push(HashMap::new()); - for param in ¶ms { - self.locals - .last_mut() - .unwrap() - .insert(param.name.clone(), Local::Param); - } - match body { - FunctionBodySpec::Expression(expr) => { - let value = self.resolve_expr(&expr); - if let Some(function) = self.hir.functions.get_mut(id) { - function.body = hir::FunctionBody::Expression(value); - } - } - FunctionBodySpec::Statements(statements) => { - let resolved = self.resolve_statements(&statements); - if let Some(function) = self.hir.functions.get_mut(id) { - function.body = hir::FunctionBody::Statements(resolved); - } - } - } - self.locals.pop(); - } - } - - fn resolve_subroutine_bodies(&mut self) { - let subroutines = self.pending_subroutines.clone(); - for decl in subroutines { - if let Some(id) = self.subroutine_ids.get(&decl.name).copied() { - let statements = self.resolve_statements(&decl.body); - if let Some(subroutine) = self.hir.subroutines.get_mut(id) { - if let Some(body) = &mut subroutine.body { - body.statements = statements; - } - } - } - } - } - - fn resolve_rules(&mut self) { - let rules = self.pending_rules.clone(); - for decl in rules { - self.resolve_rule(&decl); - } - } - - fn resolve_rule(&mut self, decl: &cst::RuleDecl) { - let event_name = match &decl.event { - Some(cst::Expr::Member { receiver, name, .. }) => match receiver.as_ref() { - cst::Expr::Ident { - name: receiver_name, - .. - } if receiver_name == "Event" => match name.as_str() { - "OngoingPlayer" => "eachPlayer", - other => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported-event", - format!("event 'Event.{other}' is outside the #118 semantic surface"), - decl.span, - )); - "global" - } - }, - _ => "global", - }, - _ => "global", - }; - let previous_event = self.current_event.clone(); - self.current_event = event_name.to_string(); - - let priority = decl.priority.as_ref().and_then(|expr| match expr { - cst::Expr::Number { value, .. } => Some(*value as i32), - cst::Expr::Unary { - op: cst::UnaryOp::Negate, - operand, - .. - } => match operand.as_ref() { - cst::Expr::Number { value, .. } => Some(-(*value as i32)), - _ => None, - }, - _ => None, - }); - - let conditions = decl - .conditions - .iter() - .map(|condition| self.resolve_expr(condition)) - .collect(); - let actions = self.resolve_statements(&decl.body); - self.current_event = previous_event; - - self.hir.rules.push(hir::Rule { - name: decl.name.clone().unwrap_or_default(), - span: Some(decl.span), - name_span: decl.name_span, - disabled: decl.disabled, - event: hir::Event { - name: event_name.to_string(), - args: Vec::new(), - span: Some(decl.span), - }, - priority, - conditions, - actions, - }); - } - - // -- statements --------------------------------------------------------- - - fn resolve_statements(&mut self, statements: &[cst::Stmt]) -> Vec { - let mut out = Vec::new(); - for statement in statements { - self.resolve_stmt_into(statement, &mut out); - } - out - } - - fn resolve_stmt_into(&mut self, statement: &cst::Stmt, out: &mut Vec) { - match statement { - cst::Stmt::Expr { expr, span } => { - // A call to a rule-named subroutine is a subroutine call; - // check before expression resolution so it never resolves as - // a value. - if let cst::Expr::Call { callee, .. } = expr { - if let cst::Expr::Ident { name, .. } = callee.as_ref() { - if let Some(subroutine) = self.subroutine_ids.get(name) { - out.push(self.hir.stmts.push(hir::Stmt::CallSubroutine { - subroutine: *subroutine, - span: Some(*span), - callee_span: None, - })); - return; - } - } - } - let resolved = self.resolve_expr(expr); - out.push(self.hir.stmts.push(hir::Stmt::Expr { - expr: resolved, - span: Some(*span), - })); - } - cst::Stmt::Assign { - target, - op, - value, - span, - } => { - let target_expr = self.resolve_expr(target); - let value_expr = self.resolve_expr(value); - let value_expr = if *op == cst::AssignOp::Assign { - value_expr - } else { - let binary_op = match op { - cst::AssignOp::AddAssign => hir::BinaryOp::Add, - cst::AssignOp::SubtractAssign => hir::BinaryOp::Subtract, - cst::AssignOp::MultiplyAssign => hir::BinaryOp::Multiply, - cst::AssignOp::DivideAssign => hir::BinaryOp::Divide, - cst::AssignOp::ModuloAssign => hir::BinaryOp::Modulo, - cst::AssignOp::Assign => unreachable!(), - }; - self.hir.exprs.push(hir::Expr::Binary { - op: binary_op, - left: target_expr, - right: value_expr, - span: Some(*span), - }) - }; - out.push(self.hir.stmts.push(hir::Stmt::Assign { - target: target_expr, - value: value_expr, - span: Some(*span), - })); - } - cst::Stmt::If { - branches, - else_body, - span, - } => { - let mut hir_branches = Vec::new(); - for branch in branches { - let condition = self.resolve_expr(&branch.condition); - let body = self.resolve_statements(&branch.body); - hir_branches.push(hir::IfBranch { condition, body }); - } - let hir_else = else_body.as_ref().map(|body| self.resolve_statements(body)); - out.push(self.hir.stmts.push(hir::Stmt::If { - branches: hir_branches, - else_body: hir_else, - span: Some(*span), - })); - } - cst::Stmt::For { - init, - condition, - increment, - body, - span, - } => { - // C-style for: the emitted form is - // `For Global Variable(i, start, condition, step)` (P5b). - let (variable, start) = match init { - Some(cst::Expr::Assign { target, value, .. }) => match target.as_ref() { - cst::Expr::Ident { name, .. } => match self.global_ids.get(name).copied() { - Some(variable) => { - let start = self.resolve_expr(value); - (variable, start) - } - None => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "for-loop variable must be a global variable", - *span, - )); - return; - } - }, - _ => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "for-loop initializer must assign a global variable", - *span, - )); - return; - } - }, - _ => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "for-loop initializer must be `variable = value`", - *span, - )); - return; - } - }; - let condition = condition.as_ref().map(|expr| self.resolve_expr(expr)); - let step = increment.as_ref().map(|expr| self.resolve_expr(expr)); - let body = self.resolve_statements(body); - out.push(self.hir.stmts.push(hir::Stmt::CFor { - variable, - start: Some(start), - condition, - step, - body, - span: Some(*span), - })); - } - cst::Stmt::Foreach { - var_type: _, - var, - iterable, - body, - span, - } => { - let iterable_expr = self.resolve_expr(iterable); - // Allocate a global counter for the loop variable (the - // reference emits `For Global Variable(x, 0, Count Of(arr), 1)` - // with the element rewritten to `Value In Array(arr, x)`). - let counter = self.hir.globals.push(hir::GlobalVar { - name: var.clone(), - index: None, - span: Some(*span), - name_span: None, - initializer: None, - }); - self.global_ids.insert(var.clone(), counter); - let counter_ref = self.hir.exprs.push(hir::Expr::GlobalVar { - variable: counter, - span: Some(*span), - }); - let element = self.hir.exprs.push(hir::Expr::Index { - array: iterable_expr, - index: counter_ref, - span: Some(*span), - }); - self.locals.push(HashMap::new()); - self.locals - .last_mut() - .unwrap() - .insert(var.clone(), Local::ForeachElement(element)); - let body = self.resolve_statements(body); - self.locals.pop(); - out.push(self.hir.stmts.push(hir::Stmt::Foreach { - variable: counter, - iterable: iterable_expr, - body, - span: Some(*span), - })); - } - cst::Stmt::While { - condition, - body, - span, - } => { - let condition = self.resolve_expr(condition); - let body = self.resolve_statements(body); - out.push(self.hir.stmts.push(hir::Stmt::While { - condition, - body, - span: Some(*span), - })); - } - cst::Stmt::Switch { value, cases, span } => { - let value = self.resolve_expr(value); - let mut hir_cases = Vec::new(); - for case in cases { - let case_value = case.value.as_ref().map(|expr| self.resolve_expr(expr)); - let body = self.resolve_statements(&case.body); - hir_cases.push(hir::SwitchCase { - value: case_value, - body, - span: Some(case.span), - }); - } - out.push(self.hir.stmts.push(hir::Stmt::Switch { - value, - cases: hir_cases, - span: Some(*span), - })); - } - cst::Stmt::Return { value, span } => { - let value = value.as_ref().map(|expr| self.resolve_expr(expr)); - out.push(self.hir.stmts.push(hir::Stmt::Return { - value, - span: Some(*span), - })); - } - cst::Stmt::Break { span } => { - out.push(self.hir.stmts.push(hir::Stmt::Break { span: Some(*span) })); - } - cst::Stmt::Continue { span } => { - out.push( - self.hir - .stmts - .push(hir::Stmt::Continue { span: Some(*span) }), - ); - } - cst::Stmt::Block { body, span } => { - // Flatten a bare block into the enclosing statement list. - let _ = span; - for statement in body { - self.resolve_stmt_into(statement, out); - } - } - cst::Stmt::LocalDefine { span, .. } | cst::Stmt::LocalDecl { span, .. } => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "local define/typed declarations are outside the #118 semantic surface", - *span, - )); - } - } - } - - // -- expressions -------------------------------------------------------- - - fn resolve_expr(&mut self, expression: &cst::Expr) -> ExprId { - match expression { - cst::Expr::Number { value, text, span } => self.hir.exprs.push(hir::Expr::Number { - value: *value, - text: text.clone(), - span: Some(*span), - }), - cst::Expr::String { value, span } | cst::Expr::VerbatimString { value, span } => { - self.hir.exprs.push(hir::Expr::String { - value: value.clone(), - span: Some(*span), - }) - } - cst::Expr::Bool { value, span } => self.hir.exprs.push(hir::Expr::Bool { - value: *value, - span: Some(*span), - }), - cst::Expr::Null { span } => self.hir.exprs.push(hir::Expr::Null { span: Some(*span) }), - cst::Expr::Array { elements, span } => { - let elements = elements - .iter() - .map(|element| self.resolve_expr(element)) - .collect(); - self.hir.exprs.push(hir::Expr::Array { - elements, - span: Some(*span), - }) - } - cst::Expr::Ident { name, span } => self.resolve_ident(name, *span), - cst::Expr::Member { - receiver, - name, - span, - } => self.resolve_member(receiver, name, *span), - cst::Expr::Call { callee, args, span } => self.resolve_call(callee, args, *span), - cst::Expr::Index { array, index, span } => { - let array = self.resolve_expr(array); - let index = self.resolve_expr(index); - self.hir.exprs.push(hir::Expr::Index { - array, - index, - span: Some(*span), - }) - } - cst::Expr::FormatString { format, args, span } => { - let text = match format.as_ref() { - cst::Expr::String { value, .. } | cst::Expr::VerbatimString { value, .. } => { - value.clone() - } - _ => String::new(), - }; - let args = args.iter().map(|arg| self.resolve_expr(arg)).collect(); - self.hir.exprs.push(hir::Expr::Format { - text, - args, - span: Some(*span), - }) - } - cst::Expr::Cast { - type_name, - value, - span, - } => { - let value = self.resolve_expr(value); - self.hir.exprs.push(hir::Expr::Cast { - type_name: cst_type_to_hir(type_name), - value, - span: Some(*span), - }) - } - cst::Expr::Unary { op, operand, span } => { - let hir_op = match op { - cst::UnaryOp::Negate => hir::UnaryOp::Negate, - cst::UnaryOp::Not => hir::UnaryOp::Not, - }; - let operand = self.resolve_expr(operand); - self.hir.exprs.push(hir::Expr::Unary { - op: hir_op, - operand, - span: Some(*span), - }) - } - cst::Expr::Binary { - op, - left, - right, - span, - } => { - let hir_op = match op { - cst::BinaryOp::Add => hir::BinaryOp::Add, - cst::BinaryOp::Subtract => hir::BinaryOp::Subtract, - cst::BinaryOp::Multiply => hir::BinaryOp::Multiply, - cst::BinaryOp::Divide => hir::BinaryOp::Divide, - cst::BinaryOp::Modulo => hir::BinaryOp::Modulo, - cst::BinaryOp::Power => hir::BinaryOp::Power, - cst::BinaryOp::Equal => hir::BinaryOp::Equal, - cst::BinaryOp::NotEqual => hir::BinaryOp::NotEqual, - cst::BinaryOp::Less => hir::BinaryOp::Less, - cst::BinaryOp::LessEqual => hir::BinaryOp::LessEqual, - cst::BinaryOp::Greater => hir::BinaryOp::Greater, - cst::BinaryOp::GreaterEqual => hir::BinaryOp::GreaterEqual, - cst::BinaryOp::And => hir::BinaryOp::And, - cst::BinaryOp::Or => hir::BinaryOp::Or, - }; - let left = self.resolve_expr(left); - let right = self.resolve_expr(right); - self.hir.exprs.push(hir::Expr::Binary { - op: hir_op, - left, - right, - span: Some(*span), - }) - } - cst::Expr::Ternary { - condition, - then_value, - else_value, - span, - } => { - let condition = self.resolve_expr(condition); - let then_value = self.resolve_expr(then_value); - let else_value = self.resolve_expr(else_value); - self.hir.exprs.push(hir::Expr::Ternary { - condition, - then_value, - else_value, - span: Some(*span), - }) - } - cst::Expr::New { - type_name, - args, - span, - } => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!( - "class instantiation ('new {type_name}') is outside the #118 semantic surface" - ), - *span, - )); - let args = args.iter().map(|arg| self.resolve_call_arg(arg)).collect(); - self.hir.exprs.push(hir::Expr::Call { - name: format!("new {type_name}"), - args, - span: Some(*span), - }) - } - cst::Expr::Assign { span, .. } => { - // Assignment expressions only occur in for-loop headers, which - // resolve_stmt handles directly; reject elsewhere. - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - "assignment expressions outside for-loop headers are unsupported", - *span, - )); - placeholder_expr(&mut self.hir, *span) - } - cst::Expr::Postfix { op, operand, span } => { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!( - "postfix '{}' is outside the #118 semantic surface", - match op { - cst::PostfixOp::Increment => "++", - cst::PostfixOp::Decrement => "--", - } - ), - *span, - )); - self.resolve_expr(operand) - } - } - } - - fn resolve_ident(&mut self, name: &str, span: Span) -> ExprId { - for scope in self.locals.iter().rev() { - if let Some(local) = scope.get(name) { - return match local { - Local::ForeachElement(expr) => *expr, - Local::Param => self.hir.exprs.push(hir::Expr::Param { - name: name.to_string(), - span: Some(span), - }), - }; - } - } - if let Some(id) = self.global_ids.get(name) { - return self.hir.exprs.push(hir::Expr::GlobalVar { - variable: *id, - span: Some(span), - }); - } - if let Some(id) = self.constant_ids.get(name) { - return self.hir.exprs.push(hir::Expr::Constant { - constant: *id, - span: Some(span), - }); - } - if let Some(id) = self.function_ids.get(name) { - // A zero-parameter typed declaration used as a value. - return self.hir.exprs.push(hir::Expr::UserCall { - function: *id, - args: Vec::new(), - span: Some(span), - }); - } - if let Some(id) = self.player_ids.get(name) { - // A bare player variable in a player rule: `EventPlayer().name`. - let player = self - .hir - .exprs - .push(hir::Expr::EventPlayer { span: Some(span) }); - return self.hir.exprs.push(hir::Expr::PlayerVar { - player, - variable: *id, - span: Some(span), - }); - } - if matches!(name, "Math" | "Cursor" | "Diagnostics") { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!( - "'{name}' comes from outside the committed protect-ban closure \ - (#118 boundary); its members cannot be resolved" - ), - span, - )); - } else { - self.diagnostics.push(FrontendError::at( - "ostw-unknown-name", - format!("no variable or type by the name of '{name}' exists in the project"), - span, - )); - } - self.hir.exprs.push(hir::Expr::Call { - name: name.to_string(), - args: Vec::new(), - span: Some(span), - }) - } - - fn resolve_member(&mut self, receiver: &cst::Expr, name: &str, span: Span) -> ExprId { - // `Type.Member` on a known builtin enum domain. - if let cst::Expr::Ident { - name: receiver_name, - .. - } = receiver - { - if let Some(binding) = signature::enum_domain(receiver_name) { - // The OSTW binding maps the source member name to its canonical - // catalog member id; the catalog is the authority on existence. - // The HIR carries the canonical domain and member ids (#119), - // so emission resolves spellings purely through the catalog. - let canonical_member = binding - .members - .iter() - .find(|(source, _)| *source == name) - .map(|(_, canonical)| *canonical); - let known_member = canonical_member.is_some_and(|canonical| { - catalog().enum_domain(binding.domain).is_some_and(|domain| { - domain - .members - .iter() - .any(|member| member.member == canonical) - }) - }); - if !known_member { - self.diagnostics.push(FrontendError::at( - "ostw-unknown-enum-member", - format!("'{receiver_name}' has no member '{name}'"), - span, - )); - } - return self.hir.exprs.push(hir::Expr::Enum { - value_type: binding.domain.to_string(), - value: canonical_member.unwrap_or(name).to_string(), - span: Some(span), - }); - } - if let Some(enum_id) = self.enum_ids.get(receiver_name) { - let has_member = self - .hir - .enums - .get(*enum_id) - .map(|enum_| enum_.members.iter().any(|member| member.name == name)) - .unwrap_or(false); - if !has_member { - self.diagnostics.push(FrontendError::at( - "ostw-unknown-enum-member", - format!("'{receiver_name}' has no member '{name}'"), - span, - )); - } - return self.hir.exprs.push(hir::Expr::UserEnum { - enum_id: *enum_id, - member: name.to_string(), - span: Some(span), - }); - } - } - // Player-variable receiver access (`EventPlayer().p`, - // `LocalPlayer().cursor`, `AllPlayers().isReady`, `players[i].x`). - if let Some(variable) = self.player_ids.get(name).copied() { - let player = self.resolve_expr(receiver); - return self.hir.exprs.push(hir::Expr::PlayerVar { - player, - variable, - span: Some(span), - }); - } - // Unresolved module/member boundaries (Math, Cursor). - if let cst::Expr::Ident { - name: receiver_name, - .. - } = receiver - { - if matches!(receiver_name.as_str(), "Math" | "Cursor" | "Diagnostics") { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!( - "'{receiver_name}.{name}' comes from outside the committed protect-ban \ - closure (#118 boundary); its members cannot be resolved" - ), - span, - )); - return self.hir.exprs.push(hir::Expr::Call { - name: format!("{receiver_name}.{name}"), - args: Vec::new(), - span: Some(span), - }); - } - } - self.diagnostics.push(FrontendError::at( - "ostw-unknown-member", - format!("cannot resolve member '{name}' on this receiver"), - span, - )); - let receiver = self.resolve_expr(receiver); - self.hir.exprs.push(hir::Expr::ReceiverCall { - receiver, - name: name.to_string(), - args: Vec::new(), - span: Some(span), - }) - } - - fn resolve_call(&mut self, callee: &cst::Expr, args: &[cst::CallArg], span: Span) -> ExprId { - match callee { - cst::Expr::Ident { name, .. } => { - // The EventPlayer/LocalPlayer restricted-value check is - // call-site/inlining dependent (the pinned reference flags - // direct uses in global rules but not protect-ban's - // function-argument uses); it is deferred to #119 where the - // reference's exact behavior can be matched. - if let Some(function) = self.function_ids.get(name).copied() { - let ordered = self.bind_function_args(function, args, span); - return self.hir.exprs.push(hir::Expr::UserCall { - function, - args: ordered, - span: Some(span), - }); - } - if name == "Vector" && args.len() == 3 { - // Reuse the frontend-neutral Vector value node for the - // Workshop `Vector(x, y, z)` value. - let x = self.resolve_call_arg(&args[0]); - let y = self.resolve_call_arg(&args[1]); - let z = self.resolve_call_arg(&args[2]); - return self.hir.exprs.push(hir::Expr::Vector { - x, - y, - z, - span: Some(span), - }); - } - if let Some((kind, id)) = signature::builtin(name) { - // Canonical param order/spellings come from the catalog; - // the binding only supplies the OSTW source identity. - // The HIR call name is the canonical catalog id, so the - // shared HIR → WIR → emission pipeline resolves - // presentation spellings purely through the catalog - // (#119); only genuinely OSTW-specific source names stay - // in `signature.rs`. - let entry = catalog().entry(kind, id); - let params = entry.map(|entry| entry.params.clone()).unwrap_or_default(); - let defaults = entry - .map(|entry| entry.param_defaults.clone()) - .unwrap_or_default(); - let ordered = self.bind_builtin_args(name, ¶ms, &defaults, args, span); - return self.hir.exprs.push(hir::Expr::Call { - name: id.to_string(), - args: ordered, - span: Some(span), - }); - } - if self.subroutine_ids.contains_key(name) { - self.diagnostics.push(FrontendError::at( - "ostw-unknown-value", - format!("'{name}' is a subroutine and cannot be used as a value"), - span, - )); - } else { - self.diagnostics.push(FrontendError::at( - "ostw-unknown-value", - format!("no function or builtin by the name of '{name}' exists"), - span, - )); - } - let args = args.iter().map(|arg| self.resolve_call_arg(arg)).collect(); - self.hir.exprs.push(hir::Expr::Call { - name: name.clone(), - args, - span: Some(span), - }) - } - cst::Expr::Member { receiver, name, .. } => { - self.resolve_receiver_call(receiver, name, args, span) - } - other => { - let receiver = self.resolve_expr(other); - let args = args.iter().map(|arg| self.resolve_call_arg(arg)).collect(); - self.hir.exprs.push(hir::Expr::ReceiverCall { - receiver, - name: String::new(), - args, - span: Some(span), - }) - } - } - } - - fn resolve_receiver_call( - &mut self, - receiver: &cst::Expr, - name: &str, - args: &[cst::CallArg], - span: Span, - ) -> ExprId { - let receiver_name = match receiver { - cst::Expr::Ident { name, .. } => Some(name.as_str()), - _ => None, - }; - let is_boundary = match receiver_name { - Some(receiver_name) => { - matches!(receiver_name, "Math" | "Cursor" | "Diagnostics") - || self.player_ids.contains_key(receiver_name) - } - None => false, - }; - if is_boundary && !name.is_empty() { - self.diagnostics.push(FrontendError::at( - "ostw-unsupported", - format!( - "member call '{}.{}' is outside the #118 semantic surface \ - (missing-import/Cursor/Math boundary)", - receiver_name.unwrap_or("?"), - name - ), - span, - )); - let mut lowered = vec![self.resolve_expr(receiver)]; - lowered.extend(args.iter().map(|arg| self.resolve_call_arg(arg))); - return self.hir.exprs.push(hir::Expr::Call { - name: format!("{}.{}", receiver_name.unwrap_or("?"), name), - args: lowered, - span: Some(span), - }); - } - let mut lowered = vec![self.resolve_expr(receiver)]; - lowered.extend(args.iter().map(|arg| self.resolve_call_arg(arg))); - self.hir.exprs.push(hir::Expr::ReceiverCall { - receiver: lowered[0], - name: name.to_string(), - args: lowered[1..].to_vec(), - span: Some(span), - }) - } - - fn resolve_call_arg(&mut self, arg: &cst::CallArg) -> ExprId { - match arg { - cst::CallArg::Positional { value, .. } | cst::CallArg::Named { value, .. } => { - self.resolve_expr(value) - } - } - } - - /// Bind positional + named arguments against a builtin's canonical param - /// order (the catalog owns the canonical param names and Wright-owned - /// default values; probe evidence P6/P6b): named args reorder to the - /// canonical order and omitted gaps resolve the catalog default value - /// (`paramDefaults`), matching the reference's call-site default filling - /// (#119). Slots without a declared default keep the zero literal. - fn bind_builtin_args( - &mut self, - name: &str, - params: &[String], - defaults: &[Option], - args: &[cst::CallArg], - span: Span, - ) -> Vec { - let mut slots: Vec> = vec![None; params.len()]; - let mut positional_index = 0usize; - for arg in args { - match arg { - cst::CallArg::Positional { value, .. } => { - if positional_index < slots.len() { - slots[positional_index] = Some(self.resolve_expr(value)); - positional_index += 1; - } else { - let _ = self.resolve_expr(value); - self.diagnostics.push(FrontendError::at( - "ostw-arity", - format!("'{name}' takes at most {} arguments", slots.len()), - span, - )); - } - } - cst::CallArg::Named { - name: arg_name, - value, - .. - } => { - let Some(slot) = params.iter().position(|param| param.as_str() == arg_name) - else { - let _ = self.resolve_expr(value); - self.diagnostics.push(FrontendError::at( - "ostw-unknown-argument", - format!("'{name}' has no argument named '{arg_name}'"), - span, - )); - continue; - }; - if slots[slot].is_some() { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-argument", - format!("argument '{arg_name}' is supplied more than once"), - span, - )); - } - slots[slot] = Some(self.resolve_expr(value)); - } - } - } - slots - .into_iter() - .enumerate() - .map(|(index, slot)| { - slot.unwrap_or_else(|| match defaults.get(index).and_then(Option::as_deref) { - Some(default) => self.resolve_catalog_default(name, default, span), - None => zero_expr(&mut self.hir), - }) - }) - .collect() - } - - /// Resolve a catalog `paramDefaults` value into HIR: `null`, a numeric - /// literal, `Domain.MEMBER` (a builtin enum member through the catalog), - /// or a catalog value id resolved as a zero-argument call. - fn resolve_catalog_default(&mut self, call_name: &str, default: &str, span: Span) -> ExprId { - if default == "null" { - return self.hir.exprs.push(hir::Expr::Null { span: Some(span) }); - } - if let Ok(number) = default.parse::() { - return self.hir.exprs.push(hir::Expr::Number { - value: number, - text: default.to_string(), - span: Some(span), - }); - } - if let Some((domain, member)) = default.split_once('.') { - if catalog().enum_domain(domain).is_some() { - return self.hir.exprs.push(hir::Expr::Enum { - value_type: domain.to_string(), - value: member.to_string(), - span: Some(span), - }); - } - } - if let Some(entry) = catalog().entry(Kind::Value, default) { - // A value-id default resolves as a call with the entry's own - // defaults filled (e.g. `allPlayers` -> `allPlayers(Team.ALL)`, - // matching the reference's call-site filling). - let args = entry - .param_defaults - .iter() - .map(|slot| match slot { - Some(slot) => self.resolve_catalog_default(default, slot, span), - None => zero_expr(&mut self.hir), - }) - .collect(); - return self.hir.exprs.push(hir::Expr::Call { - name: default.to_string(), - args, - span: Some(span), - }); - } - self.diagnostics.push(FrontendError::at( - "ostw-default", - format!( - "catalog default '{default}' for '{call_name}' is not resolvable \ - (expected null, a number, Domain.MEMBER, or a catalog value id)" - ), - span, - )); - zero_expr(&mut self.hir) - } - - /// Bind arguments against a user function's parameters (positional then - /// named, then defaults), matching the reference (probe P6: user - /// functions fill defaults, e.g. `userCall(C: 9, A: 1)` -> B defaults). - fn bind_function_args( - &mut self, - function: FunctionId, - args: &[cst::CallArg], - span: Span, - ) -> Vec { - let param_names: Vec = self - .hir - .functions - .get(function) - .map(|f| f.params.iter().map(|p| p.name.clone()).collect()) - .unwrap_or_default(); - let mut slots: Vec> = vec![None; param_names.len()]; - let mut positional_index = 0usize; - for arg in args { - match arg { - cst::CallArg::Positional { value, .. } => { - if positional_index < slots.len() { - slots[positional_index] = Some(self.resolve_expr(value)); - positional_index += 1; - } else { - let _ = self.resolve_expr(value); - self.diagnostics.push(FrontendError::at( - "ostw-arity", - "too many arguments for function", - span, - )); - } - } - cst::CallArg::Named { - name: arg_name, - value, - .. - } => { - let Some(slot) = param_names.iter().position(|param| param == arg_name) else { - let _ = self.resolve_expr(value); - self.diagnostics.push(FrontendError::at( - "ostw-unknown-argument", - format!("function has no argument named '{arg_name}'"), - span, - )); - continue; - }; - if slots[slot].is_some() { - self.diagnostics.push(FrontendError::at( - "ostw-duplicate-argument", - format!("argument '{arg_name}' is supplied more than once"), - span, - )); - } - slots[slot] = Some(self.resolve_expr(value)); - } - } - } - let mut out = Vec::with_capacity(slots.len()); - for (index, slot) in slots.into_iter().enumerate() { - match slot { - Some(expr) => out.push(expr), - None => { - let default = self - .hir - .functions - .get(function) - .and_then(|f| f.params.get(index)) - .and_then(|param| param.default); - match default { - Some(default) => out.push(default), - None => { - self.diagnostics.push(FrontendError::at( - "ostw-missing-argument", - format!("missing argument for parameter '{}'", param_names[index]), - span, - )); - out.push(zero_expr(&mut self.hir)); - } - } - } - } - } - out - } -} - -/// The body of a user function being collected. -enum FunctionBodySpec { - Expression(cst::Expr), - Statements(Vec), -} - -/// A placeholder number expression used before a body resolves (pushed into -/// the expression arena). -fn placeholder_expr(program: &mut hir::Program, span: Span) -> ExprId { - program.exprs.push(hir::Expr::Number { - value: 0.0, - text: "0".to_string(), - span: Some(span), - }) -} - -/// The zero literal used to fill unspecified builtin/default argument gaps. -fn zero_expr(program: &mut hir::Program) -> ExprId { - program.exprs.push(hir::Expr::Number { - value: 0.0, - text: "0".to_string(), - span: None, - }) -} - -fn cst_type_to_hir(type_ref: &cst::TypeRef) -> hir::TypeName { - hir::TypeName { - name: type_ref.name.clone(), - array_depth: type_ref.array_depth, - unions: type_ref - .unions - .iter() - .map(|union| hir::TypeName { - name: union.name.clone(), - array_depth: union.array_depth, - unions: Vec::new(), - span: None, - }) - .collect(), - span: None, - } -} diff --git a/crates/wright-ostw/src/signature.rs b/crates/wright-ostw/src/signature.rs deleted file mode 100644 index 7747486..0000000 --- a/crates/wright-ostw/src/signature.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! OSTW source bindings to the canonical Workshop catalog (#118). -//! -//! This module owns ONLY genuinely OSTW-specific source binding/alias -//! metadata: the OSTW source name -> canonical catalog identity mapping for -//! the exercised builtin surface, and the OSTW source member name -> canonical -//! catalog member id mapping per exercised enum domain. All canonical -//! Workshop parameter/spelling and enum domain/member data lives in the -//! canonical catalog (`workshop-rs`); the -//! semantic phase -//! resolves builtins and enum domains through that catalog at the consume -//! sites. No OSTW `Elements.json` or upstream compiler table is copied; -//! every binding is exercised by the protect-ban reachable closure or a -//! committed pinned-reference probe under `compatibility/ostw/probes/`. - -use workshop_rs::catalog::Kind; - -/// One exercised builtin binding: OSTW source name -> (kind, canonical catalog id). -pub const BUILTIN_BINDINGS: &[(&str, (Kind, &str))] = &[ - ( - "WorkshopSettingInteger", - (Kind::Value, "workshopSettingInteger"), - ), - ( - "WorkshopSettingToggle", - (Kind::Value, "workshopSettingToggle"), - ), - ( - "WorkshopSettingCombo", - (Kind::Value, "workshopSettingCombo"), - ), - ("AllPlayers", (Kind::Value, "allPlayers")), - ("AllHeroes", (Kind::Value, "allHeroes")), - ("AllTankHeroes", (Kind::Value, "allTankHeroes")), - ("AllDamageHeroes", (Kind::Value, "allDamageHeroes")), - ("AllSupportHeroes", (Kind::Value, "allSupportHeroes")), - ("AllowedHeroes", (Kind::Value, "allowedHeroes")), - ("EventPlayer", (Kind::Value, "eventPlayer")), - ("LocalPlayer", (Kind::Value, "localPlayer")), - ("TeamOf", (Kind::Value, "teamOf")), - ("OppositeTeamOf", (Kind::Value, "oppositeTeamOf")), - ("NumberOfPlayers", (Kind::Value, "numberOfPlayers")), - ("ArrayContains", (Kind::Value, "arrayContains")), - ("ArrayElement", (Kind::Value, "currentArrayElement")), - ("CurrentArrayIndex", (Kind::Value, "currentArrayIndex")), - ("CountOf", (Kind::Value, "countOf")), - ("IndexOfArrayValue", (Kind::Value, "indexOfArrayValue")), - ("RandomValueInArray", (Kind::Value, "randomValueInArray")), - ("MappedArray", (Kind::Value, "mappedArray")), - ("FilteredArray", (Kind::Value, "filteredArray")), - ("SortedArray", (Kind::Value, "sortedArray")), - ("LastOf", (Kind::Value, "lastOf")), - ("EmptyArray", (Kind::Value, "emptyArray")), - ("RemoveFromArray", (Kind::Value, "removeFromArray")), - ("Append", (Kind::Value, "appendToArray")), - ("Max", (Kind::Value, "max")), - ("Min", (Kind::Value, "min")), - ("RoundToInteger", (Kind::Value, "roundToInteger")), - ("Vector", (Kind::Value, "vector")), - ("CrossProduct", (Kind::Value, "crossProduct")), - ("DirectionFromAngles", (Kind::Value, "directionFromAngles")), - ( - "HorizontalAngleFromDirection", - (Kind::Value, "horizontalAngleFromDirection"), - ), - ( - "VerticalAngleFromDirection", - (Kind::Value, "verticalAngleFromDirection"), - ), - ("Forward", (Kind::Value, "forward")), - ("CustomColor", (Kind::Value, "customColor")), - ("HasSpawned", (Kind::Value, "hasSpawned")), - ("IsButtonHeld", (Kind::Value, "isButtonHeld")), - ("IsInSpawnRoom", (Kind::Value, "isInSpawnRoom")), - ("IsTrueForAll", (Kind::Value, "isTrueForAll")), - ("IsWaitingForPlayers", (Kind::Value, "isWaitingForPlayers")), - ("CurrentMap", (Kind::Value, "currentMap")), - ("EvaluateOnce", (Kind::Value, "evaluateOnce")), - ("UpdateEveryFrame", (Kind::Value, "updateEveryFrame")), - ("LastCreatedEntity", (Kind::Value, "lastCreatedEntity")), - ("LastTextID", (Kind::Value, "lastTextId")), - ("HeroIconString", (Kind::Value, "heroIconString")), - ("AbilityIconString", (Kind::Value, "abilityIconString")), - ("IconString", (Kind::Value, "iconString")), - ("InputBindingString", (Kind::Value, "inputBindingString")), - ("BigMessage", (Kind::Action, "bigMessage")), - ("SmallMessage", (Kind::Action, "smallMessage")), - ("Wait", (Kind::Action, "wait")), - ("WaitUntil", (Kind::Action, "waitUntil")), - ("MinWait", (Kind::Action, "wait")), - ("Skip", (Kind::Action, "skip")), - ( - "LoopIfConditionIsTrue", - (Kind::Action, "loopIfConditionIsTrue"), - ), - ("AbortIf", (Kind::Action, "abortIf")), - ("ModifyVariable", (Kind::Action, "modifyGlobalVariable")), - ("CreateEffect", (Kind::Action, "createEffect")), - ("CreateInWorldText", (Kind::Action, "createInWorldText")), - ( - "CreateProgressBarInWorldText", - (Kind::Action, "createProgressBarInWorldText"), - ), - ("CreateHudText", (Kind::Action, "createHudText")), - ("PlayEffect", (Kind::Action, "playEffect")), - ("StartCamera", (Kind::Action, "startCamera")), - ("StopCamera", (Kind::Action, "stopCamera")), - ("StartGameMode", (Kind::Action, "startGameMode")), - ("SetInvisible", (Kind::Action, "setInvisibility")), - ("SetGravity", (Kind::Action, "setGravity")), - ("SetAllowedHeroes", (Kind::Action, "setAllowedHeroes")), - ("ForcePlayerHero", (Kind::Action, "forcePlayerHero")), - ("StopForcingHero", (Kind::Action, "stopForcingHero")), - ("ForceThrottle", (Kind::Action, "forceThrottle")), - ("StopForcingThrottle", (Kind::Action, "stopForcingThrottle")), - ("DisableGameModeHud", (Kind::Action, "disableGameModeHud")), - ( - "DisableGameModeInworldUI", - (Kind::Action, "disableGameModeInworldUI"), - ), - ("DisableHeroHud", (Kind::Action, "disableHeroHud")), - ("DisableScoreboard", (Kind::Action, "disableScoreboard")), - ( - "DisableInspectorRecording", - (Kind::Action, "disableInspector"), - ), - ("EnableGameModeHud", (Kind::Action, "enableGameModeHud")), - ( - "EnableGameModeInworldUI", - (Kind::Action, "enableGameModeInworldUI"), - ), - ("EnableHeroHud", (Kind::Action, "enableHeroHud")), - ("EnableScoreboard", (Kind::Action, "enableScoreboard")), - ( - "EnableInspectorRecording", - (Kind::Action, "enableInspectorRecording"), - ), - ( - "DisableMovementCollisionWithEnvironment", - (Kind::Action, "disableMovementCollisionWithEnvironment"), - ), - ( - "DisableMovementCollisionWithPlayers", - (Kind::Action, "disableMovementCollisionWithPlayers"), - ), - ( - "EnableMovementCollisionWithEnvironment", - (Kind::Action, "enableMovementCollisionWithEnvironment"), - ), - ( - "EnableMovementCollisionWithPlayers", - (Kind::Action, "enableMovementCollisionWithPlayers"), - ), - ("DisallowButton", (Kind::Action, "disallowButton")), - ("AllowButton", (Kind::Action, "allowButton")), - ("DestroyHudText", (Kind::Action, "destroyHudText")), - ("DestroyInWorldText", (Kind::Action, "destroyInWorldText")), - ("DestroyEffect", (Kind::Action, "destroyEffect")), - ( - "DestroyAllProgressBarHudText", - (Kind::Action, "destroyAllProgressBarHudText"), - ), - ( - "DestroyAllProgressBarInWorldText", - (Kind::Action, "destroyAllProgressBarInWorldText"), - ), - ("StopChasingVariable", (Kind::Action, "stopChasingVariable")), - // The generic OSTW chase spelling is lowered to the canonical chase - // family; workshop-rs emits the global/player spelling from the variable - // value shape. - ("ChaseVariableAtRate", (Kind::Action, "chaseAtRate")), - ("Teleport", (Kind::Action, "teleport")), -]; - -/// Resolve an exercised Workshop builtin by its OSTW source name. -pub fn builtin(name: &str) -> Option<(Kind, &'static str)> { - BUILTIN_BINDINGS - .iter() - .find(|(source, _)| *source == name) - .map(|(_, binding)| *binding) -} - -/// One exercised enum domain binding: the canonical catalog domain plus the -/// OSTW source member name -> canonical catalog member id mapping. -pub struct EnumDomainBinding { - /// The canonical catalog domain name. - pub domain: &'static str, - /// OSTW source member name -> canonical catalog member id. - pub members: &'static [(&'static str, &'static str)], -} - -pub const ENUM_DOMAIN_BINDINGS: &[(&str, EnumDomainBinding)] = &[ - ( - "Team", - EnumDomainBinding { - domain: "Team", - members: &[("All", "ALL"), ("Team1", "TEAM_1"), ("Team2", "TEAM_2")], - }, - ), - ( - "Button", - EnumDomainBinding { - domain: "Button", - members: &[ - ("PrimaryFire", "PRIMARY_FIRE"), - ("SecondaryFire", "SECONDARY_FIRE"), - ("Ability1", "ABILITY_1"), - ("Ability2", "ABILITY_2"), - ("Ultimate", "ULTIMATE"), - ("Crouch", "CROUCH"), - ("Interact", "INTERACT"), - ("Jump", "JUMP"), - ("Melee", "MELEE"), - ("Reload", "RELOAD"), - ], - }, - ), - ( - "Clipping", - EnumDomainBinding { - domain: "Clipping", - members: &[ - ("DoNotClip", "DO_NOT_CLIP"), - ("ClipAgainstSurfaces", "CLIP_AGAINST_SURFACES"), - ], - }, - ), - ( - "Color", - EnumDomainBinding { - domain: "Color", - members: &[ - ("White", "WHITE"), - ("Yellow", "YELLOW"), - ("Green", "GREEN"), - ("Purple", "PURPLE"), - ("Red", "RED"), - ("Blue", "BLUE"), - ("Aqua", "AQUA"), - ("Orange", "ORANGE"), - ("SkyBlue", "SKY_BLUE"), - ("Turquoise", "TURQUOISE"), - ("LimeGreen", "LIME_GREEN"), - ("Gray", "GRAY"), - ("Violet", "VIOLET"), - ("Rose", "ROSE"), - ("Black", "BLACK"), - ("Team1", "TEAM_1"), - ("Team2", "TEAM_2"), - ], - }, - ), - ( - "Effect", - EnumDomainBinding { - domain: "Effect", - members: &[("Orb", "ORB")], - }, - ), - ( - "EffectRev", - EnumDomainBinding { - domain: "EffectReeval", - members: &[( - "VisibleToPositionAndRadius", - "VISIBLE_TO_POSITION_AND_RADIUS", - )], - }, - ), - ( - "Hero", - EnumDomainBinding { - domain: "Hero", - members: &[ - ("Dva", "DVA"), - ("Orisa", "ORISA"), - ("Reinhardt", "REINHARDT"), - ("Roadhog", "ROADHOG"), - ("Sigma", "SIGMA"), - ("WreckingBall", "WRECKING_BALL"), - ("Winston", "WINSTON"), - ("Zarya", "ZARYA"), - ("Ashe", "ASHE"), - ("Bastion", "BASTION"), - ("Cassidy", "CASSIDY"), - ("Doomfist", "DOOMFIST"), - ("Echo", "ECHO"), - ("Genji", "GENJI"), - ("Hanzo", "HANZO"), - ("Junkrat", "JUNKRAT"), - ("Mei", "MEI"), - ("Pharah", "PHARAH"), - ("Reaper", "REAPER"), - ("Soldier76", "SOLDIER_76"), - ("Symmetra", "SYMMETRA"), - ("Sombra", "SOMBRA"), - ("Tracer", "TRACER"), - ("Torbjorn", "TORBJORN"), - ("Widowmaker", "WIDOWMAKER"), - ("Ana", "ANA"), - ("Brigitte", "BRIGITTE"), - ("Baptiste", "BAPTISTE"), - ("Lucio", "LUCIO"), - ("Moira", "MOIRA"), - ("Mercy", "MERCY"), - ("Zenyatta", "ZENYATTA"), - ], - }, - ), - ( - "HudTextRev", - EnumDomainBinding { - domain: "HudReeval", - members: &[ - ("VisibleTo", "VISIBILITY"), - ("VisibleToAndString", "VISIBILITY_AND_STRING"), - ("VisibleToStringAndColor", "VISIBLE_TO_STRING_AND_COLOR"), - ("VisibleToAndColor", "VISIBLE_TO_AND_COLOR"), - ], - }, - ), - ( - "Icon", - EnumDomainBinding { - domain: "Icon", - members: &[ - ("No", "NO"), - ("QuestionMark", "QUESTION_MARK"), - ("Skull", "SKULL"), - ("Checkmark", "CHECKMARK"), - ("RingThin", "RING_THIN"), - ], - }, - ), - ( - "InvisibleTo", - EnumDomainBinding { - domain: "Invis", - members: &[("All", "ALL"), ("None", "NONE")], - }, - ), - ( - "Map", - EnumDomainBinding { - domain: "Map", - members: &[ - ("Hanamura", "HANAMURA"), - ("Hanamura_Winter", "HANAMURA_WINTER"), - ("Horizon_Lunar_Colony", "HORIZON_LUNAR_COLONY"), - ("Paris", "PARIS"), - ("Temple_of_Anubis", "TEMPLE_OF_ANUBIS"), - ("Volskaya_Industries", "VOLSKAYA_INDUSTRIES"), - ("Hanaoka", "HANAOKA"), - ("Throne_of_Anubis", "THRONE_OF_ANUBIS"), - ("Antarctic_Peninsula", "ANTARCTIC_PENINSULA"), - ("Busan", "BUSAN"), - ("Ilios", "ILIOS"), - ("Lijiang_Tower", "LIJIANG_TOWER"), - ("Lijiang_Tower_Lunar", "LIJIANG_TOWER_LUNAR"), - ("Nepal", "NEPAL"), - ("Oasis", "OASIS"), - ("Samoa", "SAMOA"), - ("Circuit_Royal", "CIRCUIT_ROYAL"), - ("Dorado", "DORADO"), - ("Havana", "HAVANA"), - ("Junkertown", "JUNKERTOWN"), - ("Rialto", "RIALTO"), - ("Route_66", "ROUTE_66"), - ("Shambali_Monastery", "SHAMBALI_MONASTERY"), - ("Watchpoint_Gibraltar", "WATCHPOINT_GIBRALTAR"), - ("Aatlis", "AATLIS"), - ("New_Junk_City", "NEW_JUNK_CITY"), - ("Suravasa", "SURAVASA"), - ("Blizzard_World", "BLIZZARD_WORLD"), - ("Blizzard_World_Winter", "BLIZZARD_WORLD_WINTER"), - ("Eichenwalde", "EICHENWALDE"), - ("Eichenwalde_Halloween", "EICHENWALDE_HALLOWEEN"), - ("Hollywood", "HOLLYWOOD"), - ("Hollywood_Halloween", "HOLLYWOOD_HALLOWEEN"), - ("Kings_Row", "KINGS_ROW"), - ("Kings_Row_Winter", "KINGS_ROW_WINTER"), - ("Midtown", "MIDTOWN"), - ("Numbani", "NUMBANI"), - ("Paraiso", "PARAISO"), - ("Colosseo", "COLOSSEO"), - ("Esperanca", "ESPERANCA"), - ("New_Queen_Street", "NEW_QUEEN_STREET"), - ("Runasapi", "RUNASAPI"), - ], - }, - ), - ( - "InworldTextRev", - EnumDomainBinding { - domain: "InworldTextReeval", - members: &[ - ("VisibleTo", "VISIBLE_TO"), - ("VisibleToAndColor", "VISIBLE_TO_AND_COLOR"), - ("VisibleToAndPosition", "VISIBLE_TO_AND_POSITION"), - ("VisibleToAndString", "VISIBLE_TO_AND_STRING"), - ("VisibleToPositionAndColor", "VISIBLE_TO_POSITION_AND_COLOR"), - ( - "VisibleToPositionAndString", - "VISIBLE_TO_POSITION_AND_STRING", - ), - ( - "VisibleToPositionStringAndColor", - "VISIBLE_TO_POSITION_STRING_AND_COLOR", - ), - ("VisibleToStringAndColor", "VISIBLE_TO_STRING_AND_COLOR"), - ("String", "STRING"), - ], - }, - ), - ( - "WorldTextRev", - EnumDomainBinding { - domain: "WorldTextReeval", - members: &[ - ("Color", "COLOR"), - ("None", "NONE"), - ("String", "STRING"), - ("StringAndColor", "STRING_AND_COLOR"), - ("VisibleTo", "VISIBILITY"), - ("VisibleToAndColor", "VISIBILITY_AND_COLOR"), - ("VisibleToAndPosition", "VISIBILITY_AND_POSITION"), - ("VisibleToAndString", "VISIBILITY_AND_STRING"), - ("VisibleToPositionAndColor", "VISIBILITY_POSITION_AND_COLOR"), - ( - "VisibleToPositionAndString", - "VISIBILITY_POSITION_AND_STRING", - ), - ( - "VisibleToPositionStringAndColor", - "VISIBILITY_POSITION_STRING_AND_COLOR", - ), - ("VisibleToStringAndColor", "VISIBILITY_STRING_AND_COLOR"), - ], - }, - ), - ( - "Location", - EnumDomainBinding { - domain: "HudPosition", - members: &[("Left", "LEFT"), ("Right", "RIGHT")], - }, - ), - ( - "Operation", - EnumDomainBinding { - domain: "Operation", - members: &[ - ("AppendToArray", "APPEND_TO_ARRAY"), - ("RemoveFromArrayByValue", "REMOVE_FROM_ARRAY_BY_VALUE"), - ("RemoveFromArrayByIndex", "REMOVE_FROM_ARRAY_BY_INDEX"), - ], - }, - ), - ( - "PlayEffect", - EnumDomainBinding { - domain: "DynamicEffect", - members: &[ - ("BuffImpactSound", "BUFF_IMPACT_SOUND"), - ("DebuffImpactSound", "DEBUFF_IMPACT_SOUND"), - ("BuffExplosionSound", "BUFF_EXPLOSION_SOUND"), - ("ExplosionSound", "EXPLOSION_SOUND"), - ("RingExplosionSound", "RING_EXPLOSION"), - ], - }, - ), - ( - "ProgressBarWorldEvaluation", - EnumDomainBinding { - domain: "ProgressBarWorldReeval", - members: &[("VisibleToAndValues", "VISIBLE_TO_AND_VALUES")], - }, - ), - ( - "RateChaseReevaluation", - EnumDomainBinding { - domain: "ChaseRateReeval", - members: &[ - ("None", "NONE"), - ("DestinationAndRate", "DESTINATION_AND_RATE"), - ], - }, - ), - ( - "Rounding", - EnumDomainBinding { - domain: "Rounding", - members: &[("Up", "UP"), ("Down", "DOWN"), ("Nearest", "NEAREST")], - }, - ), - ( - "Spectators", - EnumDomainBinding { - domain: "SpecVisibility", - members: &[ - ("DefaultVisibility", "DEFAULT"), - ("VisibleAlways", "VISIBLE_ALWAYS"), - ("VisibleNever", "VISIBLE_NEVER"), - ], - }, - ), - ( - "WaitBehavior", - EnumDomainBinding { - domain: "Wait", - members: &[ - ("AbortWhenFalse", "ABORT_WHEN_FALSE"), - ("IgnoreCondition", "IGNORE_CONDITION"), - ], - }, - ), - ( - "Vector", - EnumDomainBinding { - domain: "Vector", - members: &[("Forward", "FORWARD"), ("Backward", "BACKWARD")], - }, - ), -]; - -/// Resolve an exercised builtin enum domain by its OSTW source name. -pub fn enum_domain(name: &str) -> Option<&'static EnumDomainBinding> { - ENUM_DOMAIN_BINDINGS - .iter() - .find(|(source, _)| *source == name) - .map(|(_, binding)| binding) -} diff --git a/crates/wright-ostw/tests/differential.rs b/crates/wright-ostw/tests/differential.rs deleted file mode 100644 index 15352dc..0000000 --- a/crates/wright-ostw/tests/differential.rs +++ /dev/null @@ -1,1291 +0,0 @@ -//! OSTW forward-compilation differential suite (#119). -//! -//! Compiles the #122 explicit-root accepted differential targets -//! (`p4-types-expressions`, `p5-functions-control`, `p6-catalog-signatures`) -//! through the shared HIR → WIR → Workshop pipeline and validates semantic -//! equivalence against the pinned OSTW v3.4.0 reference evidence -//! (`compatibility/ostw/probes/*/workshop.entry-only.txt`), not output-text -//! identity. -//! -//! Both sides are parsed through the shared Workshop parser and normalized -//! with the declared #119 normalization (applied identically to both sides): -//! -//! * constant folding (`wright-transform` FoldConstants, incl. the -//! reference's `x || true` / `x && false` domination folds); -//! * write-once per-call player variables (the reference materializes every -//! void-function argument into a fresh player variable; the declared -//! contract inlines those single-writer variables); -//! * `For Player Variable(Event Player, v, …)` → `For Global Variable(v, …)` -//! with loop-body reads rewritten (declared foreach divergence: Wright -//! models foreach counters as globals; Workshop rule execution is atomic, -//! so the loop semantics coincide); -//! * the reference's null/unit vector output idioms -//! (`Vector(0,0,0)` ≡ `Subtract(Left, Left)`, `Vector(1,0,0)` ≡ `Left`); -//! * `Custom String` placeholder syntax (`<0>` ≡ `{0}`). -//! -//! Variable-table identity (names, slots, player-vs-global placement of -//! foreach counters) is explicitly outside the declared semantic comparison -//! (non-goals: optimizer parity, identical variable allocation names, -//! formatting parity). The same normalization is applied to both sides, so a -//! genuine lowering divergence (wrong ternary order, dropped calls, wrong -//! argument binding) fails the comparison. Every target must also reach the -//! declared round-trip fixed point: Wright-emitted Workshop reparses and -//! re-emits byte-identically. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use sha2::Digest as _; -use workshop_rs::wir::{self, Action, Event, Value}; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -/// The #122 explicit-root accepted differential targets (entry-only). -const TARGETS: &[&str] = &[ - "p4-types-expressions", - "p5-functions-control", - "p6-catalog-signatures", -]; - -fn read(path: &Path) -> String { - std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())) -} - -fn compile_semantic(root: &Path, main_rel: &str) -> wright_ostw::SemanticOutcome { - let main = root.join(main_rel); - let text = read(&main); - let (outcome, semantic) = wright_ostw::compile_with_semantics(&text, Some(main_rel), root); - assert!( - outcome.error.is_none(), - "the target project must load: {:?}", - outcome.error - ); - semantic -} - -/// Parse Workshop text through the shared parser with the canonical -/// signature context (the same path the driver uses). -fn parse(catalog: &workshop_rs::catalog::Catalog, text: &str) -> wir::Program { - let manifest = - wright_opy::manifest::Manifest::builtin().expect("the OPY manifest is embedded and valid"); - let context = wright_core::signatures::ChainedExpectedDomain::new(manifest, catalog); - let program = workshop_rs::parser::parse_with_context( - text, - catalog, - &workshop_rs::catalog::Locale::new("en-US"), - &context, - ) - .unwrap_or_else(|error| panic!("reference/Wright text must parse: {error}")); - program - .validate() - .expect("parsed programs validate structurally"); - program -} - -fn fold(program: &mut wir::Program) { - use wright_transform::pipeline::Pass as _; - wright_transform::fold_constants::FoldConstants.run(program); -} - -/// Apply the emitter's ambiguous-member qualification to a text before -/// parsing: a bare enum spelling shared by several domains (e.g. `Team 2` -/// is both a Team and a Team color) is rewritten to the domain constructor -/// form so the parse is deterministic — the same rule the emitter applies -/// to Wright-emitted text (the declared #119 round-trip contract). Only the -/// Team/Color collision needs it: the other shared spellings (e.g. -/// `Visible To And String`) resolve through the catalog's expected-domain -/// pins at their call positions. -fn qualify_ambiguous_members(catalog: &workshop_rs::catalog::Catalog, text: &str) -> String { - let mut out = text.to_string(); - // workshop-rs 0.1.5 exposes Vector as a catalog enum domain. The pinned - // reference's zero-vector spelling is otherwise ambiguous with - // HudPosition.Left/Right when it appears outside an expected-argument - // context. - for direction in ["Left", "Right", "Up", "Down", "Forward", "Backward"] { - let pattern = format!("Subtract({direction}, {direction})"); - let replacement = if direction == "Left" { - "Vector(0, 0, 0)".to_string() - } else { - format!("Subtract({direction}, {direction})") - }; - out = out.replace(&pattern, &replacement); - } - out = out.replace( - "Start Camera(Event Player, Vector(0, 0, 0), Left, 0)", - "Start Camera(Event Player, Vector(0, 0, 0), Vector.Left, 0)", - ); - let locale = workshop_rs::catalog::Locale::new("en-US"); - for domain in catalog.enum_domains() { - if domain.domain != "Team" { - continue; - } - let candidates: Vec<(String, String)> = domain - .members - .iter() - .filter_map(|member| { - let spelling = member.spelling(&locale)?.to_string(); - if catalog.bare_member_matches(&locale, &spelling).len() > 1 { - Some((spelling, member.member.clone())) - } else { - None - } - }) - .collect(); - for (spelling, _) in candidates { - let mut replaced = String::with_capacity(out.len()); - let mut rest = out.as_str(); - while let Some(index) = rest.find(&spelling) { - replaced.push_str(&rest[..index]); - let before = rest[..index].chars().rev().find(|c| !c.is_whitespace()); - if before == Some('(') { - // Already inside a constructor form (`Team(Team 2)`). - replaced.push_str(&spelling); - } else { - replaced.push_str(&format!("{}({spelling})", domain.domain)); - } - rest = &rest[index + spelling.len()..]; - } - replaced.push_str(rest); - out = replaced; - } - } - out -} - -/// Inline write-once per-call player variables: a `Set Player Variable(Event -/// Player, v, value)` whose variable is never written again and whose reads -/// all follow the write is replaced by the value and the Set is dropped -/// (the reference materializes void-function arguments this way; the -/// declared #119 contract inlines them). Applied identically to both sides. -fn inline_write_once_player_vars(program: &mut wir::Program) { - for rule_index in 0..program.rules.len() { - let rule_id = wright_ir::ids::Id::from_index(rule_index); - let actions: Vec = program - .rules - .get(rule_id) - .map(|rule| rule.actions.clone()) - .unwrap_or_default(); - // Find the single-writer Set actions for Event Player variables. - let mut writers: HashMap = HashMap::new(); - for (index, action) in actions.iter().enumerate() { - let Some(wir::Action::SetPlayerVariable { - player, - variable, - value, - .. - }) = program.actions.get(*action) - else { - continue; - }; - let is_event_player = matches!( - program.values.get(*player).map(|node| &node.value), - Some(wir::Value::EventPlayer) - ); - if !is_event_player { - continue; - } - let var_index = variable.index() as u32; - writers - .entry(var_index) - .and_modify(|slot| *slot = (usize::MAX, *value)) // written again - .or_insert((index, *value)); - } - // Remove the single-writer Sets and substitute reads that follow - // the write (replacing the value nodes in the arena). - let mut removed: Vec = Vec::new(); - let mut substitutions: HashMap = HashMap::new(); - for action in &actions { - let Some(Action::SetPlayerVariable { variable, .. }) = program.actions.get(*action) - else { - continue; - }; - if let Some((index, value)) = writers.get(&(variable.index() as u32)).copied() { - if index != usize::MAX { - removed.push(*action); - substitutions.insert(variable.index() as u32, value); - } - } - } - if substitutions.is_empty() { - continue; - } - // Substitute reads: any Player Variable(Event Player, v) value node - // is replaced by the written value (cloned into the arena). - let values_len = program.values.len(); - for index in 0..values_len { - let id = wright_ir::ids::Id::from_index(index); - let node = program - .values - .get(id) - .cloned() - .unwrap_or_else(|| workshop_rs::wir::ValueNode::new(wir::Value::Null, None)); - let replacement = match &node.value { - Value::PlayerVariable { player, variable } - if matches!( - program.values.get(*player).map(|n| &n.value), - Some(Value::EventPlayer) - ) => - { - substitutions.get(&(variable.index() as u32)).copied() - } - _ => None, - }; - let Some(replacement) = replacement else { - continue; - }; - let replacement = program - .values - .get(replacement) - .cloned() - .expect("replacement in range"); - program.values.get_mut(id).expect("id in range").value = replacement.value; - } - let rule_id = wright_ir::ids::Id::from_index(rule_index); - if let Some(rule) = program.rules.get_mut(rule_id) { - rule.actions.retain(|action| !removed.contains(action)); - } - } -} - -/// The declared foreach divergence: `For Player Variable(Event Player, v, …)` -/// loops and their loop-body reads of `v` normalize to the global form -/// (Wright models foreach counters as globals; Workshop rule execution is -/// atomic, so the loop semantics coincide). Applied to both sides. -fn foreach_globalize(program: &mut wir::Program) { - // Map each player variable rewritten to its replacement global. - let mut rewrites: HashMap = HashMap::new(); - fn global_for( - program: &mut wir::Program, - name: &str, - rewrites: &mut HashMap, - player_index: u32, - ) -> wir::GlobalVarId { - if let Some(global) = rewrites.get(&player_index) { - return *global; - } - let index = program.global_variables.len() as u32; - let id = program.global_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index, - span: None, - name_span: None, - }); - rewrites.insert(player_index, id); - id - } - fn rewrite_value( - program: &mut wir::Program, - id: wir::ValueId, - rewrites: &HashMap, - ) { - let node = program - .values - .get(id) - .cloned() - .unwrap_or_else(|| workshop_rs::wir::ValueNode::new(wir::Value::Null, None)); - let children: Vec = match &node.value { - Value::Array(elements) => elements.clone(), - Value::Vector { x, y, z } => vec![*x, *y, *z], - Value::PlayerVariable { player, .. } => vec![*player], - Value::Call { args, .. } => args.clone(), - _ => Vec::new(), - }; - for child in children { - rewrite_value(program, child, rewrites); - } - if let Value::PlayerVariable { player, variable } = &node.value { - if matches!( - program.values.get(*player).map(|n| &n.value), - Some(Value::EventPlayer) - ) { - if let Some(global) = rewrites.get(&(variable.index() as u32)) { - program.values.get_mut(id).expect("id in range").value = - Value::GlobalVariable(*global); - } - } - } - } - fn rewrite_actions( - program: &mut wir::Program, - actions: &[wir::ActionId], - rewrites: &HashMap, - ) { - for action in actions { - let Some(node) = program.actions.get(*action).cloned() else { - continue; - }; - let children: Vec = match &node { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } - | Action::Debug { value, .. } - | Action::Print { message: value, .. } => vec![*value], - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => vec![*player, *value], - Action::AssignMember { target, value, .. } => vec![*target, *value], - Action::CallSubroutine { .. } => Vec::new(), - Action::If { - branches, - else_body, - .. - } => { - let mut out = Vec::new(); - for branch in branches { - out.push(branch.condition); - } - if let Some(else_body) = else_body { - for action in else_body { - rewrite_actions(program, &[*action], rewrites); - } - } - for branch in branches { - rewrite_actions(program, &branch.body, rewrites); - } - out - } - Action::While { - condition, body, .. - } => { - rewrite_actions(program, body, rewrites); - vec![*condition] - } - Action::ForGlobalVariable { - start, - stop, - step, - body, - .. - } - | Action::ForPlayerVariable { - start, - stop, - step, - body, - .. - } => { - rewrite_actions(program, body, rewrites); - vec![*start, *stop, *step] - } - Action::Call { args, .. } => args.clone(), - }; - for child in children { - rewrite_value(program, child, rewrites); - } - } - } - for rule_index in 0..program.rules.len() { - let rule_id = wright_ir::ids::Id::from_index(rule_index); - let player_loops: Vec<(wir::ActionId, u32, String)> = program - .rules - .get(rule_id) - .map(|rule| rule.actions.clone()) - .unwrap_or_default() - .iter() - .filter_map(|action| { - let Some(Action::ForPlayerVariable { variable, .. }) = program.actions.get(*action) - else { - return None; - }; - let name = program - .player_variables - .get(*variable) - .map(|v| v.name.clone()) - .unwrap_or_default(); - Some((*action, variable.index() as u32, name)) - }) - .collect(); - let mut local_rewrites = rewrites.clone(); - for (action, player_index, name) in player_loops { - let global = global_for(program, &name, &mut local_rewrites, player_index); - let loop_body = { - let Some(Action::ForPlayerVariable { body, .. }) = program.actions.get(action) - else { - continue; - }; - body.clone() - }; - rewrite_actions(program, &loop_body, &local_rewrites); - let (start, stop, step) = { - let Some(Action::ForPlayerVariable { - start, stop, step, .. - }) = program.actions.get(action) - else { - continue; - }; - (*start, *stop, *step) - }; - let span = program.actions.get(action).and_then(|action| action.span()); - *program.actions.get_mut(action).expect("action in range") = - Action::ForGlobalVariable { - variable: global, - start, - stop, - step, - body: loop_body, - span, - target_span: None, - }; - } - for (player_index, global) in local_rewrites { - rewrites.entry(player_index).or_insert(global); - } - } -} - -/// `Custom String` placeholder syntax is an output-form difference -/// (`<0>` ≡ `{0}`); normalize the text of format strings on both sides. -fn fold_placeholders(program: &mut wir::Program) { - let mut texts: Vec<(usize, String)> = Vec::new(); - for index in 0..program.values.len() { - let id = wright_ir::ids::Id::from_index(index); - let Some(node) = program.values.get(id) else { - continue; - }; - if let Value::Call { name, args } = &node.value { - if name == "customString" && !args.is_empty() { - let text_id = args[0]; - if let Some(Value::String(text)) = program.values.get(text_id).map(|n| &n.value) { - let normalized: String = text - .chars() - .map(|c| match c { - '<' => '{', - '>' => '}', - other => other, - }) - .collect(); - if normalized != *text { - texts.push((text_id.index(), normalized)); - } - } - } - } - } - for (index, text) in texts { - let id = wright_ir::ids::Id::from_index(index); - program.values.get_mut(id).expect("id in range").value = Value::String(text); - } -} - -/// The reference's null/unit vector output idioms (P6 evidence): -/// `Vector(0,0,0)` ≡ `Subtract(Left, Left)` and `Vector(1,0,0)` ≡ `Left`. -/// Wright emits the honest `Vector(...)` form; the declared normalization -/// maps the reference idioms to their parsed WIR shapes on both sides. -fn vector_idioms(program: &mut wir::Program) { - let mut rewrites: Vec<(usize, wir::Value)> = Vec::new(); - for index in 0..program.values.len() { - let id = wright_ir::ids::Id::from_index(index); - let Some(node) = program.values.get(id) else { - continue; - }; - let Value::Call { name, args } = &node.value else { - continue; - }; - if name != "vector" || args.len() != 3 { - continue; - } - let components: Option<(f64, f64, f64)> = (|| { - Some(( - match &program.values.get(args[0])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - match &program.values.get(args[1])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - match &program.values.get(args[2])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - )) - })(); - let Some((x, y, z)) = components else { - continue; - }; - let left = program.values.push(wir::ValueNode::new( - Value::Enum { - value_type: "HudPosition".to_string(), - value: "LEFT".to_string(), - }, - None, - )); - if x == 0.0 && y == 0.0 && z == 0.0 { - rewrites.push(( - index, - Value::Call { - name: "subtract".to_string(), - args: vec![left, left], - }, - )); - } else if x == 1.0 && y == 0.0 && z == 0.0 { - rewrites.push(( - index, - Value::Enum { - value_type: "HudPosition".to_string(), - value: "LEFT".to_string(), - }, - )); - } - } - for (index, value) in rewrites { - let id = wright_ir::ids::Id::from_index(index); - program.values.get_mut(id).expect("id in range").value = value; - } -} - -/// The declared #119 normalization, applied identically to both sides. -fn normalize(program: &mut wir::Program) { - fold(program); - inline_write_once_player_vars(program); - fold(program); - foreach_globalize(program); - vector_idioms(program); - let qualified_vectors: Vec<(usize, wir::Value)> = (0..program.values.len()) - .filter_map(|index| { - let id = wright_ir::ids::Id::from_index(index); - let Value::Call { name, args } = &program.values.get(id)?.value else { - return None; - }; - if !name.eq_ignore_ascii_case("vector") || args.len() != 1 { - return None; - } - match &program.values.get(args[0])?.value { - Value::Enum { value_type, value } if value_type == "Vector" => Some(( - index, - Value::Enum { - value_type: value_type.clone(), - value: value.clone(), - }, - )), - _ => None, - } - }) - .collect(); - for (index, value) in qualified_vectors { - let id = wright_ir::ids::Id::from_index(index); - program.values.get_mut(id).expect("id in range").value = value; - } - fold_placeholders(program); -} - -/// Structural comparison of two normalized programs (variable tables are -/// identity artifacts and are excluded). Returns a readable diff on -/// mismatch. -fn compare(actual: &wir::Program, expected: &wir::Program) -> Result<(), String> { - let mut name_of = |program: &wir::Program| -> HashMap { - let mut map = HashMap::new(); - for variable in program.global_variables.iter() { - map.insert(variable.index, variable.name.clone()); - } - map - }; - let _ = &mut name_of; - let rules_a = &actual.rules; - let rules_b = &expected.rules; - if rules_a.len() != rules_b.len() { - return Err(format!( - "rule count differs: actual {} vs reference {}", - rules_a.len(), - rules_b.len() - )); - } - for (index, (rule_a, rule_b)) in rules_a.iter().zip(rules_b.iter()).enumerate() { - // The synthetic initialize-rule name is presentation (the game keys - // rules by structure, not display text); Wright's shared lowering - // carries the OPY-surface name while the OSTW reference emits - // "Initial Global"/"Initial Player". - let name_a = match rule_a.name.as_str() { - "Initialize global variables" => "Initial Global", - "Initialize player variables" => "Initial Player", - other => other, - }; - let name_b = match rule_b.name.as_str() { - "Initialize global variables" => "Initial Global", - "Initialize player variables" => "Initial Player", - other => other, - }; - if name_a != name_b { - return Err(format!( - "rule {index} name differs: '{}' vs '{}'", - rule_a.name, rule_b.name - )); - } - match (&rule_a.event, &rule_b.event) { - (Event::Global, Event::Global) - | (Event::EachPlayer, Event::EachPlayer) - | ( - Event::EachPlayer, - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - ) - | ( - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - Event::EachPlayer, - ) - | ( - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - ) => {} - (Event::EachPlayerWithFilters { .. }, _) - | (Event::Player { .. }, _) - | (_, Event::EachPlayerWithFilters { .. }) - | (_, Event::Player { .. }) => { - return Err(format!("rule {index} uses an unsupported event")); - } - (Event::Subroutine(a), Event::Subroutine(b)) => { - let name_a = actual - .subroutines - .get(*a) - .map(|s| s.name.clone()) - .unwrap_or_default(); - let name_b = expected - .subroutines - .get(*b) - .map(|s| s.name.clone()) - .unwrap_or_default(); - if name_a != name_b { - return Err(format!( - "rule {index} subroutine differs: {name_a} vs {name_b}" - )); - } - } - (a, b) => { - return Err(format!("rule {index} event differs: {a:?} vs {b:?}")); - } - } - if rule_a.conditions.len() != rule_b.conditions.len() { - return Err(format!( - "rule {index} condition count differs: {} vs {}", - rule_a.conditions.len(), - rule_b.conditions.len() - )); - } - for (condition_a, condition_b) in rule_a.conditions.iter().zip(rule_b.conditions.iter()) { - compare_value(actual, expected, *condition_a, *condition_b) - .map_err(|message| format!("rule {index} condition: {message}"))?; - } - if rule_a.actions.len() != rule_b.actions.len() { - return Err(format!( - "rule {index} action count differs: {} vs {}", - rule_a.actions.len(), - rule_b.actions.len() - )); - } - for (action_a, action_b) in rule_a.actions.iter().zip(rule_b.actions.iter()) { - compare_action(actual, expected, *action_a, *action_b) - .map_err(|message| format!("rule {index}: {message}"))?; - } - } - Ok(()) -} - -fn compare_action( - actual: &wir::Program, - expected: &wir::Program, - action_a: wir::ActionId, - action_b: wir::ActionId, -) -> Result<(), String> { - let (Some(a), Some(b)) = (actual.actions.get(action_a), expected.actions.get(action_b)) else { - return Err("dangling action".to_string()); - }; - let span_text = |_program: &wir::Program, action: &Action| match action { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } - | Action::Debug { value, .. } - | Action::Print { message: value, .. } => vec![*value], - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => vec![*player, *value], - Action::AssignMember { target, value, .. } => vec![*target, *value], - Action::CallSubroutine { .. } => Vec::new(), - Action::If { - branches, - else_body, - .. - } => { - let mut out = Vec::new(); - for branch in branches { - out.push(branch.condition); - } - if let Some(else_body) = else_body { - for action in else_body { - let _ = action; - } - } - out - } - Action::While { condition, .. } => vec![*condition], - Action::ForGlobalVariable { - start, stop, step, .. - } - | Action::ForPlayerVariable { - start, stop, step, .. - } => vec![*start, *stop, *step], - Action::Call { args, .. } => args.clone(), - }; - let _ = span_text; - match (a, b) { - ( - Action::SetGlobalVariable { - variable: va, - value: value_a, - .. - }, - Action::SetGlobalVariable { - variable: vb, - value: value_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "setGlobalVariable target differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::ModifyGlobalVariable { - variable: va, - op: op_a, - value: value_a, - .. - }, - Action::ModifyGlobalVariable { - variable: vb, - op: op_b, - value: value_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "modifyGlobalVariable target differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - if op_a != op_b { - return Err(format!("modify operator differs: {op_a:?} vs {op_b:?}")); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::SetPlayerVariable { - player: player_a, - variable: va, - value: value_a, - .. - }, - Action::SetPlayerVariable { - player: player_b, - variable: vb, - value: value_b, - .. - }, - ) => { - compare_value(actual, expected, *player_a, *player_b)?; - if player_name(actual, *va) != player_name(expected, *vb) { - return Err(format!( - "setPlayerVariable target differs: {} vs {}", - player_name(actual, *va), - player_name(expected, *vb) - )); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::ModifyPlayerVariable { - player: player_a, - variable: va, - op: op_a, - value: value_a, - .. - }, - Action::ModifyPlayerVariable { - player: player_b, - variable: vb, - op: op_b, - value: value_b, - .. - }, - ) => { - compare_value(actual, expected, *player_a, *player_b)?; - if player_name(actual, *va) != player_name(expected, *vb) { - return Err(format!( - "modifyPlayerVariable target differs: {} vs {}", - player_name(actual, *va), - player_name(expected, *vb) - )); - } - if op_a != op_b { - return Err(format!("modify operator differs: {op_a:?} vs {op_b:?}")); - } - compare_value(actual, expected, *value_a, *value_b) - } - (Action::CallSubroutine { .. }, Action::CallSubroutine { .. }) => Ok(()), - ( - Action::If { - branches: branches_a, - else_body: else_a, - .. - }, - Action::If { - branches: branches_b, - else_body: else_b, - .. - }, - ) => { - if branches_a.len() != branches_b.len() { - return Err(format!( - "if branch count differs: {} vs {}", - branches_a.len(), - branches_b.len() - )); - } - for (branch_a, branch_b) in branches_a.iter().zip(branches_b.iter()) { - compare_value(actual, expected, branch_a.condition, branch_b.condition)?; - if branch_a.body.len() != branch_b.body.len() { - return Err(format!( - "if branch body length differs: {} vs {}", - branch_a.body.len(), - branch_b.body.len() - )); - } - for (action_a, action_b) in branch_a.body.iter().zip(branch_b.body.iter()) { - compare_action(actual, expected, *action_a, *action_b)?; - } - } - match (else_a, else_b) { - (None, None) => Ok(()), - (Some(body_a), Some(body_b)) => { - if body_a.len() != body_b.len() { - return Err(format!( - "else body length differs: {} vs {}", - body_a.len(), - body_b.len() - )); - } - for (action_a, action_b) in body_a.iter().zip(body_b.iter()) { - compare_action(actual, expected, *action_a, *action_b)?; - } - Ok(()) - } - (Some(_), None) => Err("actual has an else body, reference does not".to_string()), - (None, Some(_)) => Err("reference has an else body, actual does not".to_string()), - } - } - ( - Action::While { - condition: condition_a, - body: body_a, - .. - }, - Action::While { - condition: condition_b, - body: body_b, - .. - }, - ) => { - compare_value(actual, expected, *condition_a, *condition_b)?; - compare_actions(actual, expected, body_a, body_b) - } - ( - Action::ForGlobalVariable { - variable: va, - start: start_a, - stop: stop_a, - step: step_a, - body: body_a, - .. - }, - Action::ForGlobalVariable { - variable: vb, - start: start_b, - stop: stop_b, - step: step_b, - body: body_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "for loop variable differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - compare_value(actual, expected, *start_a, *start_b)?; - compare_value(actual, expected, *stop_a, *stop_b)?; - compare_value(actual, expected, *step_a, *step_b)?; - compare_actions(actual, expected, body_a, body_b) - } - (Action::Debug { value: va, .. }, Action::Debug { value: vb, .. }) => { - compare_value(actual, expected, *va, *vb) - } - (Action::Print { message: ma, .. }, Action::Print { message: mb, .. }) => { - compare_value(actual, expected, *ma, *mb) - } - ( - Action::Call { - name: name_a, - args: args_a, - .. - }, - Action::Call { - name: name_b, - args: args_b, - .. - }, - ) => { - if name_a != name_b { - return Err(format!("call name differs: '{name_a}' vs '{name_b}'")); - } - if args_a.len() != args_b.len() { - return Err(format!( - "call '{name_a}' arity differs: {} vs {}", - args_a.len(), - args_b.len() - )); - } - for (value_a, value_b) in args_a.iter().zip(args_b.iter()) { - compare_value(actual, expected, *value_a, *value_b)?; - } - Ok(()) - } - (a, b) => Err(format!( - "action kind differs: {} vs {}", - action_kind(a), - action_kind(b) - )), - } -} - -fn compare_actions( - actual: &wir::Program, - expected: &wir::Program, - actions_a: &[wir::ActionId], - actions_b: &[wir::ActionId], -) -> Result<(), String> { - if actions_a.len() != actions_b.len() { - return Err(format!( - "action list length differs: {} vs {}", - actions_a.len(), - actions_b.len() - )); - } - for (action_a, action_b) in actions_a.iter().zip(actions_b.iter()) { - compare_action(actual, expected, *action_a, *action_b)?; - } - Ok(()) -} - -fn compare_value( - actual: &wir::Program, - expected: &wir::Program, - value_a: wir::ValueId, - value_b: wir::ValueId, -) -> Result<(), String> { - let (Some(node_a), Some(node_b)) = (actual.values.get(value_a), expected.values.get(value_b)) - else { - return Err("dangling value".to_string()); - }; - match (&node_a.value, &node_b.value) { - (Value::Number { value: x, .. }, Value::Number { value: y, .. }) => { - if x != y { - return Err(format!("number differs: {x} vs {y}")); - } - Ok(()) - } - (Value::String(x), Value::String(y)) => { - if x != y { - return Err(format!("string differs: '{x}' vs '{y}'")); - } - Ok(()) - } - (Value::Bool(x), Value::Bool(y)) => { - if x != y { - return Err(format!("bool differs: {x} vs {y}")); - } - Ok(()) - } - (Value::Null, Value::Null) => Ok(()), - (Value::Array(x), Value::Array(y)) => { - if x.len() != y.len() { - return Err(format!("array length differs: {} vs {}", x.len(), y.len())); - } - for (a, b) in x.iter().zip(y.iter()) { - compare_value(actual, expected, *a, *b)?; - } - Ok(()) - } - ( - Value::Vector { - x: x1, - y: y1, - z: z1, - }, - Value::Vector { - x: x2, - y: y2, - z: z2, - }, - ) => { - compare_value(actual, expected, *x1, *x2)?; - compare_value(actual, expected, *y1, *y2)?; - compare_value(actual, expected, *z1, *z2) - } - ( - Value::Enum { - value_type: t1, - value: v1, - }, - Value::Enum { - value_type: t2, - value: v2, - }, - ) => { - // A Team color and the Team itself are the same Workshop value - // (`Color.TEAM_1` ≡ `Team.TEAM_1`): the ambiguous `Team 1` - // spelling can resolve to either domain on either side. - let team_equivalent = matches!( - (t1.as_str(), t2.as_str()), - ("Color", "Team") | ("Team", "Color") - ) && v1 == v2; - if (t1 != t2 || v1 != v2) && !team_equivalent { - return Err(format!("enum differs: {t1}.{v1} vs {t2}.{v2}")); - } - Ok(()) - } - (Value::GlobalVariable(x), Value::GlobalVariable(y)) => { - if global_name(actual, *x) != global_name(expected, *y) { - return Err(format!( - "global differs: {} vs {}", - global_name(actual, *x), - global_name(expected, *y) - )); - } - Ok(()) - } - ( - Value::PlayerVariable { - player: p1, - variable: v1, - }, - Value::PlayerVariable { - player: p2, - variable: v2, - }, - ) => { - compare_value(actual, expected, *p1, *p2)?; - if player_name(actual, *v1) != player_name(expected, *v2) { - return Err(format!( - "player variable differs: {} vs {}", - player_name(actual, *v1), - player_name(expected, *v2) - )); - } - Ok(()) - } - (Value::EventPlayer, Value::EventPlayer) => Ok(()), - (Value::Enum { value, .. }, Value::Call { name, .. }) - if name == "memberAccess" && value == "LEFT" => - { - Ok(()) - } - (Value::Call { name, .. }, Value::Enum { value, .. }) - if name == "memberAccess" && value == "LEFT" => - { - Ok(()) - } - ( - Value::Call { - name: name_a, - args: args_a, - }, - Value::Call { - name: name_b, - args: args_b, - }, - ) => { - if name_a != name_b { - return Err(format!("value call differs: '{name_a}' vs '{name_b}'")); - } - if args_a.len() != args_b.len() { - return Err(format!( - "value call '{name_a}' arity differs: {} vs {}", - args_a.len(), - args_b.len() - )); - } - for (a, b) in args_a.iter().zip(args_b.iter()) { - compare_value(actual, expected, *a, *b)?; - } - Ok(()) - } - (a, b) => Err(format!( - "value kind differs: {} vs {}", - value_kind(a), - value_kind(b) - )), - } -} - -fn global_name(program: &wir::Program, id: wir::GlobalVarId) -> String { - program - .global_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() -} - -fn player_name(program: &wir::Program, id: wir::PlayerVarId) -> String { - program - .player_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() -} - -fn action_kind(action: &Action) -> &'static str { - match action { - Action::SetGlobalVariable { .. } => "setGlobalVariable", - Action::ModifyGlobalVariable { .. } => "modifyGlobalVariable", - Action::SetPlayerVariable { .. } => "setPlayerVariable", - Action::ModifyPlayerVariable { .. } => "modifyPlayerVariable", - Action::AssignMember { .. } => "assignMember", - Action::CallSubroutine { .. } => "callSubroutine", - Action::If { .. } => "if", - Action::While { .. } => "while", - Action::ForGlobalVariable { .. } => "forGlobalVariable", - Action::ForPlayerVariable { .. } => "forPlayerVariable", - Action::Debug { .. } => "debug", - Action::Print { .. } => "print", - Action::Call { .. } => "call", - } -} - -fn value_kind(value: &Value) -> &'static str { - match value { - Value::Number { .. } => "number", - Value::String(_) => "string", - Value::LocalizedString(_) => "localizedString", - Value::Bool(_) => "bool", - Value::Null => "null", - Value::Array(_) => "array", - Value::Vector { .. } => "vector", - Value::Enum { .. } => "enum", - Value::GlobalVariable(_) => "global", - Value::PlayerVariable { .. } => "playerVariable", - Value::Subroutine(_) => "subroutine", - Value::EventPlayer => "eventPlayer", - Value::Call { .. } => "call", - } -} - -#[test] -fn accepted_targets_compile_and_match_pinned_reference_semantics() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let mut report = serde_json::Map::new(); - for target in TARGETS { - let dir = workspace_root() - .join("compatibility/ostw/probes") - .join(target); - let semantic = compile_semantic(&dir, "main.ostw"); - assert!( - semantic.diagnostics.is_empty(), - "{target}: the target surface resolves cleanly: {:?}", - semantic.diagnostics - ); - let hir = semantic.hir.as_ref().expect("HIR produced"); - let program = wright_ir::lower::lower(hir).expect("lowering succeeds"); - program.validate().expect("lowered program validates"); - - let emitted = workshop_rs::emitter::emit( - &program, - &catalog, - &workshop_rs::catalog::Locale::new("en-US"), - ) - .expect("emission succeeds"); - - // Declared round-trip contract: Wright-emitted Workshop reparses and - // re-emits byte-identically (semantic fixed point). - let reparsed = parse(&catalog, &emitted); - let reemitted = workshop_rs::emitter::emit( - &reparsed, - &catalog, - &workshop_rs::catalog::Locale::new("en-US"), - ) - .expect("re-emission succeeds"); - assert_eq!( - emitted, reemitted, - "{target}: Wright-emitted Workshop must reach the round-trip fixed point" - ); - - let reference_text = read(&dir.join("workshop.entry-only.txt")); - // The reference emits ambiguous bare spellings (`Team 2`) and the - // OSTW reference's `Visible To And String` casing differs from the - // catalog's OPY-evidenced spelling; apply the emitter's - // qualification and the declared spelling normalization so both - // sides parse deterministically. - let reference_text = - reference_text.replace("Visible To And String", "Visible To and String"); - let reference_text = qualify_ambiguous_members(&catalog, &reference_text); - let mut reference = parse(&catalog, &reference_text); - - let mut actual = reparsed; - normalize(&mut actual); - normalize(&mut reference); - - compare(&actual, &reference) - .unwrap_or_else(|message| panic!("{target}: semantic divergence: {message}")); - - let mut hasher = ::new(); - sha2::Digest::update(&mut hasher, emitted.as_bytes()); - let emitted_sha256 = format!("{:x}", hasher.finalize()); - report.insert( - target.to_string(), - serde_json::json!({ - "status": "parity", - "elementCount": reference_text.matches("Rule Element Count:").count(), - "emittedSha256": emitted_sha256, - "roundTrip": "fixed-point", - }), - ); - } - report.insert( - "suite".to_string(), - serde_json::json!({ - "name": "wright-ostw-differential", - "targets": TARGETS.len(), - "reference": { - "name": "ostw", - "version": "v3.4.0", - "contentCommit": "769ce7aab097178cfe905bf21f0326d8e0d12e6b", - }, - }), - ); - let report_path = workspace_root().join("target/wright-ostw-differential-report.json"); - let parent = report_path.parent().expect("target dir"); - std::fs::create_dir_all(parent).expect("create target dir"); - std::fs::write( - &report_path, - serde_json::to_string_pretty(&serde_json::Value::Object(report)) - .expect("report serializes"), - ) - .expect("write differential report"); -} diff --git a/crates/wright-ostw/tests/parse.rs b/crates/wright-ostw/tests/parse.rs deleted file mode 100644 index 086158f..0000000 --- a/crates/wright-ostw/tests/parse.rs +++ /dev/null @@ -1,721 +0,0 @@ -//! Corpus-driven frontend regressions (#117): the real committed protect-ban -//! project must load with compilation membership equal to the `ds.toml` -//! entry-point import closure, quoted imports resolve with correct -//! multi-file provenance, unreachable sources never contribute project -//! diagnostics, and deterministic negative fixtures cover malformed syntax, -//! missing imports, invalid entry points, unsupported ds.toml configuration, -//! reachability flips, and cycle/duplicate determinism. Assertions are on -//! observable outcomes, never on hardcoded parse trees. - -use std::path::{Path, PathBuf}; - -use workshop_rs::source::FileId; -use wright_ostw::project::{FileRecord, OstwOutcome}; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -fn corpus_root() -> PathBuf { - workspace_root().join("compatibility/ostw/corpus/protect-ban") -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())) -} - -fn compile_project(root: &Path, main_rel: &str) -> OstwOutcome { - let main_text = read(&root.join(main_rel)); - wright_ostw::compile(&main_text, Some(main_rel), root) -} - -/// The 16 committed protect-ban source files in the workspace inventory. -const PROTECT_BAN_INVENTORY: &[&str] = &[ - "Credits.ostw", - "coreDebug.ostw", - "interface/ClickArea.del", - "interface/HeroSelect.del", - "interface/HeroSelectConfig.del", - "interface/HeroSelectFunctions.del", - "interface/MapData.del", - "interface/PlayerInterface.del", - "interface/miscSetup.del", - "main.ostw", - "protectBanFull.ostw", - "utils/AltFont.del", - "utils/Colors.del", - "utils/Math.del", - "utils/ScreenToWorld.del", - "utils/ServerLoad.del", -]; - -/// The 7 entry-point import-reachable files of protect-ban (`main.ostw`). -const PROTECT_BAN_CLOSURE: &[&str] = &[ - "main.ostw", - "Credits.ostw", - "interface/HeroSelect.del", - "interface/miscSetup.del", - "interface/HeroSelectConfig.del", - "interface/HeroSelectFunctions.del", - "interface/MapData.del", -]; - -fn sources(outcome: &OstwOutcome) -> Vec<&FileRecord> { - outcome - .project - .as_ref() - .expect("project loads") - .files - .iter() - .filter(|file| file.source) - .collect() -} - -#[test] -fn protect_ban_compilation_membership_is_the_entry_point_closure() { - // Compilation membership = the entry-point import closure: exactly the 7 - // reachable files parse; unreachable-file defects contribute nothing. - let outcome = compile_project(&corpus_root(), "main.ostw"); - assert!( - outcome.error.is_none(), - "project must load: {:?}", - outcome.error - ); - let project = outcome.project.as_ref().expect("project loads"); - assert_eq!(project.entry, "main.ostw", "ds.toml entry_point"); - - let files = sources(&outcome); - assert_eq!( - files.len(), - PROTECT_BAN_CLOSURE.len(), - "only the import-reachable closure is compiled" - ); - for source in PROTECT_BAN_CLOSURE { - let record = files - .iter() - .find(|file| file.path == *source) - .unwrap_or_else(|| panic!("missing closure source {source} in the registry")); - assert!(record.parsed, "{} must parse cleanly", record.path); - assert!(record.cst.is_some(), "{} must carry its CST", record.path); - } - - // No in-closure parse errors. - let parse_errors: Vec<_> = outcome - .diagnostics - .iter() - .filter(|error| error.code == "ostw-parse-error" || error.code == "ostw-lex-error") - .collect(); - assert!( - parse_errors.is_empty(), - "no in-closure parse errors, got: {:?}", - parse_errors - ); - - // Exactly the 3 reachable OSTWUtils missing imports appear. Unreachable - // defects (e.g. protectBanFull.ostw's `customGameSettings.lobby` import) - // contribute nothing. - let missing: Vec<_> = outcome - .diagnostics - .iter() - .filter(|error| error.code == "ostw-missing-import") - .collect(); - assert_eq!( - missing.len(), - 3, - "only reachable missing imports appear: {:?}", - outcome.diagnostics - ); - for diagnostic in &missing { - assert!( - diagnostic.span.is_some(), - "missing-import diagnostics carry a source location" - ); - } - - // The workspace inventory is distinct and retains all 16 sources. - assert_eq!( - project.inventory, PROTECT_BAN_INVENTORY, - "the inventory is the full workspace source list" - ); -} - -#[test] -fn protect_ban_inventory_parses_for_robustness_only() { - // All-protect-ban-files parser robustness: every inventory source lexes - // and parses cleanly through the shipped frontend functions. This is a - // parser-robustness check over the inventory, not a compilation-membership - // or project-success assertion (unreachable files are not compilation - // members). - for source in PROTECT_BAN_INVENTORY { - let path = corpus_root().join(source); - let text = read(&path); - let tokens = wright_ostw::lexer::lex(wright_ostw::lexer::LexInput { - file_id: FileId::from_index(0), - text: &text, - }) - .unwrap_or_else(|error| panic!("{source} must lex: {error}")); - wright_ostw::parser::parse(tokens) - .unwrap_or_else(|error| panic!("{source} must parse: {error}")); - } -} - -#[test] -fn quoted_imports_resolve_relative_to_the_importing_file() { - let outcome = compile_project(&corpus_root(), "main.ostw"); - let project = outcome.project.as_ref().expect("project loads"); - let by_path = |path: &str| { - project - .files - .iter() - .find(|file| file.path == path) - .unwrap_or_else(|| panic!("missing file {path}")) - }; - - let main = by_path("main.ostw"); - let credits = by_path("Credits.ostw"); - let hero_select = by_path("interface/HeroSelect.del"); - let misc_setup = by_path("interface/miscSetup.del"); - assert_eq!( - main.imports - .iter() - .map(|import| import.target) - .collect::>(), - vec![Some(credits.id), Some(hero_select.id), Some(misc_setup.id)], - "main.ostw imports resolve to the right files" - ); - - // `../main.ostw` from interface/HeroSelect.del resolves to the root main. - let main_import = hero_select - .imports - .iter() - .find(|import| import.path == "../main.ostw") - .expect("HeroSelect imports ../main.ostw"); - assert_eq!( - main_import.target, - Some(main.id), - "../main.ostw from interface/ resolves to the root main.ostw" - ); - - // The closure contains each file exactly once despite the corpus's - // import cycles (main <-> HeroSelect/miscSetup, HeroSelect <-> - // HeroSelectConfig <-> HeroSelectFunctions). - assert_eq!(sources(&outcome).len(), 7, "each file appears once"); - - // Out-of-closure imports resolve to None (missing). The - // `../OSTWUtils/Diagnostics.del` import in HeroSelect.del is inside a - // block comment and is not an import. - let out_of_closure: Vec<_> = project - .files - .iter() - .filter(|file| file.source) - .flat_map(|file| file.imports.iter()) - .filter(|import| import.target.is_none()) - .map(|import| import.path.clone()) - .collect(); - assert_eq!( - out_of_closure, - vec![ - "../OSTWUtils/OnScreenText.del".to_string(), - "../OSTWUtils/Cursor.del".to_string(), - "../OSTWUtils/StringSorting.del".to_string(), - ] - ); -} - -#[test] -fn spans_map_to_the_correct_corpus_file() { - let outcome = compile_project(&corpus_root(), "main.ostw"); - let project = outcome.project.as_ref().expect("project loads"); - for diagnostic in &outcome.diagnostics { - let span = diagnostic.span.expect("diagnostics carry spans"); - let record = &project.files[span.file.index()]; - assert!( - record.path == "ds.toml" || record.source, - "span file {} must resolve to a registry file (got {})", - span.file.index(), - record.path - ); - } - // The HeroSelect missing-import diagnostics point into HeroSelect.del. - let hero_select = project - .files - .iter() - .find(|file| file.path == "interface/HeroSelect.del") - .unwrap(); - let missing = hero_select - .imports - .iter() - .filter(|import| import.target.is_none()) - .map(|import| import.span.file.index()) - .collect::>(); - assert_eq!( - missing, - vec![hero_select.id as usize; 3], - "OSTWUtils missing imports point at interface/HeroSelect.del" - ); -} - -// -- negative fixtures ------------------------------------------------------- - -fn temp_project(content: Vec<(String, String)>) -> PathBuf { - use std::sync::atomic::{AtomicUsize, Ordering}; - static COUNTER: AtomicUsize = AtomicUsize::new(0); - let dir = std::env::temp_dir().join(format!( - "wright-ostw-neg-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, Ordering::SeqCst) - )); - std::fs::create_dir_all(&dir).unwrap(); - for (path, text) in content { - let full = dir.join(path); - std::fs::create_dir_all(full.parent().unwrap()).unwrap(); - std::fs::write(&full, text).unwrap(); - } - dir -} - -fn compile_temp(root: &Path, main_rel: &str) -> OstwOutcome { - let main_text = read(&root.join(main_rel)); - wright_ostw::compile(&main_text, Some(main_rel), root) -} - -fn project_of(outcome: &OstwOutcome) -> &wright_ostw::Project { - outcome.project.as_ref().expect("project loads") -} - -fn source_paths(outcome: &OstwOutcome) -> Vec { - project_of(outcome) - .files - .iter() - .filter(|file| file.source) - .map(|file| file.path.clone()) - .collect() -} - -#[test] -fn malformed_syntax_is_a_structured_source_located_error() { - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ( - "main.ostw".to_string(), - "globalvar Number x = ;\n".to_string(), - ), - ]); - let outcome = compile_temp(&root, "main.ostw"); - assert!(outcome.error.is_none(), "project still loads"); - let parse = outcome - .diagnostics - .iter() - .find(|error| error.code == "ostw-parse-error") - .expect("malformed syntax yields ostw-parse-error"); - let span = parse.span.expect("parse errors carry a span"); - assert_eq!( - span.file, - FileId::from_index(1), - "the error points at main.ostw (id 1, after ds.toml id 0)" - ); - assert_eq!(span.start.line, 1, "the error is on line 1"); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn missing_import_is_structured_and_source_located() { - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ( - "main.ostw".to_string(), - "import \"missing/File.del\";\nrule: \"r\" {}\n".to_string(), - ), - ]); - let outcome = compile_temp(&root, "main.ostw"); - let missing = outcome - .diagnostics - .iter() - .find(|error| error.code == "ostw-missing-import") - .expect("a missing import yields ostw-missing-import"); - let span = missing.span.expect("missing-import carries a span"); - assert_eq!(span.start.line, 1, "the import statement is on line 1"); - assert_eq!( - span.file, - FileId::from_index(1), - "the diagnostic points at main.ostw" - ); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn invalid_entry_point_is_structured() { - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"nope.ostw\"\n".to_string(), - ), - ("main.ostw".to_string(), "rule: \"r\" {}\n".to_string()), - ]); - let outcome = compile_temp(&root, "main.ostw"); - let entry = outcome - .diagnostics - .iter() - .find(|error| error.code == "ostw-entry-not-found") - .expect("an invalid entry_point yields ostw-entry-not-found"); - assert!(entry.message.contains("nope.ostw")); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn unsupported_ds_toml_key_is_structured() { - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\nout_file=\"out.ows\"\n".to_string(), - ), - ("main.ostw".to_string(), "rule: \"r\" {}\n".to_string()), - ]); - let outcome = compile_temp(&root, "main.ostw"); - let unsupported = outcome - .diagnostics - .iter() - .find(|error| error.code == "ostw-ds-toml-unsupported-key") - .expect("an unsupported ds.toml key yields ostw-ds-toml-unsupported-key"); - assert!(unsupported.message.contains("out_file")); - let span = unsupported.span.expect("ds.toml diagnostics carry a span"); - assert_eq!(span.file, FileId::from_index(0), "points at ds.toml (id 0)"); - assert_eq!(span.start.line, 2, "the unsupported key is on line 2"); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn missing_ds_toml_is_structured() { - let root = temp_project(vec![( - "main.ostw".to_string(), - "rule: \"r\" {}\n".to_string(), - )]); - let main_text = read(&root.join("main.ostw")); - let outcome = wright_ostw::compile(&main_text, Some("main.ostw"), &root); - let error = outcome - .error - .expect("a missing ds.toml is a project-load error"); - assert_eq!(error.code, "ostw-ds-toml-missing"); - let _ = std::fs::remove_dir_all(&root); -} - -// -- compilation-graph regressions ------------------------------------------- - -#[test] -fn unreachable_broken_source_does_not_fail_the_project() { - // A source with broken syntax that is not reachable from the entry must - // not fail the project or produce any diagnostic. - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ("main.ostw".to_string(), "rule: \"r\" {}\n".to_string()), - ( - "broken.ostw".to_string(), - "globalvar Number x = ;\n".to_string(), - ), - ]); - let outcome = compile_temp(&root, "main.ostw"); - assert!(outcome.error.is_none()); - assert!( - outcome.diagnostics.is_empty(), - "unreachable broken syntax contributes nothing: {:?}", - outcome.diagnostics - ); - assert_eq!( - source_paths(&outcome), - vec!["main.ostw"], - "only the entry is a compilation member" - ); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn unreachable_missing_import_does_not_fail_the_project() { - // A source with a missing import that is not reachable from the entry - // must not produce a missing-import diagnostic. - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ("main.ostw".to_string(), "rule: \"r\" {}\n".to_string()), - ( - "orphan.del".to_string(), - "import \"gone/File.del\";\nrule: \"o\" {}\n".to_string(), - ), - ]); - let outcome = compile_temp(&root, "main.ostw"); - assert!(outcome.error.is_none()); - assert!( - outcome.diagnostics.is_empty(), - "unreachable missing imports contribute nothing: {:?}", - outcome.diagnostics - ); - assert_eq!(source_paths(&outcome), vec!["main.ostw"]); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn making_a_source_reachable_surfaces_its_diagnostic() { - // The reachability flip: an unreachable source's defect is hidden; once - // the entry closure imports it, the structured source-located diagnostic - // appears; removing the import hides it again. - let mk = |import: bool| { - let mut content = vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ( - "main.ostw".to_string(), - if import { - "import \"broken.del\";\nrule: \"r\" {}\n".to_string() - } else { - "rule: \"r\" {}\n".to_string() - }, - ), - ( - "broken.del".to_string(), - "import \"gone/File.del\";\nrule: \"b\" {}\n".to_string(), - ), - ]; - // `gone/File.del` intentionally does not exist: broken.del's defect - // is the missing import, surfaced only once broken.del is reachable. - if !import { - content.push(("unused.del".to_string(), "Number x: 1;\n".to_string())); - } - temp_project(content) - }; - - let hidden = compile_temp(&mk(false), "main.ostw"); - assert!( - hidden.diagnostics.is_empty(), - "unreachable: no diagnostic: {:?}", - hidden.diagnostics - ); - assert_eq!(source_paths(&hidden), vec!["main.ostw"]); - - let visible = compile_temp(&mk(true), "main.ostw"); - let missing = visible - .diagnostics - .iter() - .find(|error| error.code == "ostw-missing-import") - .expect("reachable broken import surfaces ostw-missing-import"); - let span = missing.span.expect("diagnostic is source-located"); - assert_eq!(span.file, FileId::from_index(2), "points at broken.del"); - assert_eq!( - source_paths(&visible), - vec!["main.ostw", "broken.del"], - "broken.del is now a compilation member" - ); - - let _ = std::fs::remove_dir_all(mk(false)); - let _ = std::fs::remove_dir_all(mk(true)); -} - -#[test] -fn cycles_and_duplicate_imports_are_deterministic() { - // A cycle (a <-> b) plus duplicate imports must include each file once, - // produce no duplicate diagnostics, and be byte-stable across loads. - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ( - "main.ostw".to_string(), - "import \"a.del\";\nimport \"a.del\";\nimport \"b.del\";\nrule: \"m\" {}\n".to_string(), - ), - ( - "a.del".to_string(), - "import \"b.del\";\nrule: \"a\" {}\n".to_string(), - ), - ( - "b.del".to_string(), - "import \"a.del\";\nrule: \"b\" {}\n".to_string(), - ), - ]); - let first = compile_temp(&root, "main.ostw"); - let second = compile_temp(&root, "main.ostw"); - assert_eq!(format!("{first:?}"), format!("{second:?}"), "byte-stable"); - - assert!(first.error.is_none()); - assert!( - first.diagnostics.is_empty(), - "no duplicate diagnostics from the cycle/duplicates: {:?}", - first.diagnostics - ); - assert_eq!( - source_paths(&first), - vec!["main.ostw", "a.del", "b.del"], - "each file appears exactly once" - ); - // The duplicate `import "a.del"` produces two resolved edges, both to the - // same file id — never a diagnostic. - let main = project_of(&first) - .files - .iter() - .find(|file| file.path == "main.ostw") - .unwrap(); - assert_eq!( - main.imports - .iter() - .filter(|import| import.path == "a.del") - .count(), - 2 - ); - let a = project_of(&first) - .files - .iter() - .find(|file| file.path == "a.del") - .unwrap(); - assert_eq!( - a.imports[0].target, - Some( - project_of(&first) - .files - .iter() - .find(|file| file.path == "b.del") - .unwrap() - .id - ), - "cycle edge a -> b resolves" - ); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn determinism_two_runs_produce_identical_outcomes() { - let root = corpus_root(); - let first = compile_project(&root, "main.ostw"); - let second = compile_project(&root, "main.ostw"); - assert_eq!(format!("{first:?}"), format!("{second:?}")); -} - -#[test] -fn overlays_validate_proposed_edits_without_rewriting_files() { - // #128: a proposed multi-file edit validates against overlay text - // (main file and imports) while the on-disk project stays untouched. - use std::collections::BTreeMap; - let root = temp_project(vec![ - ( - "ds.toml".to_string(), - "entry_point=\"main.ostw\"\n".to_string(), - ), - ( - "main.ostw".to_string(), - "import \"lib.del\";\nrule: \"main\" {}\n".to_string(), - ), - ("lib.del".to_string(), "rule: \"lib\" {}\n".to_string()), - ]); - let original_main = read(&root.join("main.ostw")); - - // A clean overlay of both files compiles with no diagnostics, and the - // overlay text — not the disk content — is what parsed. - let overlay: BTreeMap = BTreeMap::from([ - ( - "main.ostw".to_string(), - "import \"lib.del\";\nrule: \"edited main\" {}\n".to_string(), - ), - ( - "lib.del".to_string(), - "rule: \"edited lib\" {}\n".to_string(), - ), - ]); - let outcome = - wright_ostw::compile_with_overlay(&original_main, Some("main.ostw"), &root, &overlay); - assert!( - outcome.error.is_none(), - "project must load: {:?}", - outcome.error - ); - assert!( - outcome.diagnostics.is_empty(), - "clean overlay edits compile: {:?}", - outcome.diagnostics - ); - let main = project_of(&outcome) - .files - .iter() - .find(|file| file.path == "main.ostw") - .expect("main.ostw in the registry"); - let edited_name = main - .cst - .as_ref() - .expect("main.ostw parsed") - .items - .iter() - .filter_map(|item| match item { - wright_ostw::cst::Item::Rule(rule) => rule.name.clone(), - _ => None, - }) - .next(); - assert_eq!( - edited_name.as_deref(), - Some("edited main"), - "the overlay main text parsed" - ); - - // A broken overlay edit refuses with a source-located error naming the - // overlaid file — the proposed edit was validated, never the disk file. - let broken: BTreeMap = - BTreeMap::from([("lib.del".to_string(), "rule: \"broken {}\n".to_string())]); - let outcome = - wright_ostw::compile_with_overlay(&original_main, Some("main.ostw"), &root, &broken); - assert!(outcome.error.is_none(), "project still loads"); - assert!( - !outcome.diagnostics.is_empty(), - "broken overlay edit yields diagnostics, got: {:?}", - outcome.diagnostics - ); - let parse = outcome - .diagnostics - .iter() - .find(|error| error.code == "ostw-parse-error" || error.code == "ostw-lex-error") - .expect("broken overlay edit yields a parse/lex error"); - let span = parse.span.expect("parse errors carry a span"); - let lib = project_of(&outcome) - .files - .iter() - .find(|file| file.path == "lib.del") - .expect("lib.del in the registry"); - assert_eq!( - span.file, - FileId::from_index(lib.id as usize), - "the error points at the overlaid lib.del, not the main file" - ); - - // The overlay main text takes precedence over the passed-in main text. - let overlay_main: BTreeMap = BTreeMap::from([( - "main.ostw".to_string(), - "import \"lib.del\";\nrule: \"overlaid\" {}\n".to_string(), - )]); - let outcome = wright_ostw::compile_with_overlay( - "rule: \"broken main {}\n", - Some("main.ostw"), - &root, - &overlay_main, - ); - assert!( - outcome.diagnostics.is_empty(), - "overlay main text wins over the passed-in main text: {:?}", - outcome.diagnostics - ); - - // The on-disk files were never rewritten. - assert_eq!( - read(&root.join("main.ostw")), - original_main, - "disk main.ostw unchanged" - ); - let _ = std::fs::remove_dir_all(&root); -} diff --git a/crates/wright-ostw/tests/reconstruct.rs b/crates/wright-ostw/tests/reconstruct.rs deleted file mode 100644 index af90a64..0000000 --- a/crates/wright-ostw/tests/reconstruct.rs +++ /dev/null @@ -1,2520 +0,0 @@ -//! WIR → OSTW reconstruction suite (#125). -//! -//! For every committed reconstruction fixture under -//! `compatibility/ostw/reconstruction/`, the full loop -//! -//! ```text -//! Workshop text → shared parser → WIR → reconstruct → OSTW text -//! → native wright-ostw frontend (generated ds.toml project root) → HIR -//! → shared lowering → WIR → shared Workshop emitter → Workshop text -//! ``` -//! -//! must hold: the reconstructed OSTW loads through the native frontend with -//! zero diagnostics, the reconstructed Workshop is semantically equivalent -//! to the original under the declared #119 normalization, and the -//! reconstructed Workshop text reparses and re-emits byte-identically -//! (round-trip fixed point). The normalization is applied identically to -//! both sides, exactly like the forward differential -//! (`crates/wright-ostw/tests/differential.rs`). -//! -//! The machine-readable report lands at -//! `target/wright-ostw-reconstruct-report.json` (the repo report pattern). - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use sha2::Digest as _; -use workshop_rs::wir::{self, Action, Event, Value}; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())) -} - -/// Parse Workshop text through the shared parser with the canonical -/// signature context (the same path the driver uses). -fn parse(catalog: &workshop_rs::catalog::Catalog, text: &str) -> wir::Program { - let manifest = - wright_opy::manifest::Manifest::builtin().expect("the OPY manifest is embedded and valid"); - let context = wright_core::signatures::ChainedExpectedDomain::new(manifest, catalog); - let program = workshop_rs::parser::parse_with_context( - text, - catalog, - &workshop_rs::catalog::Locale::new("en-US"), - &context, - ) - .unwrap_or_else(|error| panic!("fixture Workshop text must parse: {error}")); - program - .validate() - .expect("parsed programs validate structurally"); - program -} - -/// Load the reconstructed OSTW through the native frontend in a generated -/// project root (`ds.toml` + `main.ostw`). Returns the HIR. -fn compile_reconstructed_ostw(ostw_text: &str, test_name: &str) -> wright_ir::hir::Program { - let root = std::env::temp_dir().join(format!("wright-ostw-reconstruct-{test_name}")); - std::fs::create_dir_all(&root).expect("create project root"); - std::fs::write(root.join("ds.toml"), "entry_point=\"main.ostw\"\n").expect("write ds.toml"); - std::fs::write(root.join("main.ostw"), ostw_text).expect("write main.ostw"); - let (outcome, semantic) = - wright_ostw::compile_with_semantics(ostw_text, Some("main.ostw"), &root); - let _ = std::fs::remove_dir_all(&root); - assert!( - outcome.error.is_none(), - "reconstructed project must load: {:?}", - outcome.error - ); - assert!( - outcome.diagnostics.is_empty(), - "reconstructed project must parse cleanly: {:?}", - outcome.diagnostics - ); - assert!( - semantic.diagnostics.is_empty(), - "reconstructed OSTW must resolve cleanly: {:?}", - semantic.diagnostics - ); - semantic.hir.expect("HIR produced") -} - -fn fold(program: &mut wir::Program) { - use wright_transform::pipeline::Pass as _; - wright_transform::fold_constants::FoldConstants.run(program); -} - -/// Inline write-once per-call player variables (declared #119 contract; the -/// reference materializes void-function arguments this way). Applied -/// identically to both sides. -fn inline_write_once_player_vars(program: &mut wir::Program) { - for rule_index in 0..program.rules.len() { - let rule_id = wright_ir::ids::Id::from_index(rule_index); - let actions: Vec = program - .rules - .get(rule_id) - .map(|rule| rule.actions.clone()) - .unwrap_or_default(); - let mut writers: HashMap = HashMap::new(); - for (index, action) in actions.iter().enumerate() { - let Some(wir::Action::SetPlayerVariable { - player, - variable, - value, - .. - }) = program.actions.get(*action) - else { - continue; - }; - let is_event_player = matches!( - program.values.get(*player).map(|node| &node.value), - Some(wir::Value::EventPlayer) - ); - if !is_event_player { - continue; - } - let var_index = variable.index() as u32; - writers - .entry(var_index) - .and_modify(|slot| *slot = (usize::MAX, *value)) // written again - .or_insert((index, *value)); - } - let mut removed: Vec = Vec::new(); - let mut substitutions: HashMap = HashMap::new(); - for action in &actions { - let Some(Action::SetPlayerVariable { variable, .. }) = program.actions.get(*action) - else { - continue; - }; - if let Some((index, value)) = writers.get(&(variable.index() as u32)).copied() { - if index != usize::MAX { - removed.push(*action); - substitutions.insert(variable.index() as u32, value); - } - } - } - if substitutions.is_empty() { - continue; - } - let values_len = program.values.len(); - for index in 0..values_len { - let id = wright_ir::ids::Id::from_index(index); - let node = program - .values - .get(id) - .cloned() - .unwrap_or_else(|| workshop_rs::wir::ValueNode::new(wir::Value::Null, None)); - let replacement = match &node.value { - Value::PlayerVariable { player, variable } - if matches!( - program.values.get(*player).map(|n| &n.value), - Some(Value::EventPlayer) - ) => - { - substitutions.get(&(variable.index() as u32)).copied() - } - _ => None, - }; - let Some(replacement) = replacement else { - continue; - }; - let replacement = program - .values - .get(replacement) - .cloned() - .expect("replacement in range"); - program.values.get_mut(id).expect("id in range").value = replacement.value; - } - let rule_id = wright_ir::ids::Id::from_index(rule_index); - if let Some(rule) = program.rules.get_mut(rule_id) { - rule.actions.retain(|action| !removed.contains(action)); - } - } -} - -/// The declared foreach divergence: `For Player Variable(Event Player, v, …)` -/// loops normalize to the global form. Applied identically to both sides. -fn foreach_globalize(program: &mut wir::Program) { - let mut rewrites: HashMap = HashMap::new(); - fn global_for( - program: &mut wir::Program, - name: &str, - rewrites: &mut HashMap, - player_index: u32, - ) -> wir::GlobalVarId { - if let Some(global) = rewrites.get(&player_index) { - return *global; - } - let index = program.global_variables.len() as u32; - let id = program.global_variables.push(wir::WorkshopVariable { - name: name.to_string(), - index, - span: None, - name_span: None, - }); - rewrites.insert(player_index, id); - id - } - fn rewrite_value( - program: &mut wir::Program, - id: wir::ValueId, - rewrites: &HashMap, - ) { - let node = program - .values - .get(id) - .cloned() - .unwrap_or_else(|| workshop_rs::wir::ValueNode::new(wir::Value::Null, None)); - let children: Vec = match &node.value { - Value::Array(elements) => elements.clone(), - Value::Vector { x, y, z } => vec![*x, *y, *z], - Value::PlayerVariable { player, .. } => vec![*player], - Value::Call { args, .. } => args.clone(), - _ => Vec::new(), - }; - for child in children { - rewrite_value(program, child, rewrites); - } - if let Value::PlayerVariable { player, variable } = &node.value { - if matches!( - program.values.get(*player).map(|n| &n.value), - Some(Value::EventPlayer) - ) { - if let Some(global) = rewrites.get(&(variable.index() as u32)) { - program.values.get_mut(id).expect("id in range").value = - Value::GlobalVariable(*global); - } - } - } - } - fn rewrite_actions( - program: &mut wir::Program, - actions: &[wir::ActionId], - rewrites: &HashMap, - ) { - for action in actions { - let Some(node) = program.actions.get(*action).cloned() else { - continue; - }; - let children: Vec = match &node { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } - | Action::Debug { value, .. } - | Action::Print { message: value, .. } => vec![*value], - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => vec![*player, *value], - Action::AssignMember { target, value, .. } => vec![*target, *value], - Action::CallSubroutine { .. } => Vec::new(), - Action::If { - branches, - else_body, - .. - } => { - let mut out = Vec::new(); - for branch in branches { - out.push(branch.condition); - } - if let Some(else_body) = else_body { - for action in else_body { - rewrite_actions(program, &[*action], rewrites); - } - } - for branch in branches { - rewrite_actions(program, &branch.body, rewrites); - } - out - } - Action::While { - condition, body, .. - } => { - rewrite_actions(program, body, rewrites); - vec![*condition] - } - Action::ForGlobalVariable { - start, - stop, - step, - body, - .. - } - | Action::ForPlayerVariable { - start, - stop, - step, - body, - .. - } => { - rewrite_actions(program, body, rewrites); - vec![*start, *stop, *step] - } - Action::Call { args, .. } => args.clone(), - }; - for child in children { - rewrite_value(program, child, rewrites); - } - } - } - for rule_index in 0..program.rules.len() { - let rule_id = wright_ir::ids::Id::from_index(rule_index); - let player_loops: Vec<(wir::ActionId, u32, String)> = program - .rules - .get(rule_id) - .map(|rule| rule.actions.clone()) - .unwrap_or_default() - .iter() - .filter_map(|action| { - let Some(Action::ForPlayerVariable { variable, .. }) = program.actions.get(*action) - else { - return None; - }; - let name = program - .player_variables - .get(*variable) - .map(|v| v.name.clone()) - .unwrap_or_default(); - Some((*action, variable.index() as u32, name)) - }) - .collect(); - let mut local_rewrites = rewrites.clone(); - for (action, player_index, name) in player_loops { - let global = global_for(program, &name, &mut local_rewrites, player_index); - let loop_body = { - let Some(Action::ForPlayerVariable { body, .. }) = program.actions.get(action) - else { - continue; - }; - body.clone() - }; - rewrite_actions(program, &loop_body, &local_rewrites); - let (start, stop, step) = { - let Some(Action::ForPlayerVariable { - start, stop, step, .. - }) = program.actions.get(action) - else { - continue; - }; - (*start, *stop, *step) - }; - let span = program.actions.get(action).and_then(|action| action.span()); - *program.actions.get_mut(action).expect("action in range") = - Action::ForGlobalVariable { - variable: global, - start, - stop, - step, - body: loop_body, - span, - target_span: None, - }; - } - for (player_index, global) in local_rewrites { - rewrites.entry(player_index).or_insert(global); - } - } -} - -/// `Custom String` placeholder syntax is an output-form difference -/// (`<0>` ≡ `{0}`); normalize the text of format strings on both sides. -fn fold_placeholders(program: &mut wir::Program) { - let mut texts: Vec<(usize, String)> = Vec::new(); - for index in 0..program.values.len() { - let id = wright_ir::ids::Id::from_index(index); - let Some(node) = program.values.get(id) else { - continue; - }; - if let Value::Call { name, args } = &node.value { - if name == "customString" && !args.is_empty() { - let text_id = args[0]; - if let Some(Value::String(text)) = program.values.get(text_id).map(|n| &n.value) { - let normalized: String = text - .chars() - .map(|c| match c { - '<' => '{', - '>' => '}', - other => other, - }) - .collect(); - if normalized != *text { - texts.push((text_id.index(), normalized)); - } - } - } - } - } - for (index, text) in texts { - let id = wright_ir::ids::Id::from_index(index); - program.values.get_mut(id).expect("id in range").value = Value::String(text); - } -} - -/// The reference's null/unit vector output idioms (P6 evidence): -/// `Vector(0,0,0)` ≡ `Subtract(Left, Left)` and `Vector(1,0,0)` ≡ `Left`. -/// Applied identically to both sides. -fn vector_idioms(program: &mut wir::Program) { - let mut rewrites: Vec<(usize, wir::Value)> = Vec::new(); - for index in 0..program.values.len() { - let id = wright_ir::ids::Id::from_index(index); - let Some(node) = program.values.get(id) else { - continue; - }; - let Value::Call { name, args } = &node.value else { - continue; - }; - if name != "vector" || args.len() != 3 { - continue; - } - let components: Option<(f64, f64, f64)> = (|| { - Some(( - match &program.values.get(args[0])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - match &program.values.get(args[1])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - match &program.values.get(args[2])?.value { - Value::Number { value, .. } => *value, - _ => return None, - }, - )) - })(); - let Some((x, y, z)) = components else { - continue; - }; - let left = program.values.push(wir::ValueNode::new( - Value::Enum { - value_type: "HudPosition".to_string(), - value: "LEFT".to_string(), - }, - None, - )); - if x == 0.0 && y == 0.0 && z == 0.0 { - rewrites.push(( - index, - Value::Call { - name: "subtract".to_string(), - args: vec![left, left], - }, - )); - } else if x == 1.0 && y == 0.0 && z == 0.0 { - rewrites.push(( - index, - Value::Enum { - value_type: "HudPosition".to_string(), - value: "LEFT".to_string(), - }, - )); - } - } - for (index, value) in rewrites { - let id = wright_ir::ids::Id::from_index(index); - program.values.get_mut(id).expect("id in range").value = value; - } -} - -/// The declared #119 normalization, applied identically to both sides. -fn normalize(program: &mut wir::Program) { - fold(program); - inline_write_once_player_vars(program); - fold(program); - foreach_globalize(program); - vector_idioms(program); - fold_placeholders(program); -} - -// -- structural comparison (the declared #119 contract) --------------------- - -fn compare(actual: &wir::Program, expected: &wir::Program) -> Result<(), String> { - let rules_a = &actual.rules; - let rules_b = &expected.rules; - if rules_a.len() != rules_b.len() { - return Err(format!( - "rule count differs: actual {} vs reference {}", - rules_a.len(), - rules_b.len() - )); - } - for (index, (rule_a, rule_b)) in rules_a.iter().zip(rules_b.iter()).enumerate() { - let name_a = match rule_a.name.as_str() { - "Initialize global variables" => "Initial Global", - "Initialize player variables" => "Initial Player", - other => other, - }; - let name_b = match rule_b.name.as_str() { - "Initialize global variables" => "Initial Global", - "Initialize player variables" => "Initial Player", - other => other, - }; - if name_a != name_b { - return Err(format!( - "rule {index} name differs: '{}' vs '{}'", - rule_a.name, rule_b.name - )); - } - match (&rule_a.event, &rule_b.event) { - (Event::Global, Event::Global) - | (Event::EachPlayer, Event::EachPlayer) - | ( - Event::EachPlayer, - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - ) - | ( - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - Event::EachPlayer, - ) - | ( - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - Event::EachPlayerWithFilters { - team: workshop_rs::wir::EventTeam::All, - target: workshop_rs::wir::EventTarget::All, - }, - ) => {} - (Event::EachPlayerWithFilters { .. }, _) - | (Event::Player { .. }, _) - | (_, Event::EachPlayerWithFilters { .. }) - | (_, Event::Player { .. }) => { - return Err(format!("rule {index} uses an unsupported event")); - } - (Event::Subroutine(a), Event::Subroutine(b)) => { - let name_a = actual - .subroutines - .get(*a) - .map(|s| s.name.clone()) - .unwrap_or_default(); - let name_b = expected - .subroutines - .get(*b) - .map(|s| s.name.clone()) - .unwrap_or_default(); - if name_a != name_b { - return Err(format!( - "rule {index} subroutine differs: {name_a} vs {name_b}" - )); - } - } - (a, b) => { - return Err(format!("rule {index} event differs: {a:?} vs {b:?}")); - } - } - if rule_a.conditions.len() != rule_b.conditions.len() { - return Err(format!( - "rule {index} condition count differs: {} vs {}", - rule_a.conditions.len(), - rule_b.conditions.len() - )); - } - for (condition_a, condition_b) in rule_a.conditions.iter().zip(rule_b.conditions.iter()) { - compare_value(actual, expected, *condition_a, *condition_b) - .map_err(|message| format!("rule {index} condition: {message}"))?; - } - if rule_a.actions.len() != rule_b.actions.len() { - return Err(format!( - "rule {index} action count differs: {} vs {}", - rule_a.actions.len(), - rule_b.actions.len() - )); - } - for (action_a, action_b) in rule_a.actions.iter().zip(rule_b.actions.iter()) { - compare_action(actual, expected, *action_a, *action_b) - .map_err(|message| format!("rule {index}: {message}"))?; - } - } - Ok(()) -} - -fn compare_actions( - actual: &wir::Program, - expected: &wir::Program, - actions_a: &[wir::ActionId], - actions_b: &[wir::ActionId], -) -> Result<(), String> { - if actions_a.len() != actions_b.len() { - return Err(format!( - "action list length differs: {} vs {}", - actions_a.len(), - actions_b.len() - )); - } - for (action_a, action_b) in actions_a.iter().zip(actions_b.iter()) { - compare_action(actual, expected, *action_a, *action_b)?; - } - Ok(()) -} - -fn compare_action( - actual: &wir::Program, - expected: &wir::Program, - action_a: wir::ActionId, - action_b: wir::ActionId, -) -> Result<(), String> { - let (Some(a), Some(b)) = (actual.actions.get(action_a), expected.actions.get(action_b)) else { - return Err("dangling action".to_string()); - }; - match (a, b) { - ( - Action::SetGlobalVariable { - variable: va, - value: value_a, - .. - }, - Action::SetGlobalVariable { - variable: vb, - value: value_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "setGlobalVariable target differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::ModifyGlobalVariable { - variable: va, - op: op_a, - value: value_a, - .. - }, - Action::ModifyGlobalVariable { - variable: vb, - op: op_b, - value: value_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "modifyGlobalVariable target differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - if op_a != op_b { - return Err(format!("modify operator differs: {op_a:?} vs {op_b:?}")); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::SetPlayerVariable { - player: player_a, - variable: va, - value: value_a, - .. - }, - Action::SetPlayerVariable { - player: player_b, - variable: vb, - value: value_b, - .. - }, - ) => { - compare_value(actual, expected, *player_a, *player_b)?; - if player_name(actual, *va) != player_name(expected, *vb) { - return Err(format!( - "setPlayerVariable target differs: {} vs {}", - player_name(actual, *va), - player_name(expected, *vb) - )); - } - compare_value(actual, expected, *value_a, *value_b) - } - ( - Action::ModifyPlayerVariable { - player: player_a, - variable: va, - op: op_a, - value: value_a, - .. - }, - Action::ModifyPlayerVariable { - player: player_b, - variable: vb, - op: op_b, - value: value_b, - .. - }, - ) => { - compare_value(actual, expected, *player_a, *player_b)?; - if player_name(actual, *va) != player_name(expected, *vb) { - return Err(format!( - "modifyPlayerVariable target differs: {} vs {}", - player_name(actual, *va), - player_name(expected, *vb) - )); - } - if op_a != op_b { - return Err(format!("modify operator differs: {op_a:?} vs {op_b:?}")); - } - compare_value(actual, expected, *value_a, *value_b) - } - (Action::CallSubroutine { .. }, Action::CallSubroutine { .. }) => Ok(()), - ( - Action::If { - branches: branches_a, - else_body: else_a, - .. - }, - Action::If { - branches: branches_b, - else_body: else_b, - .. - }, - ) => { - if branches_a.len() != branches_b.len() { - return Err(format!( - "if branch count differs: {} vs {}", - branches_a.len(), - branches_b.len() - )); - } - for (branch_a, branch_b) in branches_a.iter().zip(branches_b.iter()) { - compare_value(actual, expected, branch_a.condition, branch_b.condition)?; - compare_actions(actual, expected, &branch_a.body, &branch_b.body)?; - } - match (else_a, else_b) { - (None, None) => Ok(()), - (Some(body_a), Some(body_b)) => compare_actions(actual, expected, body_a, body_b), - (Some(_), None) => Err("actual has an else body, reference does not".to_string()), - (None, Some(_)) => Err("reference has an else body, actual does not".to_string()), - } - } - ( - Action::While { - condition: condition_a, - body: body_a, - .. - }, - Action::While { - condition: condition_b, - body: body_b, - .. - }, - ) => { - compare_value(actual, expected, *condition_a, *condition_b)?; - compare_actions(actual, expected, body_a, body_b) - } - ( - Action::ForGlobalVariable { - variable: va, - start: start_a, - stop: stop_a, - step: step_a, - body: body_a, - .. - }, - Action::ForGlobalVariable { - variable: vb, - start: start_b, - stop: stop_b, - step: step_b, - body: body_b, - .. - }, - ) => { - if global_name(actual, *va) != global_name(expected, *vb) { - return Err(format!( - "for loop variable differs: {} vs {}", - global_name(actual, *va), - global_name(expected, *vb) - )); - } - compare_value(actual, expected, *start_a, *start_b)?; - compare_value(actual, expected, *stop_a, *stop_b)?; - compare_value(actual, expected, *step_a, *step_b)?; - compare_actions(actual, expected, body_a, body_b) - } - (Action::Debug { .. }, Action::Debug { .. }) => { - Err("debug actions are rejected on the reconstruction surface".to_string()) - } - (Action::Print { .. }, Action::Print { .. }) => { - Err("print actions are rejected on the reconstruction surface".to_string()) - } - ( - Action::Call { - name: name_a, - args: args_a, - .. - }, - Action::Call { - name: name_b, - args: args_b, - .. - }, - ) => { - if name_a != name_b { - return Err(format!("call name differs: '{name_a}' vs '{name_b}'")); - } - if args_a.len() != args_b.len() { - return Err(format!( - "call '{name_a}' arity differs: {} vs {}", - args_a.len(), - args_b.len() - )); - } - for (value_a, value_b) in args_a.iter().zip(args_b.iter()) { - compare_value(actual, expected, *value_a, *value_b)?; - } - Ok(()) - } - (a, b) => Err(format!( - "action kind differs: {} vs {}", - action_kind(a), - action_kind(b) - )), - } -} - -fn compare_value( - actual: &wir::Program, - expected: &wir::Program, - value_a: wir::ValueId, - value_b: wir::ValueId, -) -> Result<(), String> { - let (Some(node_a), Some(node_b)) = (actual.values.get(value_a), expected.values.get(value_b)) - else { - return Err("dangling value".to_string()); - }; - match (&node_a.value, &node_b.value) { - (Value::Number { value: x, .. }, Value::Number { value: y, .. }) => { - if x != y { - return Err(format!("number differs: {x} vs {y}")); - } - Ok(()) - } - (Value::String(x), Value::String(y)) => { - if x != y { - return Err(format!("string differs: '{x}' vs '{y}'")); - } - Ok(()) - } - (Value::Bool(x), Value::Bool(y)) => { - if x != y { - return Err(format!("bool differs: {x} vs {y}")); - } - Ok(()) - } - (Value::Null, Value::Null) => Ok(()), - (Value::Array(x), Value::Array(y)) => { - if x.len() != y.len() { - return Err(format!("array length differs: {} vs {}", x.len(), y.len())); - } - for (a, b) in x.iter().zip(y.iter()) { - compare_value(actual, expected, *a, *b)?; - } - Ok(()) - } - ( - Value::Vector { - x: x1, - y: y1, - z: z1, - }, - Value::Vector { - x: x2, - y: y2, - z: z2, - }, - ) => { - compare_value(actual, expected, *x1, *x2)?; - compare_value(actual, expected, *y1, *y2)?; - compare_value(actual, expected, *z1, *z2) - } - ( - Value::Enum { - value_type: t1, - value: v1, - }, - Value::Enum { - value_type: t2, - value: v2, - }, - ) => { - let team_equivalent = matches!( - (t1.as_str(), t2.as_str()), - ("Color", "Team") | ("Team", "Color") - ) && v1 == v2; - if (t1 != t2 || v1 != v2) && !team_equivalent { - return Err(format!("enum differs: {t1}.{v1} vs {t2}.{v2}")); - } - Ok(()) - } - (Value::GlobalVariable(x), Value::GlobalVariable(y)) => { - if global_name(actual, *x) != global_name(expected, *y) { - return Err(format!( - "global differs: {} vs {}", - global_name(actual, *x), - global_name(expected, *y) - )); - } - Ok(()) - } - ( - Value::PlayerVariable { - player: p1, - variable: v1, - }, - Value::PlayerVariable { - player: p2, - variable: v2, - }, - ) => { - compare_value(actual, expected, *p1, *p2)?; - if player_name(actual, *v1) != player_name(expected, *v2) { - return Err(format!( - "player variable differs: {} vs {}", - player_name(actual, *v1), - player_name(expected, *v2) - )); - } - Ok(()) - } - (Value::EventPlayer, Value::EventPlayer) => Ok(()), - ( - Value::Call { - name: name_a, - args: args_a, - }, - Value::Call { - name: name_b, - args: args_b, - }, - ) => { - if name_a != name_b { - return Err(format!("value call differs: '{name_a}' vs '{name_b}'")); - } - if args_a.len() != args_b.len() { - return Err(format!( - "value call '{name_a}' arity differs: {} vs {}", - args_a.len(), - args_b.len() - )); - } - for (a, b) in args_a.iter().zip(args_b.iter()) { - compare_value(actual, expected, *a, *b)?; - } - Ok(()) - } - (a, b) => Err(format!( - "value kind differs: {} vs {}", - value_kind(a), - value_kind(b) - )), - } -} - -fn global_name(program: &wir::Program, id: wir::GlobalVarId) -> String { - program - .global_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() -} - -fn player_name(program: &wir::Program, id: wir::PlayerVarId) -> String { - program - .player_variables - .get(id) - .map(|variable| variable.name.clone()) - .unwrap_or_default() -} - -fn action_kind(action: &Action) -> &'static str { - match action { - Action::SetGlobalVariable { .. } => "setGlobalVariable", - Action::ModifyGlobalVariable { .. } => "modifyGlobalVariable", - Action::SetPlayerVariable { .. } => "setPlayerVariable", - Action::ModifyPlayerVariable { .. } => "modifyPlayerVariable", - Action::AssignMember { .. } => "assignMember", - Action::CallSubroutine { .. } => "callSubroutine", - Action::If { .. } => "if", - Action::While { .. } => "while", - Action::ForGlobalVariable { .. } => "forGlobalVariable", - Action::ForPlayerVariable { .. } => "forPlayerVariable", - Action::Debug { .. } => "debug", - Action::Print { .. } => "print", - Action::Call { .. } => "call", - } -} - -fn value_kind(value: &Value) -> &'static str { - match value { - Value::Number { .. } => "number", - Value::String(_) => "string", - Value::LocalizedString(_) => "localizedString", - Value::Bool(_) => "bool", - Value::Null => "null", - Value::Array(_) => "array", - Value::Vector { .. } => "vector", - Value::Enum { .. } => "enum", - Value::GlobalVariable(_) => "global", - Value::PlayerVariable { .. } => "playerVariable", - Value::Subroutine(_) => "subroutine", - Value::EventPlayer => "eventPlayer", - Value::Call { .. } => "call", - } -} - -// -- fixtures --------------------------------------------------------------- - -const RECONSTRUCTION_DIR: &str = "compatibility/ostw/reconstruction"; - -const POSITIVE_FIXTURES: &[&str] = &["surface-basic", "surface-actions", "surface-values"]; - -const SYNTAX_VALUE_IDS: &[&str] = &[ - "==", - "!=", - "<", - "<=", - ">", - ">=", - "add", - "and", - "array", - "customString", - "divide", - "eventPlayer", - "ifThenElse", - "multiply", - "not", - "or", - "subtract", - "valueInArray", - "vector", -]; - -fn fixture_dir(name: &str) -> PathBuf { - workspace_root().join(RECONSTRUCTION_DIR).join(name) -} - -/// The full loop for one positive fixture. -fn run_full_loop(catalog: &workshop_rs::catalog::Catalog, name: &str) -> serde_json::Value { - let dir = fixture_dir(name); - let fixture_text = read(&dir.join("workshop.txt")); - - // Workshop → WIR (the shared parser, the driver path). - let original = parse(catalog, &fixture_text); - - // WIR → OSTW (the shipped reconstruction API). - let ostw = wright_ostw::reconstruct::reconstruct(&original, catalog).unwrap_or_else(|errors| { - panic!( - "{name}: fixture WIR must reconstruct: {}", - errors - .iter() - .map(|error| error.to_string()) - .collect::>() - .join("; ") - ) - }); - - // OSTW → native frontend → HIR → WIR (zero diagnostics). - let hir = compile_reconstructed_ostw(&ostw, name); - let reconstructed = wright_ir::lower::lower(&hir).expect("lowering succeeds"); - reconstructed.validate().expect("lowered program validates"); - - // WIR → Workshop text (the shared emitter). - let emitted = workshop_rs::emitter::emit( - &reconstructed, - catalog, - &workshop_rs::catalog::Locale::new("en-US"), - ) - .expect("reconstructed Workshop emission succeeds"); - - // The reconstructed Workshop text reparses and re-emits byte-identically - // (round-trip fixed point). - let reparsed = parse(catalog, &emitted); - let reemitted = workshop_rs::emitter::emit( - &reparsed, - catalog, - &workshop_rs::catalog::Locale::new("en-US"), - ) - .expect("re-emission succeeds"); - assert_eq!( - emitted, reemitted, - "{name}: reconstructed Workshop must reach the round-trip fixed point" - ); - - // Semantic equivalence under the declared #119 normalization. - let mut actual = reparsed; - let mut reference = original; - normalize(&mut actual); - normalize(&mut reference); - compare(&actual, &reference) - .unwrap_or_else(|message| panic!("{name}: semantic divergence: {message}")); - - let mut hasher = ::new(); - sha2::Digest::update(&mut hasher, ostw.as_bytes()); - let ostw_sha256 = format!("{:x}", hasher.finalize()); - let mut hasher = ::new(); - sha2::Digest::update(&mut hasher, emitted.as_bytes()); - let workshop_sha256 = format!("{:x}", hasher.finalize()); - - serde_json::json!({ - "status": "round-trip", - "ostwSha256": ostw_sha256, - "workshopSha256": workshop_sha256, - "roundTrip": "fixed-point", - "frontend": "wright/ostw-native zero diagnostics", - }) -} - -#[test] -fn reconstruction_full_loop_holds_for_positive_fixtures() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let mut report = serde_json::Map::new(); - for name in POSITIVE_FIXTURES { - report.insert(name.to_string(), run_full_loop(&catalog, name)); - } - report.insert( - "suite".to_string(), - serde_json::json!({ - "name": "wright-ostw-reconstruct", - "fixtures": POSITIVE_FIXTURES.len(), - }), - ); - let report_path = workspace_root().join("target/wright-ostw-reconstruct-report.json"); - let parent = report_path.parent().expect("target dir"); - std::fs::create_dir_all(parent).expect("create target dir"); - std::fs::write( - &report_path, - serde_json::to_string_pretty(&serde_json::Value::Object(report)) - .expect("report serializes"), - ) - .expect("write reconstruction report"); -} - -// -- deterministic emission (unit-level, drives the shipped API) ------------ - -/// Build a minimal WIR program exercising every declared-surface construct, -/// used by the per-construct emission assertions. -fn surface_program(catalog: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let counter = program.global_variables.push(wir::WorkshopVariable { - name: "counter".to_string(), - index: 1, - span: None, - name_span: None, - }); - let arr = program.global_variables.push(wir::WorkshopVariable { - name: "arr".to_string(), - index: 2, - span: None, - name_span: None, - }); - let health = program.player_variables.push(wir::WorkshopVariable { - name: "health".to_string(), - index: 0, - span: None, - name_span: None, - }); - let sub = program.subroutines.push(wir::WorkshopSubroutine { - name: "bump".to_string(), - index: 0, - span: None, - name_span: None, - }); - - let number = |program: &mut wir::Program, value: f64| -> wir::ValueId { - program.values.push(wir::ValueNode::new( - wir::Value::Number { - value, - text: value.to_string(), - }, - None, - )) - }; - let string = |program: &mut wir::Program, text: &str| -> wir::ValueId { - program.values.push(wir::ValueNode::new( - wir::Value::String(text.to_string()), - None, - )) - }; - let global = |program: &mut wir::Program, id: wir::GlobalVarId| -> wir::ValueId { - program - .values - .push(wir::ValueNode::new(wir::Value::GlobalVariable(id), None)) - }; - let call = |program: &mut wir::Program, name: &str, args: Vec| -> wir::ValueId { - program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: name.to_string(), - args, - }, - None, - )) - }; - - // A subroutine rule: counter += 1; if (counter == 10) { counter = 0; } - let one = number(&mut program, 1.0); - let counter_read = global(&mut program, counter); - let add = call(&mut program, "add", vec![counter_read, one]); - let mut sub_actions = vec![program.actions.push(wir::Action::ModifyGlobalVariable { - variable: counter, - op: wir::ModifyOp::Add, - value: add, - span: None, - target_span: None, - })]; - // if (counter == 10) { counter = 0; } inside the subroutine body. - let ten = number(&mut program, 10.0); - let counter_read = global(&mut program, counter); - let condition = call(&mut program, "==", vec![counter_read, ten]); - let zero = number(&mut program, 0.0); - let set_zero = program.actions.push(wir::Action::SetGlobalVariable { - variable: counter, - value: zero, - span: None, - target_span: None, - }); - let if_action = program.actions.push(wir::Action::If { - branches: vec![wir::IfBranch { - condition, - body: vec![set_zero], - }], - else_body: None, - span: None, - }); - sub_actions.push(if_action); - program.rules.push(wir::Rule { - name: "Subroutine bump".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Subroutine(sub), - conditions: Vec::new(), - actions: sub_actions, - }); - - // The main rule: set/modify/if/while/for + calls + values. - let mut actions = Vec::new(); - // g = Add(counter, 5); g += 1; arr.append(7); health = 1; health += 1; - let five = number(&mut program, 5.0); - let counter_read = global(&mut program, counter); - let add = call(&mut program, "add", vec![counter_read, five]); - actions.push(program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value: add, - span: None, - target_span: None, - })); - let one = number(&mut program, 1.0); - actions.push(program.actions.push(wir::Action::ModifyGlobalVariable { - variable: g, - op: wir::ModifyOp::Add, - value: one, - span: None, - target_span: None, - })); - let seven = number(&mut program, 7.0); - actions.push(program.actions.push(wir::Action::ModifyGlobalVariable { - variable: arr, - op: wir::ModifyOp::AppendToArray, - value: seven, - span: None, - target_span: None, - })); - let event_player = program - .values - .push(wir::ValueNode::new(wir::Value::EventPlayer, None)); - let one = number(&mut program, 1.0); - actions.push(program.actions.push(wir::Action::SetPlayerVariable { - player: event_player, - variable: health, - value: one, - span: None, - target_span: None, - })); - let event_player = program - .values - .push(wir::ValueNode::new(wir::Value::EventPlayer, None)); - let one = number(&mut program, 1.0); - actions.push(program.actions.push(wir::Action::ModifyPlayerVariable { - player: event_player, - variable: health, - op: wir::ModifyOp::Add, - value: one, - span: None, - target_span: None, - })); - // bump(); - actions.push(program.actions.push(wir::Action::CallSubroutine { - subroutine: sub, - span: None, - callee_span: None, - })); - // BigMessage(AllPlayers(Team.ALL), <"<0>", g>); - let team_all = program.values.push(wir::ValueNode::new( - wir::Value::Enum { - value_type: "Team".to_string(), - value: "ALL".to_string(), - }, - None, - )); - let all_players = call(&mut program, "allPlayers", vec![team_all]); - let text = string(&mut program, "<0>"); - let g_read = global(&mut program, g); - let format = call(&mut program, "customString", vec![text, g_read]); - actions.push(program.actions.push(wir::Action::Call { - name: "bigMessage".to_string(), - args: vec![all_players, format], - span: None, - })); - // if (g > 10) { g = 0; } else if (g > 5) { g = 1; } else { g = 2; } - let ten = number(&mut program, 10.0); - let g_read = global(&mut program, g); - let cond_a = call(&mut program, ">", vec![g_read, ten]); - let zero = number(&mut program, 0.0); - let set_a = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value: zero, - span: None, - target_span: None, - }); - let five = number(&mut program, 5.0); - let g_read = global(&mut program, g); - let cond_b = call(&mut program, ">", vec![g_read, five]); - let one = number(&mut program, 1.0); - let set_b = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value: one, - span: None, - target_span: None, - }); - let two = number(&mut program, 2.0); - let set_c = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value: two, - span: None, - target_span: None, - }); - actions.push(program.actions.push(wir::Action::If { - branches: vec![ - wir::IfBranch { - condition: cond_a, - body: vec![set_a], - }, - wir::IfBranch { - condition: cond_b, - body: vec![set_b], - }, - ], - else_body: Some(vec![set_c]), - span: None, - })); - // while (counter < 3) { counter += 1; } - let three = number(&mut program, 3.0); - let counter_read = global(&mut program, counter); - let while_cond = call(&mut program, "<", vec![counter_read, three]); - let one = number(&mut program, 1.0); - let bump_counter = program.actions.push(wir::Action::ModifyGlobalVariable { - variable: counter, - op: wir::ModifyOp::Add, - value: one, - span: None, - target_span: None, - }); - actions.push(program.actions.push(wir::Action::While { - condition: while_cond, - body: vec![bump_counter], - span: None, - })); - // for (counter = 0; counter < 3; 1) { g += 1; } - let zero = number(&mut program, 0.0); - let three = number(&mut program, 3.0); - let counter_read = global(&mut program, counter); - let stop = call(&mut program, "<", vec![counter_read, three]); - let one = number(&mut program, 1.0); - let one_more = number(&mut program, 1.0); - let bump_g = program.actions.push(wir::Action::ModifyGlobalVariable { - variable: g, - op: wir::ModifyOp::Add, - value: one_more, - span: None, - target_span: None, - }); - actions.push(program.actions.push(wir::Action::ForGlobalVariable { - variable: counter, - start: zero, - stop, - step: one, - body: vec![bump_g], - span: None, - target_span: None, - })); - // return; - actions.push(program.actions.push(wir::Action::Call { - name: "abort".to_string(), - args: Vec::new(), - span: None, - })); - - program.rules.push(wir::Rule { - name: "Main".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions, - }); - let _ = catalog; - program -} - -#[test] -fn emission_covers_every_declared_construct() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let program = surface_program(&catalog); - program.validate().expect("synthetic program validates"); - let text = wright_ostw::reconstruct::reconstruct(&program, &catalog) - .expect("the declared surface must reconstruct"); - for expected in [ - "globalvar Any g;", - "globalvar Any counter;", - "globalvar Any arr;", - "playervar Any health;", - "void bump() \"Subroutine bump\" {", - "counter += 1;", - "if (counter == 10) {", - "counter = 0;", - "g = counter + 5;", - "g += 1;", - "arr.append(7);", - "health = 1;", - "health += 1;", - "bump();", - "BigMessage(AllPlayers(Team.All), <\"<0>\", g>);", - "if (g > 10) {", - "else if (g > 5) {", - "else {", - "g = 2;", - "while (counter < 3) {", - "for (counter = 0; counter < 3; 1) {", - "return;", - "rule: \"Main\" {", - ] { - assert!( - text.contains(expected), - "reconstructed OSTW must contain {expected:?}:\n{text}" - ); - } -} - -#[test] -fn emission_is_byte_identical_across_runs() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let program = surface_program(&catalog); - program.validate().expect("synthetic program validates"); - let first = wright_ostw::reconstruct::reconstruct(&program, &catalog).expect("reconstructs"); - let second = wright_ostw::reconstruct::reconstruct(&program, &catalog).expect("reconstructs"); - assert_eq!(first, second, "identical WIR must emit byte-identical OSTW"); - // Re-running on the fixture WIRs is also byte-identical (determinism over - // the committed fixtures). - for name in POSITIVE_FIXTURES { - let dir = fixture_dir(name); - let fixture = parse(&catalog, &read(&dir.join("workshop.txt"))); - let a = wright_ostw::reconstruct::reconstruct(&fixture, &catalog).expect("reconstructs"); - let b = wright_ostw::reconstruct::reconstruct(&fixture, &catalog).expect("reconstructs"); - assert_eq!(a, b, "{name}: reconstruction must be deterministic"); - } -} - -// -- structured rejections (synthetic WIR per declared boundary) ------------ - -type ProgramBuilder = fn(&workshop_rs::catalog::Catalog) -> wir::Program; - -fn program_with_for_player_variable(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let p = program.player_variables.push(wir::WorkshopVariable { - name: "p".to_string(), - index: 0, - span: None, - name_span: None, - }); - let event_player = program - .values - .push(wir::ValueNode::new(wir::Value::EventPlayer, None)); - let zero = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 0.0, - text: "0".to_string(), - }, - None, - )); - let five = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 5.0, - text: "5".to_string(), - }, - None, - )); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::ForPlayerVariable { - player: event_player, - variable: p, - start: zero, - stop: five, - step: one, - body: Vec::new(), - span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_debug(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let action = program - .actions - .push(wir::Action::Debug { value, span: None }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_print(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let message = program.values.push(wir::ValueNode::new( - wir::Value::String("x".to_string()), - None, - )); - let action = program.actions.push(wir::Action::Print { - message, - span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_settings(_: &workshop_rs::catalog::Catalog) -> wir::Program { - wir::Program { - settings: Some(workshop_rs::settings::Settings { - span: None, - children: Vec::new(), - }), - ..wir::Program::default() - } -} - -fn program_with_unbound_action(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let action = program.actions.push(wir::Action::Call { - name: "createBeamEffect".to_string(), - args: Vec::new(), - span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_unbound_value(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: "getHealth".to_string(), - args: Vec::new(), - }, - None, - )); - let action = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_unbound_enum(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Enum { - value_type: "SomeDomain".to_string(), - value: "X".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_raise_to_power(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::ModifyGlobalVariable { - variable: g, - op: wir::ModifyOp::RaiseToPower, - value: one, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_remove_from_array(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::ModifyGlobalVariable { - variable: g, - op: wir::ModifyOp::RemoveFromArray, - value: one, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_non_comparison_condition(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let condition = program - .values - .push(wir::ValueNode::new(wir::Value::Bool(true), None)); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: vec![condition], - actions: Vec::new(), - }); - program -} - -fn program_with_partial_arity(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let event_player = program - .values - .push(wir::ValueNode::new(wir::Value::EventPlayer, None)); - let action = program.actions.push(wir::Action::Call { - name: "bigMessage".to_string(), - args: vec![event_player], // 1 of 2 canonical arguments - span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_name_collision(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - program.player_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - program -} - -fn program_with_empty_name(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - program.global_variables.push(wir::WorkshopVariable { - name: String::new(), - index: 0, - span: None, - name_span: None, - }); - program -} - -fn program_with_bodiless_subroutine(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - program.subroutines.push(wir::WorkshopSubroutine { - name: "sub".to_string(), - index: 0, - span: None, - name_span: None, - }); - program -} - -fn program_with_player_modify_receiver(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let p = program.player_variables.push(wir::WorkshopVariable { - name: "p".to_string(), - index: 0, - span: None, - name_span: None, - }); - let team_all = program.values.push(wir::ValueNode::new( - wir::Value::Enum { - value_type: "Team".to_string(), - value: "ALL".to_string(), - }, - None, - )); - let all_players = program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: "allPlayers".to_string(), - args: vec![team_all], - }, - None, - )); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::ModifyPlayerVariable { - player: all_players, - variable: p, - op: wir::ModifyOp::Add, - value: one, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_non_literal_format_text(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: "customString".to_string(), - args: vec![one], - }, - None, - )); - let action = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_strict_greater_in_format(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let text = program.values.push(wir::ValueNode::new( - wir::Value::String("<0>".to_string()), - None, - )); - let one = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 1.0, - text: "1".to_string(), - }, - None, - )); - let two = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: 2.0, - text: "2".to_string(), - }, - None, - )); - let g_read = program - .values - .push(wir::ValueNode::new(wir::Value::GlobalVariable(g), None)); - let greater = program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: ">".to_string(), - args: vec![one, two], - }, - None, - )); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Call { - name: "customString".to_string(), - args: vec![text, g_read, greater], - }, - None, - )); - let action = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -fn program_with_invalid_number(_: &workshop_rs::catalog::Catalog) -> wir::Program { - let mut program = wir::Program::default(); - let g = program.global_variables.push(wir::WorkshopVariable { - name: "g".to_string(), - index: 0, - span: None, - name_span: None, - }); - let value = program.values.push(wir::ValueNode::new( - wir::Value::Number { - value: -5.0, - text: "-5".to_string(), - }, - None, - )); - let action = program.actions.push(wir::Action::SetGlobalVariable { - variable: g, - value, - span: None, - target_span: None, - }); - program.rules.push(wir::Rule { - name: "R".to_string(), - span: None, - name_span: None, - disabled: false, - event: wir::Event::Global, - conditions: Vec::new(), - actions: vec![action], - }); - program -} - -/// Every rejection case the declared boundary names: (manifest kind, expected -/// code, program builder). The boundary-manifest conformance test requires -/// the manifest's rejected set to be exactly this table. -fn rejection_cases() -> Vec<(&'static str, &'static str, ProgramBuilder)> { - vec![ - ( - "forPlayerVariable", - "reconstruct-unsupported-action", - program_with_for_player_variable, - ), - ( - "debug", - "reconstruct-unsupported-action", - program_with_debug, - ), - ( - "print", - "reconstruct-unsupported-action", - program_with_print, - ), - ( - "settings", - "reconstruct-unsupported-program-settings", - program_with_settings, - ), - ( - "unboundAction", - "reconstruct-unbound-call", - program_with_unbound_action, - ), - ( - "unboundValue", - "reconstruct-unbound-call", - program_with_unbound_value, - ), - ( - "unboundEnum", - "reconstruct-unbound-enum", - program_with_unbound_enum, - ), - ( - "modifyOp:RaiseToPower", - "reconstruct-unsupported-modify-op", - program_with_raise_to_power, - ), - ( - "modifyOp:RemoveFromArray", - "reconstruct-unsupported-modify-op", - program_with_remove_from_array, - ), - ( - "condition", - "reconstruct-unsupported-condition", - program_with_non_comparison_condition, - ), - ("arity", "reconstruct-arity", program_with_partial_arity), - ( - "nameCollision", - "reconstruct-name-collision", - program_with_name_collision, - ), - ( - "emptyName", - "reconstruct-name-collision", - program_with_empty_name, - ), - ( - "subroutine", - "reconstruct-unsupported-subroutine", - program_with_bodiless_subroutine, - ), - ( - "playerModifyReceiver", - "reconstruct-unsupported-player-receiver", - program_with_player_modify_receiver, - ), - ( - "formatText", - "reconstruct-unsupported-format-text", - program_with_non_literal_format_text, - ), - ( - "formatArg", - "reconstruct-unsupported-format-arg", - program_with_strict_greater_in_format, - ), - ( - "number", - "reconstruct-unsupported-number", - program_with_invalid_number, - ), - ] -} - -#[test] -fn every_declared_rejection_is_structured_and_total() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - for (kind, code, builder) in rejection_cases() { - let program = builder(&catalog); - program - .validate() - .expect("synthetic rejection program validates"); - let result = wright_ostw::reconstruct::reconstruct(&program, &catalog); - let errors = result.expect_err(&format!("{kind} must be rejected")); - assert!( - errors.iter().any(|error| error.code == code), - "{kind}: expected code {code}, got: {:?}", - errors - ); - for error in &errors { - assert!( - !error.code.is_empty() && !error.kind.is_empty(), - "{kind}: every rejection must carry a stable code and kind" - ); - } - // Total rejection: no partial output is ever produced. - assert!( - !errors.is_empty(), - "{kind}: a rejection must produce at least one diagnostic" - ); - } -} - -#[test] -fn rejection_never_produces_partial_output() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - // The committed for-player-variable rejection fixture. - let dir = fixture_dir("reject/for-player-variable"); - let fixture = parse(&catalog, &read(&dir.join("workshop.txt"))); - let result = wright_ostw::reconstruct::reconstruct(&fixture, &catalog); - let errors = result.expect_err("the fixture must be rejected"); - assert!( - errors - .iter() - .any(|error| error.code == "reconstruct-unsupported-action" - && error.kind == "forPlayerVariable"), - "for-player-variable fixture: {:?}", - errors - ); -} - -// -- machine-readable boundary manifest conformance -------------------------- - -/// Walk one program and collect the exercised construct kinds: catalog call -/// ids (action/value, excluding the special-syntax ids), enum members, -/// modify ops, and structural kinds. -fn collect_coverage(program: &wir::Program) -> serde_json::Value { - fn walk_value( - program: &wir::Program, - id: wir::ValueId, - values: &mut Vec, - enums: &mut Vec, - ) { - let Some(node) = program.values.get(id) else { - return; - }; - match &node.value { - Value::Call { name, args } => { - if !SYNTAX_VALUE_IDS.contains(&name.as_str()) { - values.push(name.clone()); - } - for arg in args { - walk_value(program, *arg, values, enums); - } - } - Value::Array(elements) => { - for element in elements { - walk_value(program, *element, values, enums); - } - } - Value::Vector { x, y, z } => { - walk_value(program, *x, values, enums); - walk_value(program, *y, values, enums); - walk_value(program, *z, values, enums); - } - Value::PlayerVariable { player, .. } => walk_value(program, *player, values, enums), - Value::Enum { value_type, value } => { - enums.push(format!("{value_type}.{value}")); - } - _ => {} - } - } - let mut values = Vec::new(); - let mut actions = Vec::new(); - let mut enums = Vec::new(); - let mut modify_ops = Vec::new(); - let mut action_kinds = Vec::new(); - for rule in program.rules.iter() { - for action in &rule.actions { - let Some(node) = program.actions.get(*action) else { - continue; - }; - match node { - Action::Call { name, args, .. } => { - if name == "abort" { - action_kinds.push("return".to_string()); - } else { - action_kinds.push("call".to_string()); - if !SYNTAX_VALUE_IDS.contains(&name.as_str()) { - actions.push(name.clone()); - } - } - for arg in args { - walk_value(program, *arg, &mut values, &mut enums); - } - } - Action::SetGlobalVariable { value, .. } => { - action_kinds.push("setGlobalVariable".to_string()); - walk_value(program, *value, &mut values, &mut enums); - } - Action::ModifyGlobalVariable { op, value, .. } => { - action_kinds.push("modifyGlobalVariable".to_string()); - modify_ops.push(op.as_str().to_string()); - walk_value(program, *value, &mut values, &mut enums); - } - Action::SetPlayerVariable { player, value, .. } => { - action_kinds.push("setPlayerVariable".to_string()); - walk_value(program, *player, &mut values, &mut enums); - walk_value(program, *value, &mut values, &mut enums); - } - Action::ModifyPlayerVariable { - player, op, value, .. - } => { - action_kinds.push("modifyPlayerVariable".to_string()); - modify_ops.push(op.as_str().to_string()); - walk_value(program, *player, &mut values, &mut enums); - walk_value(program, *value, &mut values, &mut enums); - } - Action::CallSubroutine { .. } => { - action_kinds.push("callSubroutine".to_string()); - } - Action::If { branches, .. } => { - action_kinds.push("if".to_string()); - for branch in branches { - walk_value(program, branch.condition, &mut values, &mut enums); - } - } - Action::While { condition, .. } => { - action_kinds.push("while".to_string()); - walk_value(program, *condition, &mut values, &mut enums); - } - Action::ForGlobalVariable { - start, stop, step, .. - } => { - action_kinds.push("forGlobalVariable".to_string()); - walk_value(program, *start, &mut values, &mut enums); - walk_value(program, *stop, &mut values, &mut enums); - walk_value(program, *step, &mut values, &mut enums); - } - _ => {} - } - } - } - fn sorted_unique(mut items: Vec) -> Vec { - items.sort(); - items.dedup(); - items - } - serde_json::json!({ - "values": sorted_unique(values), - "actions": sorted_unique(actions), - "enums": sorted_unique(enums), - "modifyOps": sorted_unique(modify_ops), - "actionKinds": sorted_unique(action_kinds), - }) -} - -fn manifest() -> serde_json::Value { - let path = workspace_root() - .join(RECONSTRUCTION_DIR) - .join("support-boundary.json"); - serde_json::from_str(&read(&path)).expect("manifest parses") -} - -#[test] -fn boundary_manifest_matches_classification_and_fixture_coverage() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let manifest = manifest(); - - // The rejected set in the manifest is exactly the tested rejection table. - let mut manifest_kinds: Vec = manifest["rejected"] - .as_array() - .expect("rejected list") - .iter() - .map(|entry| entry["kind"].as_str().expect("kind").to_string()) - .collect(); - manifest_kinds.sort(); - let mut tested_kinds: Vec = rejection_cases() - .iter() - .map(|(kind, _, _)| kind.to_string()) - .collect(); - tested_kinds.sort(); - assert_eq!( - manifest_kinds, tested_kinds, - "the manifest's rejected set must exactly match the tested rejection table" - ); - - // Every manifest-supported bound id resolves through the shipped reverse - // bindings (the classifier treats it as supported). - for id in manifest["supported"]["boundValueIds"] - .as_array() - .expect("boundValueIds") - { - let id = id.as_str().expect("id"); - assert!( - wright_ostw::reconstruct::value_ostw_name(id).is_some(), - "manifest value id '{id}' must have a reverse binding" - ); - } - for id in manifest["supported"]["boundActionIds"] - .as_array() - .expect("boundActionIds") - { - let id = id.as_str().expect("id"); - assert!( - wright_ostw::reconstruct::action_ostw_name(id).is_some(), - "manifest action id '{id}' must have a reverse binding" - ); - } - for domain in manifest["supported"]["enumDomains"] - .as_array() - .expect("enumDomains") - { - let domain = domain.as_str().expect("domain"); - assert!( - wright_ostw::reconstruct::bound_enum_domains() - .iter() - .any(|binding| binding.domain == domain), - "manifest enum domain '{domain}' must have a reverse binding" - ); - } - for member in manifest["supported"]["enumMembers"] - .as_array() - .expect("enumMembers") - { - let member = member.as_str().expect("member"); - let (domain, value) = member.split_once('.').expect("domain.member"); - assert!( - wright_ostw::reconstruct::enum_ostw(domain, value).is_some(), - "manifest enum member '{member}' must have a reverse binding" - ); - } - for id in manifest["supported"]["syntaxValueIds"] - .as_array() - .expect("syntaxValueIds") - { - let id = id.as_str().expect("id"); - assert!( - SYNTAX_VALUE_IDS.contains(&id), - "syntax value id '{id}' must be in the known special-syntax set" - ); - } - - // Fixture coverage must exactly match the manifest's supported sets. - let mut coverage = serde_json::json!({ - "values": Vec::::new(), - "actions": Vec::::new(), - "enums": Vec::::new(), - "modifyOps": Vec::::new(), - "actionKinds": Vec::::new(), - }); - for name in POSITIVE_FIXTURES { - let dir = fixture_dir(name); - let fixture = parse(&catalog, &read(&dir.join("workshop.txt"))); - let fixture_coverage = collect_coverage(&fixture); - for key in ["values", "actions", "enums", "modifyOps", "actionKinds"] { - let mut merged: Vec = coverage[key] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()) - .collect(); - merged.extend( - fixture_coverage[key] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()), - ); - merged.sort(); - merged.dedup(); - coverage[key] = serde_json::json!(merged); - } - } - for key in ["boundValueIds", "boundActionIds", "enumMembers"] { - let manifest_key = match key { - "boundValueIds" => "values", - "boundActionIds" => "actions", - _ => "enums", - }; - let mut manifest_list: Vec = manifest["supported"][key] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()) - .collect(); - manifest_list.sort(); - let mut covered: Vec = coverage[manifest_key] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()) - .collect(); - covered.sort(); - assert_eq!( - manifest_list, covered, - "manifest {key} must exactly match fixture coverage" - ); - } - // The structural kinds and modify ops exercised by the fixtures are the - // declared ones. - for kind in [ - "setGlobalVariable", - "modifyGlobalVariable", - "setPlayerVariable", - "modifyPlayerVariable", - "callSubroutine", - "if", - "while", - "forGlobalVariable", - "call", - "return", - ] { - let covered: Vec = coverage["actionKinds"] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()) - .collect(); - assert!( - covered.contains(&kind.to_string()), - "fixture coverage must exercise action kind {kind}" - ); - } - for op in [ - "Add", - "Subtract", - "Multiply", - "Divide", - "Modulo", - "AppendToArray", - ] { - let covered: Vec = coverage["modifyOps"] - .as_array() - .expect("array") - .iter() - .map(|v| v.as_str().expect("str").to_string()) - .collect(); - assert!( - covered.contains(&op.to_string()), - "fixture coverage must exercise modify op {op}" - ); - } -} - -#[test] -fn reconstruct_api_exposes_the_reverse_binding_tables() { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - // Every bound id maps to a catalog entry of the right kind, and the OSTW - // name resolves back through signature::builtin to the same id. - for (id, source) in wright_ostw::reconstruct::bound_action_ids() { - assert!( - catalog - .entry(workshop_rs::catalog::Kind::Action, id) - .is_some(), - "bound action id '{id}' must exist in the canonical catalog" - ); - assert_eq!( - crate_signature_builtin(source), - Some((workshop_rs::catalog::Kind::Action, id)), - "OSTW action name '{source}' must resolve back to catalog id '{id}'" - ); - } - for (id, source) in wright_ostw::reconstruct::bound_value_ids() { - assert!( - catalog - .entry(workshop_rs::catalog::Kind::Value, id) - .is_some(), - "bound value id '{id}' must exist in the canonical catalog" - ); - assert_eq!( - crate_signature_builtin(source), - Some((workshop_rs::catalog::Kind::Value, id)), - "OSTW value name '{source}' must resolve back to catalog id '{id}'" - ); - } - for binding in wright_ostw::reconstruct::bound_enum_domains() { - assert!( - catalog.enum_domain(binding.domain).is_some(), - "bound enum domain '{}' must exist in the canonical catalog", - binding.domain - ); - for (member, source_member) in &binding.members { - assert_eq!( - wright_ostw::reconstruct::enum_ostw(binding.domain, member), - Some((binding.source, *source_member)), - "enum member '{}' must resolve back to '{}'", - member, - source_member - ); - } - } -} - -/// The same lookup the OSTW semantic phase uses (signature::builtin), kept -/// local so the test asserts the round trip through the shipped frontend -/// binding table without importing the private module path. -fn crate_signature_builtin(name: &str) -> Option<(workshop_rs::catalog::Kind, &'static str)> { - wright_ostw::signature::builtin(name) -} diff --git a/crates/wright-ostw/tests/semantic.rs b/crates/wright-ostw/tests/semantic.rs deleted file mode 100644 index f67c9ad..0000000 --- a/crates/wright-ostw/tests/semantic.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Semantic regressions (#118): the pinned protect-ban entry-point reachable -//! graph resolves through the native OSTW semantic phase into frontend-neutral -//! Wright HIR that validates, boundary forms fail deterministically with -//! structured source-located diagnostics, unreachable sources never affect -//! semantic success, and the outcome is byte-stable across runs. Assertions -//! are on observable outcomes (HIR counts, diagnostics, determinism), never -//! on hardcoded parse trees. - -use std::path::{Path, PathBuf}; - -use wright_ostw::SemanticOutcome; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") -} - -fn corpus_root() -> PathBuf { - workspace_root().join("compatibility/ostw/corpus/protect-ban") -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())) -} - -fn compile_semantic(root: &Path, main_rel: &str) -> (wright_ostw::OstwOutcome, SemanticOutcome) { - let main_text = read(&root.join(main_rel)); - wright_ostw::compile_with_semantics(&main_text, Some(main_rel), root) -} - -#[test] -fn reachable_closure_resolves_to_valid_frontend_neutral_hir() { - let (project, semantic) = compile_semantic(&corpus_root(), "main.ostw"); - assert!( - project.error.is_none(), - "project loads: {:?}", - project.error - ); - let hir = semantic.hir.as_ref().expect("HIR is produced"); - hir.validate().expect("lowered HIR validates"); - - // The reachable closure's supported surface is present. - assert_eq!( - hir.globals.len(), - 54, - "all reachable globals (incl. foreach counters)" - ); - assert_eq!(hir.players.len(), 4, "the reachable playervars"); - assert_eq!(hir.enums.len(), 1, "the user enum Phase"); - assert_eq!(hir.functions.len(), 53, "typed/value/void-inline functions"); - assert_eq!(hir.subroutines.len(), 10, "rule-named subroutines"); - assert_eq!(hir.rules.len(), 28, "the reachable rules"); - - // The explicit global id `i 127` is honored (P3a). - let i = hir - .globals - .iter() - .find(|global| global.name == "i") - .expect("global i"); - assert_eq!(i.index, Some(127), "explicit global id 127"); - - // The user enum Phase has the expected members. - let phase = hir - .enums - .iter() - .find(|enum_| enum_.name == "Phase") - .expect("enum Phase"); - assert_eq!( - phase - .members - .iter() - .map(|member| member.name.as_str()) - .collect::>(), - vec!["Waiting", "Protect", "Ban", "ExtraBan", "Gameplay"] - ); - - // The 3 active OSTWUtils missing imports remain explicit boundaries. - let missing_imports = project - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.code == "ostw-missing-import") - .count(); - assert_eq!(missing_imports, 3); - - // Unreachable sources contribute nothing: no protectBanFull/PlayerInterface - // defects appear in the semantic diagnostics. - for diagnostic in &semantic.diagnostics { - let path = diagnostic - .span - .and_then(|span| { - project - .project - .as_ref() - .map(|p| p.files[span.file.index()].path.clone()) - }) - .unwrap_or_default(); - assert!( - path != "protectBanFull.ostw" && path != "interface/PlayerInterface.del", - "unreachable file {path} must not contribute: {diagnostic:?}" - ); - } -} - -#[test] -fn unsupported_reachable_boundaries_fail_deterministically() { - let (_, semantic) = compile_semantic(&corpus_root(), "main.ostw"); - let codes: std::collections::BTreeMap<&str, usize> = - semantic - .diagnostics - .iter() - .fold(Default::default(), |mut map, d| { - *map.entry(d.code.as_str()).or_default() += 1; - map - }); - // The reachable graph's boundaries: the missing OSTWUtils/Cursor surface, - // the Math module, and class instantiation. - assert!( - codes.get("ostw-unsupported").copied().unwrap_or(0) >= 20, - "Math/Cursor/new boundaries surface: {codes:?}" - ); - assert!( - semantic - .diagnostics - .iter() - .any(|d| d.message.contains("new Cursor")), - "class instantiation is rejected" - ); - // Every diagnostic is source-located. - for diagnostic in &semantic.diagnostics { - assert!( - diagnostic.span.is_some(), - "every semantic diagnostic carries a span: {diagnostic:?}" - ); - } -} - -#[test] -fn semantic_outcome_is_byte_stable_across_runs() { - let root = corpus_root(); - let first = compile_semantic(&root, "main.ostw"); - let second = compile_semantic(&root, "main.ostw"); - let first_hir = format!("{:?}", first.1.hir); - let second_hir = format!("{:?}", second.1.hir); - assert_eq!(first_hir, second_hir, "HIR is byte-stable"); - assert_eq!( - format!("{:?}", first.1.diagnostics), - format!("{:?}", second.1.diagnostics), - "diagnostics are byte-stable" - ); -} - -#[test] -fn unreachable_broken_source_does_not_affect_semantics() { - // A project with an unreachable broken source resolves exactly like the - // project without it (unreachable files are never semantic inputs). - let dir = std::env::temp_dir().join(format!("wright-ostw-sem-neg-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ds.toml"), "entry_point=\"main.ostw\"\n").unwrap(); - std::fs::write( - dir.join("main.ostw"), - "Number add(Number a): a + 1;\nrule: \"r\" { BigMessage(AllPlayers(), \"hi\"); }\n", - ) - .unwrap(); - std::fs::write(dir.join("broken.del"), "globalvar Number x = ;\n").unwrap(); - - let (with_broken, semantic) = compile_semantic(&dir, "main.ostw"); - assert!(with_broken.error.is_none()); - assert!( - semantic.diagnostics.is_empty(), - "unreachable broken source contributes nothing: {:?}", - semantic.diagnostics - ); - let hir = semantic.hir.as_ref().expect("HIR produced"); - hir.validate().expect("HIR validates"); - assert_eq!(hir.functions.len(), 1, "only the reachable function"); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn reachable_unsupported_form_surfaces_structured_diagnostic() { - // A reachable `new` (class instantiation) fails at resolution with a - // structured source-located diagnostic, not at emission. - let dir = std::env::temp_dir().join(format!("wright-ostw-sem-new-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ds.toml"), "entry_point=\"main.ostw\"\n").unwrap(); - std::fs::write( - dir.join("main.ostw"), - "playervar Cursor | Number c = -1;\nrule: \"r\" Event.OngoingPlayer { c = new Cursor(1, 2, 3, 4); }\n", - ) - .unwrap(); - let (_, semantic) = compile_semantic(&dir, "main.ostw"); - let unsupported = semantic - .diagnostics - .iter() - .find(|d| d.code == "ostw-unsupported" && d.message.contains("new Cursor")) - .expect("new Cursor is rejected during resolution"); - assert!(unsupported.span.is_some(), "diagnostic is source-located"); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn every_ostw_binding_resolves_through_the_canonical_catalog() { - // Catalog-ownership invariant (#118 AC): wright-ostw ships only OSTW - // source-name bindings, and every binding resolves to real canonical - // catalog data (kind/id for builtins; domain + member ids for enums). - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let en = workshop_rs::catalog::Locale::new("en-US"); - - for (source, (kind, id)) in wright_ostw::signature::BUILTIN_BINDINGS { - let entry = catalog - .entry(*kind, id) - .unwrap_or_else(|| panic!("builtin '{source}' -> {id:?} has no catalog entry")); - assert!( - entry.spelling(&en).is_some(), - "builtin '{source}' entry '{id}' has no en-US spelling" - ); - } - - for (source, binding) in wright_ostw::signature::ENUM_DOMAIN_BINDINGS { - let domain = catalog.enum_domain(binding.domain).unwrap_or_else(|| { - panic!( - "enum domain '{source}' -> '{}' has no catalog domain", - binding.domain - ) - }); - for (member_source, canonical) in binding.members { - assert!( - domain - .members - .iter() - .any(|member| &member.member == canonical), - "domain '{source}': member '{member_source}' -> '{canonical}' is not in the catalog domain '{}'", - binding.domain - ); - } - } -} - -#[test] -fn builtins_and_enum_domains_resolve_through_the_canonical_catalog() { - // Representative resolutions through the shipped semantic path: an action - // (BigMessage), a value (AllPlayers), and a builtin enum domain - // (Color.White) resolve to HIR through the canonical Workshop catalog. - let dir = std::env::temp_dir().join(format!("wright-ostw-sem-cat-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ds.toml"), "entry_point=\"main.ostw\"\n").unwrap(); - std::fs::write( - dir.join("main.ostw"), - "rule: \"r\" Event.OngoingPlayer {\n BigMessage(AllPlayers(), \"hi\");\n BigMessage(AllPlayers(), Color.White);\n}\n", - ) - .unwrap(); - let (_, semantic) = compile_semantic(&dir, "main.ostw"); - assert!( - semantic.diagnostics.is_empty(), - "the exercised surface resolves cleanly: {:?}", - semantic.diagnostics - ); - let hir = semantic.hir.as_ref().expect("HIR produced"); - - let calls: Vec<&str> = hir - .exprs - .iter() - .filter_map(|expr| match expr { - wright_ir::hir::Expr::Call { name, .. } => Some(name.as_str()), - _ => None, - }) - .collect(); - // #119: bound builtin calls carry the canonical catalog id, so the - // shared pipeline resolves presentation spellings purely through the - // catalog (the OSTW source identity lives in signature.rs only). - assert!(calls.contains(&"bigMessage"), "action resolves: {calls:?}"); - assert!(calls.contains(&"allPlayers"), "value resolves: {calls:?}"); - - let enums: Vec<(&str, &str)> = hir - .exprs - .iter() - .filter_map(|expr| match expr { - wright_ir::hir::Expr::Enum { - value_type, value, .. - } => Some((value_type.as_str(), value.as_str())), - _ => None, - }) - .collect(); - assert!( - enums.contains(&("Color", "WHITE")), - "enum domain resolves: {enums:?}" - ); - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/docs/README.md b/docs/README.md index 66dda88..f0088b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,7 @@ feature or contract evolves, its living document is updated directly: - [`.opy` Support Matrix](opy/support-matrix.md): Supported syntax, declarations, expressions, settings, diagnostics. - [Workshop Support Matrix](workshop/support-matrix.md): Evidenced actions, values, events, enums, localized catalog. - **Protocol & Pipeline Specifications**: - - [Opy HIR v1 Protocol](hir/opy-hir-v1.md): JSON schema for the `.opy` frontend HIR interchange boundary. + - [Opy HIR v1 Protocol](hir/opy-hir-v1.md): JSON schema for the `.opy` owner-producer interchange boundary. - [Workshop Catalog Data Pipeline](workshop/catalog-pipeline.md): Canonical catalog generation, localization data, validation. ### 3. Architecture Decision Records (ADRs) @@ -88,7 +88,7 @@ documents in `main`. | **Editor Services & LSP** | [`language-services.md`](language-services.md) | Hover, definitions, references, rename, semantic tokens, LSP framing. | | **Release & Packaging** | [`release.md`](release.md) | Platform targets, packaging scripts, automated release validation. | | **Governance & Process** | [`agent-team.md`](agent-team.md) | Role authority (PM / Architect / Engineer / QA), spec schemas, blocked routes. | -| **OPY Language** | [`opy/support-matrix.md`](opy/support-matrix.md) | Native Rust `.opy` frontend syntax, preprocessing, resolution, settings. | +| **OPY Language** | [`opy/support-matrix.md`](opy/support-matrix.md) | Owner-side `.opy` syntax, preprocessing, resolution, settings, and Wright adapter boundary. | | **OSTW Language** | [`ostw/support-matrix.md`](ostw/support-matrix.md) | Declared OSTW → Workshop compile surface (#119): accepted targets, lowering decisions, declared normalization and divergences, boundaries. | | **OPY Baseline** | [`opy/compatibility-baseline.md`](opy/compatibility-baseline.md) | Tiered forward-looking OPY compatibility inventory and residual evidence. | | **OPY Manifest** | [`opy/compat-manifest-spec.md`](opy/compat-manifest-spec.md) | Machine-readable OPY semantic compatibility manifest specification. | diff --git a/docs/architecture.md b/docs/architecture.md index 85ae40c..89615fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,19 +65,7 @@ the better integration choice. No language/core implementation should depend back on Wright tooling internals. -## Terminology - -### Frontend - -A **frontend** is an internal stage inside a language implementation that turns -authored source into parsed/project/semantic representations and HIR where -applicable. - -It is useful to distinguish a Workshop-independent semantic frontend from the -compiler backend because `check`, inspect/query, and source tooling need not -wait for complete target emission. - -`frontend` is not the product identity of `opy-rs` or `del-rs`. +## Integration terminology ### Provider @@ -89,7 +77,9 @@ Provider conformance proves protocol behavior, not semantic completeness. A standalone implementation can expose a provider and still remain independently usable through its own library and CLI. -See ADR-0010 for the durable terminology decision. +Language repositories own source parsing, semantics, lowering, diagnostics, and +compatibility evidence. Wright adapters translate those owner contracts into +driver results and do not become a second implementation. ## Source-form integrations diff --git a/docs/cli.md b/docs/cli.md index 309eb1d..6b37dfe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -114,7 +114,7 @@ agents consume. input (path | stdin) ↓ discovery: kind detection, locale, root, identity (wright-driver::input) CompilerSession (wright-driver) - ├─ frontend: .opy bridge | native Workshop | protocol JSON + ├─ source adapter: owner-side OPY/DEL | Workshop | protocol JSON ├─ validation (WIR) ├─ lowering (HIR → WIR) ├─ analysis (SemanticService: semantic facts, symbols, references, CFG) @@ -156,14 +156,14 @@ stdin content (protocol JSON starts with `{`, otherwise Workshop text) and can be overridden with `--kind auto|opy|ostw|workshop|protocol`. `--locale` overrides Workshop client-locale detection; `--root` sets the include/project root; `-o/--output` writes compiled output to a file. `.ostw`/`.del` inputs -are parsed by the native OSTW frontend (`wright-ostw`), which loads the -`ds.toml` project closure, resolves the reachable imports, and lowers the -#118 semantic HIR through the same validate→lower→validate path as the other -frontends; `check`, `lint`, `analyze`, and `inspect` then run the shared -analyzer/semantic services over that program and report project-relative -multi-file provenance and the #118 boundary diagnostics. `compile` (#119) -lowers the reachable #118 semantic surface through the shared HIR → WIR → -Workshop pipeline and emits en-US Workshop text; it fails deterministically +are parsed through the `del-rs` owner adapter, which loads the project and +resolves the reachable imports. The adapter preserves owner diagnostics and +source-file identity, then passes owner-produced canonical WIR to the shared +analyzer/emitter. `check`, `lint`, `analyze`, and `inspect` report the owner +diagnostics plus project-relative provenance. DEL overlay validation is +explicitly unsupported until the owner exposes an overlay project contract; +it never falls back to the removed Wright implementation. `compile` (#119) +emits en-US Workshop text from owner-produced WIR and fails deterministically with structured, source-located diagnostics when the reachable surface is outside the declared support matrix (see [`docs/ostw/support-matrix.md`](ostw/support-matrix.md)) or the project @@ -178,8 +178,7 @@ a thin passthrough: it parses argv, builds the session, calls the driver workflow, and renders the envelope — no reconstruction logic lives in the CLI layer. The driver reuses its own `load()` path (kind detection, Workshop parsing, WIR validation) and delegates per target to the language-owned -reconstructors, `wright_opy::reconstruct::reconstruct` and -`wright_ostw::reconstruct::reconstruct`, unchanged. +reconstructors from `opy-rs` and `del-rs` through the narrow Wright adapters. * The target flag is **required and explicit** (`--target opy|ostw`); a missing or unknown target is a usage error (exit 2), and `--target` on any @@ -204,7 +203,7 @@ reconstructors, `wright_opy::reconstruct::reconstruct` and The cross-format round-trip acceptance suite lives in `crates/wright-driver/tests/convert.rs` and writes the machine-readable report `target/wright-convert-report.json` (one entry per committed fixture: -`Workshop → convert → native frontend → HIR → WIR → Workshop` for both +`Workshop → convert → owner source implementation → WIR → Workshop` for both targets, plus the deterministic rejection entries). ## `wright lint` and the lint configuration @@ -307,7 +306,7 @@ identity; the tool/agent API exposes the same value as `inputIdentity`), The three core workflows have separate contracts: -* `check` is the correctness gate. It reports discovery, frontend, project, +* `check` is the correctness gate. It reports discovery, source implementation, project, semantic, lowering, and validation diagnostics. Ordinary configurable lint findings such as `duplicate-condition` and `min-wait-loop` are not emitted by default. @@ -465,14 +464,15 @@ For identical inputs and configuration, JSON output is byte-deterministic SHA-256 of the input bytes (`result.output.input_identity`); emitted artifacts carry their own SHA-256 (`result.output.sha256`). -## The `.opy` frontend +## The `.opy` source implementation -`.opy` inputs are compiled by the native Rust frontend (`wright-opy`): no -Node, no OverPy, and stdin `.opy` is supported (the include root defaults to the -working directory for stdin, `--root` for files). The pinned OverPy adapter -remains available only as an explicit compatibility fallback by setting -`WRIGHT_ADAPTER_PATH`; it is never selected silently. The frontend surface is -declared in [`opy/support-matrix.md`](opy/support-matrix.md). +`.opy` inputs are compiled through the owner-backed `opy-rs` implementation +through Wright's narrow adapter: no Node, no OverPy, and stdin `.opy` is +supported (the include root defaults to the working directory for stdin, +`--root` for files). The pinned OverPy adapter remains available only as an +explicit compatibility fallback by setting `WRIGHT_ADAPTER_PATH`; it is never +selected silently. The source surface is declared in +[`opy/support-matrix.md`](opy/support-matrix.md). ## Library reuse diff --git a/docs/compatibility.md b/docs/compatibility.md index 9b134b7..f1ca948 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -14,11 +14,11 @@ Wright owns tooling and orchestration, not the durable source-language implementations. `opy-rs` owns OPY language semantics, `del-rs` owns the DEL/OSTW-compatible implementation, and `workshop-rs` owns canonical Workshop semantics and WIR. During the migration described by ADR-0009, Wright still -contains the in-repo `wright-opy` / `wright-ostw` migration frontends until -their planned cutovers complete (the `wright-workshop` re-export adapter was -removed once call sites consumed `workshop-rs` directly). -Wright therefore keeps regression coverage for those current integration paths, -but upstream OverPy and OSTW compilers/language services remain compatibility +contains only narrow `wright-opy` / `wright-ostw` contract adapters; source +language ownership and regression evidence live in the owner repositories +(the `wright-workshop` re-export adapter was removed once call sites consumed +`workshop-rs` directly). Upstream OverPy and OSTW compilers/language services +remain compatibility oracles and behavior references rather than production or default-CI runtime dependencies. @@ -57,7 +57,7 @@ subset; passing a lower level never implies passing a higher one. ### S: syntax compatibility Wright and the reference agree on whether each corpus input is accepted by the -supported frontend boundary, and accepted inputs are classified into the same +supported source implementation boundary, and accepted inputs are classified into the same documented supported or unsupported subset. Minimum evidence: diff --git a/docs/compatibility/upstream-references.md b/docs/compatibility/upstream-references.md index 248c66a..4768091 100644 --- a/docs/compatibility/upstream-references.md +++ b/docs/compatibility/upstream-references.md @@ -43,7 +43,7 @@ artifacts. Concretely, it serves as: * the reference for S (syntax), D (diagnostic), and N (normalized-output) evidence in the compatibility corpus (`compatibility/fixtures/**`, `compatibility/oracle/`); -* the pinned frontend invoked by the adapter (`adapter/`) to produce Opy HIR +* the pinned reference invoked by the compatibility adapter (`adapter/`) to produce Opy HIR v1 reference fixtures (`adapter/fixtures/**`), compared against the native frontend at the HIR boundary by `crates/wright-opy/tests/differential.rs`; * the source of systematic probe validation for the proactive compatibility @@ -54,7 +54,7 @@ artifacts. Concretely, it serves as: | Wright surface | Use of the reference | | --- | --- | -| `wright-opy` native frontend | Differential HIR parity, accept/reject agreement, structured diagnostics | +| `opy-rs` owner implementation + `wright-opy` adapter | Differential HIR parity, accept/reject agreement, structured diagnostics | | `workshop-rs` catalog/emission | Canonical en-US spelling validation against oracle-emitted Workshop text; receiver-method and enum emission evidence | | `compatibility/` harness | Fixture snapshots, oracle identity blocks, S/D/N gate evidence | | Systematic baseline | Reference-validated probes for builtin action/value/member/enum/signature metadata — implemented as the OPY semantic compatibility manifest (`crates/wright-opy/src/manifest/`): every entry records the probe that validates it, and `probes/validate.py` runs the full probe set against the pinned oracle (accept/reject, normalized emission hash, diagnostic category; wired into `compatibility/tests`) | @@ -119,8 +119,8 @@ exercised by the oracle harness (`compatibility/ostw/run_oracle.py`). ### Oracle role -OSTW is the compatibility **oracle and behavior reference** for Wright's -OSTW frontend (`wright-ostw`), per [`docs/compatibility.md`](../compatibility.md) and the +OSTW is the compatibility **oracle and behavior reference** for the owner-side +DEL implementation (`del-rs`), consumed through `wright-ostw`, per [`docs/compatibility.md`](../compatibility.md) and the extension of ADR-0007 pinning policy to a second reference. It is not a production runtime dependency of the Wright core and is never bundled into release artifacts. Concretely it serves as: @@ -140,9 +140,9 @@ release artifacts. Concretely it serves as: | Wright surface | Use of the reference | | --- | --- | -| `wright-ostw` native frontend | Accept/reject agreement, structured diagnostics, HIR semantic identity for the declared OSTW surface | +| `del-rs` owner implementation + `wright-ostw` adapter | Accept/reject agreement, structured diagnostics, canonical WIR identity for the declared OSTW surface | | `workshop-rs` emitter/catalog | Canonical en-US emission cross-check against the oracle's `workshopCode` output for shared Workshop surfaces | -| Workshop → OSTW reconstruction (future) | Reference decompiler output for the declared reconstruction surface and quality criteria | +| Workshop → OSTW reconstruction (`del-rs`) | Reference decompiler output for the declared reconstruction surface and quality criteria | | `compatibility/` harness | OSTW fixture snapshots, oracle identity blocks, S/D/N gate evidence | ### Reference semantics vs Wright-owned architecture diff --git a/docs/embedding.md b/docs/embedding.md index 9c92114..5213047 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -12,7 +12,7 @@ safe source-edit contracts, and the transport adapters | `wright_driver::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}` | **stable** | Transport-neutral workflow phase events; no terminal presentation or machine-result mutation | | `wright_driver::{Envelope, CompileResult, CheckResult, AnalyzeResult, InspectResult, LintResult, Diagnostic, CompiledOutput}` | **stable** | `wright-result/v1` machine contract ([`docs/cli.md`](cli.md)) | | `wright_driver::service::{ToolService, ToolRequest, ToolResponse, Capabilities}` | **stable** | Session-aware tool queries (project/rules/symbols/references/usage/CFG/findings/lint/lintRules/callGraph/costEstimate/targetMetadata/capabilities) plus validated mutation (`validateEditTransaction`, `semanticRename`, #130) | -| `wright_driver::edit::{SourceEdit, EditRange, EditTransaction, SourcePreview, EditValidation, RenameRequest, rename_symbol, validate_transaction}` | **stable** | Frontend-neutral source-edit transactions; validated through the correct native frontend/project semantics (#128); `EditTransaction::apply` applies ranges against one original source snapshot | +| `wright_driver::edit::{SourceEdit, EditRange, EditTransaction, SourcePreview, EditValidation, RenameRequest, rename_symbol, validate_transaction}` | **stable** | Source-edit transactions; validated through the correct owner-backed project semantics (#128); `EditTransaction::apply` applies ranges against one original source snapshot | | `wright_driver::{input_identity, EMBEDDING_CONTRACT}` | **stable** | `wright-embedding/v1` | | Internal HIR/WIR arenas, parser/CST, emitter internals | **internal** | Never part of the public contract | | `wright-serve` stdio/JSON-RPC adapters | **stable** | Thin mappings over `ToolService`; MCP not implemented (no agent evidence) | @@ -94,14 +94,12 @@ order-dependent zero-width combinations at one position are refused as overlapping/conflicting edits, invalid ranges, and compilation errors, and returns the previewed edited sources atomically (any failed validation returns `ok = false` and no validated preview). Validation runs through the -*original* project/session semantics -(`SessionConfig` kind/root, transformation profile): OPY projects compile -through the native OPY frontend with edited includes as in-memory overlays, -and OSTW projects load their `ds.toml` project graph with edited files as -overlays, so cross-file diagnostics keep their real source paths and no -filesystem write is ever required to preview or validate. Workshop/Protocol -inputs refuse explicitly (editing is declared over the OPY and OSTW source -frontends). The first evidence-backed refactoring is symbol rename +owner-backed project/session semantics (`SessionConfig` kind/root, +transformation profile): OPY projects compile through `opy-rs` with edited +includes as in-memory overlays. DEL/OSTW overlays refuse explicitly because +`del-rs` has not exposed an equivalent overlay project contract. Workshop and +Protocol inputs also refuse explicitly. The first evidence-backed refactoring +is symbol rename ([`rename_symbol`]) with whole-word replacement and transaction validation. Raw HIR/WIR mutation is never public, and application/writing stays an explicit caller responsibility. diff --git a/docs/hir/opy-hir-v1.md b/docs/hir/opy-hir-v1.md index bb98dd6..e9f4b0c 100644 --- a/docs/hir/opy-hir-v1.md +++ b/docs/hir/opy-hir-v1.md @@ -1,7 +1,7 @@ -# Opy HIR v1 — Wright frontend protocol +# Opy HIR v1 — producer protocol Status: accepted baseline for v0.1 -Scope: the interchange format between the temporary OverPy frontend adapter +Scope: the interchange format between an OPY source producer and Wright's and the Wright Rust core This document is the normative specification for `wright/opy-hir` protocol diff --git a/docs/language-services.md b/docs/language-services.md index 52f5226..92cc47d 100644 --- a/docs/language-services.md +++ b/docs/language-services.md @@ -94,7 +94,7 @@ suppression is the authoritative contract. Rename delegates target resolution, edit generation, and validation to the shared driver refactoring contract (`wright_driver::edit::semantic_rename`, #129): every affected root - resolves through its original native frontend, the unioned exact-range + resolves through its original owner-backed source implementation, the unioned exact-range transaction is validated through the shared #128 transaction boundary (`wright_driver::edit::validate_transaction`), and no duplicate edit-validation or span-collection semantics live here. @@ -104,25 +104,19 @@ suppression is the authoritative contract. ## OSTW documents (#120) -`.ostw`/`.del` documents route through the same editor-neutral services with -no OSTW-specific analysis stack: the native OSTW frontend loads the `ds.toml` -project closure and resolves the #118 semantic HIR, which is lowered through -the shared HIR→WIR path; diagnostics, findings, hover, definition, -references, completion, and semantic tokens then come from the shared -analyzer/semantic index exactly as for OPY/Workshop inputs. Project-level and -#118 semantic boundary diagnostics (missing imports, Math/Cursor/class -surfaces) surface as source-aware errors with project-relative paths. +`.ostw`/`.del` documents route through the owner-backed adapter. `del-rs` +loads the project closure, resolves semantics, lowers directly to canonical +WIR, and preserves owner diagnostics and file identity. Shared analysis runs +on successful owner WIR; owner capabilities that do not produce WIR are +reported as structured source errors. Operations that stay unsupported for OSTW are **explicitly refused or documented, never emulated through upstream calls**: -- **Semantic rename** — offered for OSTW symbols on the declared semantic - surface (globals, player variables, subroutines/functions) through the - shared refactoring contract (#129): resolution runs over the shared - semantic index of the `ds.toml` project graph, edited transactions validate - through the native OSTW frontend, and targets without an exact identifier - span (e.g. typed constants and other provenance-limited forms) refuse - explicitly instead of broadening to a statement span. Whole-source OSTW +- **Semantic rename and overlays** — refused explicitly while the owner-backed + adapter lacks a source-edit/overlay project contract. The service never + invokes an upstream compiler or the removed Wright implementation as a + fallback. regeneration/emitters remain a declared non-goal (#120) — rename edits original OSTW source with exact identifier ranges, never reconstructed text. diff --git a/docs/opy/compat-manifest-spec.md b/docs/opy/compat-manifest-spec.md index 8268f38..743733c 100644 --- a/docs/opy/compat-manifest-spec.md +++ b/docs/opy/compat-manifest-spec.md @@ -2,10 +2,10 @@ Status: accepted specification — implemented (#109), extended for named/keyword argument binding (#110) -Scope: the Wright-owned representation for builtin actions/values, member +Scope: the OPY owner-side representation for builtin actions/values, member functions, signatures, parameter enum domains, enum members, and source -aliases; reference-validated and consumed by the native frontend. The -implementation lives in `crates/wright-opy/src/manifest/` (data in +aliases; reference-validated and consumed by the owner source implementation. +The implementation lives in `opy-rs/crates/opy-rs/src/manifest/` (data in `data/manifest.json`, probe evidence in `probes/`); this document is the schema and boundary contract for that data. @@ -16,7 +16,7 @@ Wright-owned manifest is justified: Wright's parse surface already exceeds its semantic/compile surface, and the residual `unknown-action`/`unknown-value`/ `unsupported-member` emission gaps are catalog-coverage gaps, not grammar gaps. The manifest replaces the hardcoded `KNOWN_ENUMS` table in -`crates/wright-opy/src/lower.rs` with data and gives the frontend a single, +the former Wright lowering table with data and gives the owner implementation a single, reference-validated source for: * builtin actions and values (generic and member); @@ -191,12 +191,12 @@ the probe source hash, expected oracle status, normalized emission hash, and emission hash, and diagnostic category (S/D level, see the #106 planning comment); wired into the compatibility harness test suite (`compatibility/tests/test_manifest_probes.py`). -* The frontend consumes the manifest in `lower.rs`: unknown names, wrong +* The owner implementation consumes the manifest: unknown names, wrong action/value position, invalid arity, invalid receiver category, enum-domain mismatches, and named/keyword argument binding (`unknown-keyword`, `duplicate-argument`, `missing-argument`, `positional-after-keyword`, `keyword-required`, `keyword-unsupported`, - `invalid-argument`) produce structured, source-located frontend + `invalid-argument`) produce structured, source-located owner diagnostics before Workshop emission. ## Consumers diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 1a37923..f8de40b 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -1,7 +1,7 @@ -# Native .opy Frontend Support Matrix +# .opy Source Support Matrix -Status: accepted baseline — living .opy frontend support matrix -Scope: the `.opy` source-language surface Wright's native frontend supports, +Status: accepted baseline — living .opy source support matrix +Scope: the `.opy` source-language surface owned and supported by `opy-rs`, with production/corpus evidence for each feature and explicitly deferred constructs @@ -14,9 +14,9 @@ reference identity behind both is recorded centrally in Every claimed feature is backed by the compatibility corpus (`compatibility/fixtures/**/source.opy` and pinned adapter HIR fixtures) or -marked as investigation. The architecture is `lexer → preprocess → CST/parser → -resolve/lower → Opy HIR` (see [`docs/architecture.md`](../architecture.md) and -`crates/wright-opy`). +marked as investigation. The owner architecture is `lexer → preprocess → +CST/parser → resolve/lower → Opy HIR`; Wright consumes it through a narrow +adapter. ## Evidence sources diff --git a/docs/ostw/support-matrix.md b/docs/ostw/support-matrix.md index c3f843d..e41e29c 100644 --- a/docs/ostw/support-matrix.md +++ b/docs/ostw/support-matrix.md @@ -1,4 +1,4 @@ -# Native OSTW Compile Support Matrix +# OSTW Compile Support Matrix Status: accepted baseline — first declared OSTW forward-compilation surface (#119) Scope: the OSTW source surface Wright compiles to Workshop through the shared @@ -13,8 +13,8 @@ the corrected explicit-root oracle evidence model is documented there too (#122). The pinned reference identity is recorded in [`docs/compatibility/upstream-references.md`](../compatibility/upstream-references.md). -The pipeline is `wright-ostw` (project + syntax + #118 semantics) → -frontend-neutral HIR → shared `wright-ir` lowering → canonical +The owner-side pipeline is `del-rs` (project + syntax + semantic analysis) → +canonical WIR through the narrow `wright-ostw` adapter → canonical `workshop-rs` emitter (en-US), identical to the OPY/Workshop paths — no OSTW-specific backend exists. @@ -163,7 +163,7 @@ match exactly (`compatibility/ostw/reconstruction/`): the optional oracle cross-check records exactly this one remaining rejection (`target/ostw-reference`, reference-only by contract). -The full loop `Workshop → WIR → OSTW → native frontend → HIR → WIR → +The full loop `Workshop → WIR → OSTW → owner source implementation → WIR → Workshop` is proven per committed fixture with zero frontend diagnostics, the declared #119 normalization applied to both sides, structural equality, and the round-trip fixed point (reconstructed Workshop reparses and re-emits