From 5011ecb074e1d3d07ff4b1a05dd850ec55d80640 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 18:58:54 +0800 Subject: [PATCH 01/11] feat(provider): pin workshop-rs catalog seam Fixes #29 --- .github/workflows/ci.yml | 2 +- Cargo.lock | 96 +++++++++++++- Cargo.toml | 3 +- README.md | 7 +- docs/architecture.md | 16 ++- docs/limitations.md | 6 +- src/semantic/provider.rs | 257 ++++++++++++++++++++++++++++++++++++- tests/workshop_provider.rs | 61 +++++++++ 8 files changed, 430 insertions(+), 18 deletions(-) create mode 100644 tests/workshop_provider.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38beea6..1c9eae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install stable toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.85.0 - name: Cache uses: Swatinem/rust-cache@v2 with: diff --git a/Cargo.lock b/Cargo.lock index 0f0a3ad..4183053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,40 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] [[package]] name = "del-rs" @@ -9,6 +43,17 @@ dependencies = [ "serde", "serde_json", "toml", + "workshop-rs", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -17,6 +62,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -39,6 +94,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" @@ -115,6 +176,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "syn" version = "3.0.3" @@ -165,18 +237,40 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "workshop-rs" +version = "0.1.0" +source = "git+https://github.com/wrightkit/workshop-rs.git?rev=7b0f8c38b9d1ee627565e8406de25293df9b4f7f#7b0f8c38b9d1ee627565e8406de25293df9b4f7f" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index dbee647..ac7bd70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "del-rs" version = "0.1.0" edition = "2021" -rust-version = "1.80" +rust-version = "1.85" license = "MIT" description = "Workshop-independent OSTW/DeltinScript-compatible frontend: lexer, recoverable parser, project model, semantic analysis, typed HIR, and tooling APIs" repository = "https://github.com/wrightkit/del-rs" @@ -11,6 +11,7 @@ repository = "https://github.com/wrightkit/del-rs" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "1.1" +workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f", package = "workshop-rs" } [[bin]] name = "del-rs" diff --git a/README.md b/README.md index 6517757..ba7234f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ evidence.[^upstream-reference] | Lambdas & closures | โœ… Supported | | | Pattern matching & recursion | โœ… Supported | | | Embedded Workshop / lobby data | ๐ŸŸก Partial | Vanilla Workshop blocks parse; lobby-settings import not yet | -| Workshop builtins | โณ Not yet | Requires the `workshop-rs` catalog | +| Workshop builtins | ๐ŸŸก Partial | `CatalogProvider` resolves the pinned canonical catalog; lowering is tracked separately | | DEL/OSTW โ†’ Workshop compilation | โณ Not yet | Requires `workshop-rs` integration | | Workshop โ†’ DEL/OSTW reconstruction | โณ Not yet | | @@ -60,8 +60,9 @@ meanings. ## Building -Requirements: Rust 1.80+ (edition 2021). The crate has no runtime dependencies -beyond `serde`, `serde_json`, and `toml`. +Requirements: Rust 1.85+ (edition 2021). The crate consumes the pinned +canonical `workshop-rs` catalog in addition to `serde`, `serde_json`, and +`toml`; Workshop-independent workflows can continue using `NoopProvider`. ```sh cargo build --release diff --git a/docs/architecture.md b/docs/architecture.md index b90d828..e6357a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -702,10 +702,11 @@ pub fn load_project(opts: ProjectOptions) -> Project; // total; errors become ## 12. External provider boundary -The single seam through which Workshop-facing names enter the front end. del-rs owns the trait -and the permissive default; workshop-rs owns a real implementation later (integration #8, -blocked on `wrightkit/workshop-rs#2` contracts). No catalog data, enum tables, event tables, or -builtin signatures live in del-rs. +The single seam through which Workshop-facing names enter the front end. del-rs owns the trait, +the permissive default, and the source-language adapter; `CatalogProvider` reads canonical +identities and metadata from the pinned `workshop-rs` catalog. No catalog data, enum tables, +event tables, or builtin signatures are copied into del-rs. The adapter records +`WORKSHOP_RS_REVISION` and exposes the catalog identity for reproducible diagnostics/tests. ```rust // semantic/provider.rs @@ -737,12 +738,13 @@ pub enum ExternalBinding { } pub struct ExternalValueInfo { + pub canonical_id: String, pub ty: ExternalType, // Known(category) | Unknown pub signature: Option, // param names + optionality, when known } -pub struct ExternalActionInfo { pub params: Option> } -pub struct ExternalEventInfo { pub context: Option } // Global | Player | Unknown -pub struct ExternalTypeInfo { pub category: ExternalCategory, pub constant: bool } +pub struct ExternalActionInfo { pub canonical_id: String, pub params: Option> } +pub struct ExternalEventInfo { pub canonical_id: String, pub context: Option } // Global | Player | Unknown +pub struct ExternalTypeInfo { pub canonical_id: String, pub category: ExternalCategory, pub constant: bool } pub struct ArgSignature { pub params: Vec } pub struct ExternalParam { pub name: String, pub optional: bool } diff --git a/docs/limitations.md b/docs/limitations.md index 461c5ac..dfe9831 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -17,9 +17,9 @@ approximation areas. optimizer choices. The typed HIR expresses intent only (`architecture.md` ยง15). - Canonical Workshop catalog data (actions/values/events/constants): - `del-rs` never vendors it; the `WorkshopProvider` trait is the documented - seam. The `NoopProvider` treats every Workshop-facing name as - unresolved-but-legal. + `del-rs` never vendors it; `CatalogProvider` reads the pinned + `workshop-rs` catalog through the documented `WorkshopProvider` seam. + `NoopProvider` remains available for Workshop-independent workflows. - Vanilla Workshop superset bodies (`rule("...")`, `variables {}`, `subroutines {}`, `settings {}`, hooks): parsed as opaque token spans with no frontend semantics. diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index c6365ed..437e523 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -1,9 +1,15 @@ //! Workshop-facing name boundary: the single seam through which Workshop //! catalog names enter the frontend (architecture ยง12). `del-rs` owns the -//! trait and the permissive default; `workshop-rs` implements a real provider -//! at integration time (#8). No catalog data lives here. +//! trait, the permissive default, and the catalog-backed source-language +//! adapter; canonical catalog data remains in `workshop-rs`. use crate::span::{FileId, Span}; +use workshop_rs::catalog::{Catalog, CatalogEntry, Kind, Locale}; +use workshop_rs::WorkshopError; + +/// The reviewed canonical Workshop dependency baseline consumed by this +/// integration slice. Keep this in sync with the git revision in `Cargo.toml`. +pub const WORKSHOP_RS_REVISION: &str = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f"; /// Position a query name is used in. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -62,12 +68,16 @@ pub enum ExternalCategory { #[derive(Clone, Debug)] pub struct ExternalValueInfo { + /// Canonical Workshop identity, when the provider resolved one. + pub canonical_id: String, pub ty: Option, pub signature: Option, } #[derive(Clone, Debug)] pub struct ExternalActionInfo { + /// Canonical Workshop action identity. + pub canonical_id: String, pub params: Option>, } @@ -79,11 +89,15 @@ pub enum EventContext { #[derive(Clone, Debug)] pub struct ExternalEventInfo { + /// Canonical Workshop event identity. + pub canonical_id: String, pub context: Option, } #[derive(Clone, Debug)] pub struct ExternalTypeInfo { + /// Canonical Workshop type/domain identity. + pub canonical_id: String, pub category: ExternalCategory, pub constant: bool, } @@ -124,6 +138,245 @@ impl WorkshopProvider for NoopProvider { } } +/// A catalog-backed provider for the Workshop integration boundary. +/// +/// The provider owns only the source-language adapter. Canonical Workshop +/// identities, aliases, parameter metadata, enum domains, and provenance are +/// read from `workshop-rs`; no catalog data is duplicated here. +pub struct CatalogProvider { + catalog: Catalog, + locale: Locale, +} + +impl CatalogProvider { + /// Load the built-in catalog at the reviewed `workshop-rs` revision. + pub fn new() -> Result { + Self::from_catalog(Catalog::builtin()?, Locale::new("en-US")) + } + + /// Build a provider from a caller-supplied canonical catalog and locale. + pub fn from_catalog(catalog: Catalog, locale: Locale) -> Result { + if !catalog.supports(&locale) { + return Err(WorkshopError::Unsupported { + message: format!( + "provider locale '{}' is not declared by the catalog", + locale + ), + span: None, + }); + } + Ok(Self { catalog, locale }) + } + + /// The canonical catalog identity consumed by this provider. + pub fn catalog_identity(&self) -> workshop_rs::catalog::CatalogIdentity { + self.catalog.identity() + } + + /// The provider's catalog and locale, for integration/lowering clients. + pub fn catalog(&self) -> &Catalog { + &self.catalog + } + + pub fn locale(&self) -> &Locale { + &self.locale + } + + fn resolve_entry(&self, kind: Kind, name: &str) -> Option<&CatalogEntry> { + self.catalog + .entry(kind, name) + .or_else(|| self.catalog.resolve(kind, &self.locale, name)) + .or_else(|| { + let normalized = lowercase_first(name); + self.catalog.entry(kind, &normalized) + }) + } + + fn resolve_event(&self, name: &str) -> Option<(&CatalogEntry, String)> { + // These are the three event identities represented by the pinned WIR. + // The spelling bridge is DEL's source contract; the identity remains + // owned by the canonical catalog. + let canonical = match name { + "OngoingGlobal" => "global", + "OngoingPlayer" => "eachPlayer", + "Subroutine" => "subroutine", + _ => name, + }; + self.catalog + .entry(Kind::Event, canonical) + .map(|entry| (entry, canonical.to_string())) + .or_else(|| { + self.catalog + .resolve(Kind::Event, &self.locale, name) + .map(|entry| (entry, entry.id.clone())) + }) + } + + fn resolve_enum_member(&self, namespace: &[String], name: &str) -> Option<(String, String)> { + let domain = namespace.first()?; + let canonical_name = lowercase_first(name); + self.catalog + .enum_domain(domain) + .and_then(|_| { + self.catalog + .resolve_enum_member(domain, &self.locale, name) + .or_else(|| { + self.catalog + .resolve_enum_member(domain, &self.locale, &canonical_name) + }) + }) + .or_else(|| { + self.catalog + .enum_domain(domain) + .and_then(|d| { + d.members.iter().find(|m| { + m.member == name + || m.member == canonical_name + || m.member.eq_ignore_ascii_case(name) + }) + }) + .map(|m| (domain.to_string(), m.member.clone())) + }) + } +} + +impl WorkshopProvider for CatalogProvider { + fn resolve(&self, query: &NameQuery) -> ExternalResolution { + if query.namespace.first().map(String::as_str) == Some("Event") { + let Some((entry, canonical_id)) = self.resolve_event(&query.name) else { + return ExternalResolution::NotFound; + }; + let context = match canonical_id.as_str() { + "global" => Some(EventContext::Global), + "eachPlayer" => Some(EventContext::Player), + _ => None, + }; + return ExternalResolution::Known(ExternalBinding::Event(ExternalEventInfo { + canonical_id: entry.id.clone(), + context, + })); + } + + if !query.namespace.is_empty() { + if let Some((domain, member)) = self.resolve_enum_member(&query.namespace, &query.name) + { + return ExternalResolution::Known(ExternalBinding::Value(ExternalValueInfo { + canonical_id: format!("{domain}.{member}"), + ty: Some(ExternalCategory::EnumLike), + signature: None, + })); + } + } + + let kinds: &[Kind] = match query.position { + ExternalPosition::Action => &[Kind::Action], + ExternalPosition::Event => &[Kind::Event], + ExternalPosition::Type => &[Kind::Enum], + // The existing DEL semantic contract presents both value and + // action calls through the value position; preserve that seam by + // asking the canonical catalog for both kinds in order. + ExternalPosition::Value | ExternalPosition::Pattern => &[Kind::Value, Kind::Action], + }; + if let Some((kind, entry)) = kinds.iter().find_map(|kind| { + self.resolve_entry(*kind, &query.name) + .map(|entry| (*kind, entry)) + }) { + if query.arity > entry.params.len() { + return ExternalResolution::DefiniteError(format!( + "Workshop {} '{}' accepts at most {} arguments, got {}", + kind.as_str(), + entry.id, + entry.params.len(), + query.arity + )); + } + return match kind { + Kind::Action => { + ExternalResolution::Known(ExternalBinding::Action(ExternalActionInfo { + canonical_id: entry.id.clone(), + params: Some(parameters(entry)), + })) + } + Kind::Value => { + ExternalResolution::Known(ExternalBinding::Value(ExternalValueInfo { + canonical_id: entry.id.clone(), + ty: None, + signature: Some(ArgSignature { + params: parameters(entry), + }), + })) + } + Kind::Enum => ExternalResolution::Known(ExternalBinding::Type(ExternalTypeInfo { + canonical_id: entry.id.clone(), + category: ExternalCategory::EnumLike, + constant: true, + })), + Kind::Event => { + ExternalResolution::Known(ExternalBinding::Event(ExternalEventInfo { + canonical_id: entry.id.clone(), + context: None, + })) + } + _ => ExternalResolution::NotFound, + }; + } + + if query.namespace.is_empty() + && matches!( + query.position, + ExternalPosition::Value | ExternalPosition::Pattern + ) + { + let matches = self.catalog.bare_member_matches(&self.locale, &query.name); + if matches.len() == 1 { + let (domain, member) = &matches[0]; + return ExternalResolution::Known(ExternalBinding::Value(ExternalValueInfo { + canonical_id: format!("{domain}.{member}"), + ty: Some(ExternalCategory::EnumLike), + signature: None, + })); + } + if matches.len() > 1 { + return ExternalResolution::DefiniteError(format!( + "ambiguous Workshop enum member '{}' (matches {})", + query.name, + matches + .iter() + .map(|(domain, member)| format!("{domain}.{member}")) + .collect::>() + .join(", ") + )); + } + } + + ExternalResolution::NotFound + } +} + +fn lowercase_first(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_lowercase().collect::() + chars.as_str() +} + +fn parameters(entry: &CatalogEntry) -> Vec { + entry + .params + .iter() + .enumerate() + .map(|(index, name)| ExternalParam { + name: name.clone(), + optional: entry + .param_defaults + .get(index) + .and_then(Option::as_ref) + .is_some(), + }) + .collect() +} + /// File-agnostic placeholder context for provider calls (providers that need /// program context can be given it at construction; the trait stays narrow). pub struct ResolutionContext<'a> { diff --git a/tests/workshop_provider.rs b/tests/workshop_provider.rs new file mode 100644 index 0000000..85af21c --- /dev/null +++ b/tests/workshop_provider.rs @@ -0,0 +1,61 @@ +use del_rs::semantic::provider::{ + CatalogProvider, EventContext, ExternalBinding, ExternalPosition, ExternalResolution, + NameQuery, WorkshopProvider, WORKSHOP_RS_REVISION, +}; +use del_rs::{FileId, Span}; + +fn query(namespace: &[&str], name: &str, position: ExternalPosition, arity: usize) -> NameQuery { + NameQuery { + namespace: namespace.iter().map(|part| (*part).to_string()).collect(), + name: name.to_string(), + position, + arity, + span: Span::new(FileId(0), 0, 1), + } +} + +#[test] +fn catalog_provider_preserves_canonical_action_identity_and_metadata() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&[], "SmallMessage", ExternalPosition::Value, 2)); + let ExternalResolution::Known(ExternalBinding::Action(action)) = result else { + panic!("expected catalog-backed action binding"); + }; + assert_eq!(action.canonical_id, "smallMessage"); + assert_eq!(action.params.as_ref().map(Vec::len), Some(2)); +} + +#[test] +fn catalog_provider_resolves_del_event_names_to_canonical_ids() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query( + &["Event"], + "OngoingPlayer", + ExternalPosition::Value, + 0, + )); + let ExternalResolution::Known(ExternalBinding::Event(event)) = result else { + panic!("expected catalog-backed event binding"); + }; + assert_eq!(event.canonical_id, "eachPlayer"); + assert_eq!(event.context, Some(EventContext::Player)); +} + +#[test] +fn catalog_provider_resolves_enum_member_without_copying_catalog_data() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&["Team"], "All", ExternalPosition::Value, 0)); + let ExternalResolution::Known(ExternalBinding::Value(value)) = result else { + panic!("expected catalog-backed enum binding"); + }; + assert_eq!(value.canonical_id, "Team.ALL"); +} + +#[test] +fn catalog_provider_rejects_excess_arguments_and_exposes_identity() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&[], "Wait", ExternalPosition::Value, 3)); + assert!(matches!(result, ExternalResolution::DefiniteError(_))); + assert_eq!(WORKSHOP_RS_REVISION.len(), 40); + assert_eq!(provider.catalog_identity().catalog_version, "0.1.0"); +} From 0b578e2504cf983ee31bd6a6061b778b927d50f7 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:26:24 +0800 Subject: [PATCH 02/11] feat(lowering): add core HIR to Workshop WIR path Refs #30 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api.rs | 11 +- src/diagnostics.rs | 1 + src/hir/lower.rs | 57 ++- src/hir/mod.rs | 9 + src/lib.rs | 1 + src/semantic/provider.rs | 22 +- src/workshop.rs | 969 +++++++++++++++++++++++++++++++++++++ tests/workshop_lowering.rs | 89 ++++ tests/workshop_provider.rs | 23 + 11 files changed, 1169 insertions(+), 17 deletions(-) create mode 100644 src/workshop.rs create mode 100644 tests/workshop_lowering.rs diff --git a/Cargo.lock b/Cargo.lock index 4183053..25bfdc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,7 +264,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "workshop-rs" version = "0.1.0" -source = "git+https://github.com/wrightkit/workshop-rs.git?rev=7b0f8c38b9d1ee627565e8406de25293df9b4f7f#7b0f8c38b9d1ee627565e8406de25293df9b4f7f" +source = "git+https://github.com/wrightkit/workshop-rs.git?rev=5d9d77250462898d00e970d51141bcd87a44b8f3#5d9d77250462898d00e970d51141bcd87a44b8f3" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ac7bd70..dc06cd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/wrightkit/del-rs" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "1.1" -workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f", package = "workshop-rs" } +workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "5d9d77250462898d00e970d51141bcd87a44b8f3", package = "workshop-rs" } [[bin]] name = "del-rs" diff --git a/src/api.rs b/src/api.rs index 0d14050..381c0e6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,7 @@ use crate::semantic::resolve::Resolution; use crate::semantic::symbols::SymbolId; use crate::semantic::types::Type; use crate::semantic::SemanticProgram; -use crate::span::{FileId, Span}; +use crate::span::{FileId, SourceMap, Span}; use crate::syntax::parse_source; use crate::syntax::token::Token; use std::path::Path; @@ -62,6 +62,15 @@ pub fn validate_hir(hir: &HirProgram) -> Vec { crate::hir::validate::validate(hir) } +/// Lower validated HIR into canonical Workshop WIR while preserving source +/// provenance through the supplied project source registry. +pub fn lower_to_wir( + hir: &HirProgram, + sources: &SourceMap, +) -> (workshop_rs::wir::Program, Vec) { + crate::workshop::lower_to_wir(hir, sources) +} + // ---- queries ---- /// The symbol bound at `offset` in `file` (via the resolution table). diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 67686d9..64fc99d 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -155,6 +155,7 @@ pub const DIAGNOSTIC_CODES: &[(&str, &str)] = &[ ("HI015", "auto-for variable storage conflict"), ("HI016", "field initializer type mismatch"), ("HI017", "interpolation/async/hook shape violation"), + ("HI018", "HIR construct cannot be lowered to canonical Workshop WIR"), ("HI099", "HIR has validation errors; oracle refuses to execute"), // Oracle ("OR001", "stale reference: use of a deleted object"), diff --git a/src/hir/lower.rs b/src/hir/lower.rs index badd45c..bda6580 100644 --- a/src/hir/lower.rs +++ b/src/hir/lower.rs @@ -34,6 +34,7 @@ pub fn lower(program: &SemanticProgram) -> (HirProgram, Vec Lowerer<'a> { ValueSemantics::Value }; let vid = self.hir.vars.len() as HirVarId; + let explicit_id = self + .program + .var_symbol_of(v.name.id) + .and_then(|sid| self.program.tables.symbol(sid).flags.var_id) + .and_then(|id| u32::try_from(id).ok()); self.hir.vars.push(HirVar { name: v.name.name.clone(), ty, storage, semantics, is_const: v.is_const_init, + explicit_id, span: v.name.span, }); if let Some(sid) = self.program.var_symbol_of(v.name.id) { @@ -134,6 +141,22 @@ impl<'a> Lowerer<'a> { } self.local_vars.insert(v.name.id, vid); } + ItemKind::VarReservation(reservation) => { + let storage = match reservation.storage { + StorageModifier::GlobalVar => StorageIntent::Global, + StorageModifier::PlayerVar => StorageIntent::Player, + }; + self.hir.reservations.push(HirReservation { + storage, + names: reservation + .names + .iter() + .map(NameText::name_text) + .filter(|name| !name.is_empty()) + .collect(), + span: item.span, + }); + } ItemKind::Function(f) => { let sid = self.program.function_symbol_of(f.name.id); let is_macro = matches!(f.body, FuncBody::Expr(_)); @@ -426,6 +449,7 @@ impl<'a> Lowerer<'a> { storage: StorageIntent::Local, semantics: ValueSemantics::Value, is_const: false, + explicit_id: None, span, }); vid @@ -683,13 +707,23 @@ impl<'a> Lowerer<'a> { HirExprKind::External { name: self.ident_name(e), namespace: Vec::new(), - binding: None, + binding: self.external_binding(e.id), + } + } + ExprKind::Member { base, name } => { + if let Some(binding) = self.external_binding(e.id) { + HirExprKind::External { + name: name.name.clone(), + namespace: self.member_namespace(base), + binding: Some(binding), + } + } else { + HirExprKind::Member { + base: self.expr(base), + member: self.lower_member_target(name), + } } } - ExprKind::Member { base, name } => HirExprKind::Member { - base: self.expr(base), - member: self.lower_member_target(name), - }, ExprKind::Index { base, index } => HirExprKind::Index { base: self.expr(base), index: self.expr(index), @@ -895,7 +929,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: id.name.clone(), namespace: Vec::new(), - binding: None, + binding: self.external_binding(call.callee.id), }, args, } @@ -904,7 +938,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: id.name.clone(), namespace: Vec::new(), - binding: None, + binding: self.external_binding(call.callee.id), }, args, }, @@ -972,7 +1006,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: name.name.clone(), namespace: self.member_namespace(base), - binding: None, + binding: self.external_binding(call.callee.id), }, args, }, @@ -997,6 +1031,13 @@ impl<'a> Lowerer<'a> { } } + fn external_binding(&self, node: NodeId) -> Option { + match self.program.resolution.get(&node) { + Some(Resolution::External(binding)) => Some(binding.clone()), + _ => None, + } + } + fn lower_arg(&mut self, a: &Arg) -> HirArg { match &a.name { Some(n) => HirArg::Named { diff --git a/src/hir/mod.rs b/src/hir/mod.rs index 3069985..26fcba6 100644 --- a/src/hir/mod.rs +++ b/src/hir/mod.rs @@ -27,6 +27,7 @@ pub struct HirProgram { pub classes: Vec, pub enums: Vec, pub vars: Vec, + pub reservations: Vec, pub rules: Vec, /// Expression registry: id -> node (HirExprId - 1 indexes this). pub exprs: Vec, @@ -146,6 +147,14 @@ pub struct HirVar { pub storage: StorageIntent, pub semantics: ValueSemantics, pub is_const: bool, + pub explicit_id: Option, + pub span: Span, +} + +#[derive(Clone, Debug)] +pub struct HirReservation { + pub storage: StorageIntent, + pub names: Vec, pub span: Span, } diff --git a/src/lib.rs b/src/lib.rs index 9ef096c..8581d04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod project; pub mod semantic; pub mod hir; pub mod api; +pub mod workshop; pub use diagnostics::{Diagnostic, Phase, RelatedSpan, Severity}; pub use span::{FileId, LineCol, SourceFile, SourceMap, Span}; diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index 437e523..7b36530 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -9,7 +9,7 @@ use workshop_rs::WorkshopError; /// The reviewed canonical Workshop dependency baseline consumed by this /// integration slice. Keep this in sync with the git revision in `Cargo.toml`. -pub const WORKSHOP_RS_REVISION: &str = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f"; +pub const WORKSHOP_RS_REVISION: &str = "5d9d77250462898d00e970d51141bcd87a44b8f3"; /// Position a query name is used in. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -193,12 +193,20 @@ impl CatalogProvider { } fn resolve_event(&self, name: &str) -> Option<(&CatalogEntry, String)> { - // These are the three event identities represented by the pinned WIR. // The spelling bridge is DEL's source contract; the identity remains // owned by the canonical catalog. let canonical = match name { "OngoingGlobal" => "global", "OngoingPlayer" => "eachPlayer", + "OnElimination" => "playerEarnedElimination", + "OnFinalBlow" => "playerDealtFinalBlow", + "OnDamageDealt" => "playerDealtDamage", + "OnDamageTaken" => "playerTookDamage", + "OnDeath" => "playerDied", + "OnHealingDealt" => "playerDealtHealing", + "OnHealingTaken" => "playerReceivedHealing", + "OnPlayerJoin" => "playerJoined", + "OnPlayerLeave" => "playerLeft", "Subroutine" => "subroutine", _ => name, }; @@ -246,10 +254,12 @@ impl WorkshopProvider for CatalogProvider { let Some((entry, canonical_id)) = self.resolve_event(&query.name) else { return ExternalResolution::NotFound; }; - let context = match canonical_id.as_str() { - "global" => Some(EventContext::Global), - "eachPlayer" => Some(EventContext::Player), - _ => None, + let context = if canonical_id == "global" { + Some(EventContext::Global) + } else if canonical_id == "eachPlayer" || canonical_id.starts_with("player") { + Some(EventContext::Player) + } else { + None }; return ExternalResolution::Known(ExternalBinding::Event(ExternalEventInfo { canonical_id: entry.id.clone(), diff --git a/src/workshop.rs b/src/workshop.rs new file mode 100644 index 0000000..02f6094 --- /dev/null +++ b/src/workshop.rs @@ -0,0 +1,969 @@ +//! Lower the backend-neutral DEL HIR into canonical `workshop-rs` WIR. +//! +//! This module owns the DEL-side lowering policy only. Workshop identities, +//! event shapes, variable/action/value nodes, validation, and emission remain +//! owned by `workshop-rs`. + +use crate::diagnostics::{error, Diagnostic, Phase}; +use crate::hir::{ + CallTarget, HirArg, HirExprId, HirExprKind, HirFuncId, HirProgram, HirStmt, HirStmtKind, + HirVarId, LiteralValue, StorageIntent, +}; +use crate::project::Project; +use crate::semantic::provider::ExternalBinding; +use crate::span::{FileId, SourceMap, Span}; +use crate::syntax::ast::{AssignOp, BinaryOp, UnaryOp}; +use std::collections::{HashMap, HashSet}; +use workshop_rs::catalog::Catalog; +use workshop_rs::source::{Position, SourceFile, Span as WorkshopSpan}; +use workshop_rs::wir; + +/// Lower a validated HIR program into canonical Workshop WIR. +/// +/// The source map is required because HIR spans use DEL byte offsets while +/// WIR provenance uses 1-based source positions. The returned diagnostics are +/// fail-closed: an unsupported construct never becomes a successful but +/// semantically incomplete WIR node. +pub fn lower_to_wir(hir: &HirProgram, sources: &SourceMap) -> (wir::Program, Vec) { + Lowerer::new(hir, sources).run() +} + +/// Convenience entry point for callers that still own the checked semantic +/// program. HIR is lowered first, then lowered into WIR with the same project +/// source registry. +pub fn lower_project_to_wir( + semantic: &crate::semantic::SemanticProgram, +) -> (wir::Program, Vec) { + let (hir, mut diagnostics) = crate::hir::lower::lower(semantic); + let (program, mut lowering) = lower_to_wir(&hir, &semantic.project.sources); + diagnostics.append(&mut lowering); + (program, diagnostics) +} + +/// Lower a checked project directly. This preserves the public project +/// boundary without making the WIR backend depend on the semantic provider. +pub fn lower_project( + project: &Project, + provider: &dyn crate::semantic::provider::WorkshopProvider, +) -> (wir::Program, Vec) { + let semantic = crate::semantic::check_project(project, provider); + lower_project_to_wir(&semantic) +} + +struct Lowerer<'a> { + hir: &'a HirProgram, + sources: &'a SourceMap, + out: wir::Program, + global_vars: HashMap, + player_vars: HashMap, + subroutines: HashMap, + diagnostics: Vec, + used_global_indices: HashSet, + used_player_indices: HashSet, +} + +impl<'a> Lowerer<'a> { + fn new(hir: &'a HirProgram, sources: &'a SourceMap) -> Self { + let mut out = wir::Program::default(); + for source in sources.files() { + out.files + .push(SourceFile::new(source.name.display().to_string())); + } + Self { + hir, + sources, + out, + global_vars: HashMap::new(), + player_vars: HashMap::new(), + subroutines: HashMap::new(), + diagnostics: Vec::new(), + used_global_indices: HashSet::new(), + used_player_indices: HashSet::new(), + } + } + + fn run(mut self) -> (wir::Program, Vec) { + self.allocate_variables(); + self.allocate_subroutines(); + self.lower_initializers(); + for rule in &self.hir.rules { + self.lower_rule(rule); + } + for (fid, func) in self.hir.funcs.iter().enumerate() { + if func.kind == crate::hir::FuncKind::Subroutine { + self.lower_subroutine(fid as HirFuncId); + } + } + self.validate_output(); + (self.out, self.diagnostics) + } + + fn allocate_variables(&mut self) { + for (id, var) in self.hir.vars.iter().enumerate() { + let id = id as HirVarId; + match var.storage { + StorageIntent::Global => { + let index = self.allocate_index(var.explicit_id, false, var.span); + let wir_id = self.out.global_variables.push(wir::WorkshopVariable { + name: var.name.clone(), + index, + span: self.ws_span(var.span), + name_span: self.ws_span(var.span), + }); + self.global_vars.insert(id, wir_id); + } + StorageIntent::Player => { + let index = self.allocate_index(var.explicit_id, true, var.span); + let wir_id = self.out.player_variables.push(wir::WorkshopVariable { + name: var.name.clone(), + index, + span: self.ws_span(var.span), + name_span: self.ws_span(var.span), + }); + self.player_vars.insert(id, wir_id); + } + StorageIntent::Local + | StorageIntent::Member + | StorageIntent::StaticMember + | StorageIntent::Parameter + | StorageIntent::External => {} + } + } + for reservation in &self.hir.reservations { + for name in &reservation.names { + match reservation.storage { + StorageIntent::Global => { + let index = self.allocate_index(None, false, reservation.span); + self.out.global_variables.push(wir::WorkshopVariable { + name: name.clone(), + index, + span: self.ws_span(reservation.span), + name_span: self.ws_span(reservation.span), + }); + } + StorageIntent::Player => { + let index = self.allocate_index(None, true, reservation.span); + self.out.player_variables.push(wir::WorkshopVariable { + name: name.clone(), + index, + span: self.ws_span(reservation.span), + name_span: self.ws_span(reservation.span), + }); + } + _ => self.unsupported(reservation.span, "invalid variable reservation storage"), + } + } + } + } + + fn allocate_index(&mut self, explicit: Option, player: bool, span: Span) -> u32 { + let used = if player { + &mut self.used_player_indices + } else { + &mut self.used_global_indices + }; + if let Some(index) = explicit { + if !used.insert(index) { + self.unsupported( + span, + format!("duplicate explicit Workshop variable index {index}"), + ); + } + return index; + } + let mut index = 0; + while used.contains(&index) { + index += 1; + } + used.insert(index); + index + } + + fn allocate_subroutines(&mut self) { + for (index, func) in self.hir.funcs.iter().enumerate() { + if func.kind != crate::hir::FuncKind::Subroutine { + continue; + } + let id = self.out.subroutines.push(wir::WorkshopSubroutine { + name: func.name.clone(), + index: self.out.subroutines.len() as u32, + span: self.ws_span(func.span), + name_span: self.ws_span(func.span), + }); + self.subroutines.insert(index as HirFuncId, id); + } + } + + fn lower_initializers(&mut self) { + let mut global_actions = Vec::new(); + let mut player_actions = Vec::new(); + for stmt in &self.hir.top { + let HirStmtKind::VarDecl { var, init } = stmt.kind else { + self.unsupported( + stmt.span, + "top-level initializer is not a variable declaration", + ); + continue; + }; + let Some(init) = init else { continue }; + let Ok(value) = self.lower_value(init) else { + continue; + }; + let Some(hir_var) = self.hir.vars.get(var as usize) else { + self.unsupported( + stmt.span, + format!("initializer references unknown HIR variable {var}"), + ); + continue; + }; + match hir_var.storage { + StorageIntent::Global => { + if let Some(variable) = self.global_vars.get(&var).copied() { + global_actions.push(self.out.actions.push( + wir::Action::SetGlobalVariable { + variable, + value, + span: self.ws_span(stmt.span), + target_span: self.ws_span(hir_var.span), + }, + )); + } + } + StorageIntent::Player => { + if let Some(variable) = self.player_vars.get(&var).copied() { + let player = self.out.values.push(wir::ValueNode::new( + wir::Value::EventPlayer, + self.ws_span(stmt.span), + )); + player_actions.push(self.out.actions.push( + wir::Action::SetPlayerVariable { + player, + variable, + value, + span: self.ws_span(stmt.span), + target_span: self.ws_span(hir_var.span), + }, + )); + } + } + _ => self.unsupported( + stmt.span, + "top-level initializer targets a non-Workshop variable", + ), + } + } + if !global_actions.is_empty() { + self.out.rules.push(wir::Rule { + name: "Initialize Global Variables".to_string(), + span: None, + name_span: None, + disabled: false, + event: wir::Event::Global, + conditions: Vec::new(), + actions: global_actions, + }); + } + if !player_actions.is_empty() { + self.out.rules.push(wir::Rule { + name: "Initialize Player Variables".to_string(), + span: None, + name_span: None, + disabled: false, + event: wir::Event::EachPlayer, + conditions: Vec::new(), + actions: player_actions, + }); + } + } + + fn lower_rule(&mut self, rule: &crate::hir::HirRule) { + let diagnostic_count = self.diagnostics.len(); + let Some(event) = rule.event.and_then(|id| self.lower_event(id)) else { + if rule.event.is_none() { + self.unsupported(rule.span, "rule has no canonical Workshop event"); + } + return; + }; + let mut conditions = Vec::new(); + for condition in &rule.conditions { + if let Ok(value) = self.lower_value(condition.expr) { + conditions.push(value); + } + } + let actions = self.lower_actions(&rule.body); + if self.has_new_errors(diagnostic_count) { + return; + } + self.out.rules.push(wir::Rule { + name: rule.name.clone().unwrap_or_default(), + span: self.ws_span(rule.span), + name_span: self.ws_span(rule.span), + disabled: rule.disabled, + event, + conditions, + actions, + }); + } + + fn lower_subroutine(&mut self, fid: HirFuncId) { + let Some(func) = self.hir.funcs.get(fid as usize) else { + return; + }; + let Some(body) = func.body.as_ref() else { + self.unsupported(func.span, format!("subroutine '{}' has no body", func.name)); + return; + }; + let diagnostic_count = self.diagnostics.len(); + let actions = self.lower_actions(body); + if self.has_new_errors(diagnostic_count) { + return; + } + let Some(subroutine) = self.subroutines.get(&fid).copied() else { + return; + }; + self.out.rules.push(wir::Rule { + name: func.name.clone(), + span: self.ws_span(func.span), + name_span: self.ws_span(func.span), + disabled: false, + event: wir::Event::Subroutine(subroutine), + conditions: Vec::new(), + actions, + }); + } + + fn lower_event(&mut self, id: HirExprId) -> Option { + let expr = self.hir.expr(id)?.clone(); + let HirExprKind::External { + binding: Some(ExternalBinding::Event(info)), + .. + } = expr.kind + else { + self.unsupported( + expr.span, + "rule event is not a canonical Workshop event binding", + ); + return None; + }; + match info.canonical_id.as_str() { + "global" => Some(wir::Event::Global), + "eachPlayer" => Some(wir::Event::EachPlayer), + "playerDealtDamage" => Some(self.player_event(wir::PlayerEventKind::DealtDamage)), + "playerDealtFinalBlow" => Some(self.player_event(wir::PlayerEventKind::DealtFinalBlow)), + "playerDealtHealing" => Some(self.player_event(wir::PlayerEventKind::DealtHealing)), + "playerDied" => Some(self.player_event(wir::PlayerEventKind::Died)), + "playerEarnedElimination" => { + Some(self.player_event(wir::PlayerEventKind::EarnedElimination)) + } + "playerJoined" => Some(self.player_event(wir::PlayerEventKind::Joined)), + "playerLeft" => Some(self.player_event(wir::PlayerEventKind::Left)), + "playerReceivedHealing" => { + Some(self.player_event(wir::PlayerEventKind::ReceivedHealing)) + } + "playerTookDamage" => Some(self.player_event(wir::PlayerEventKind::TookDamage)), + "subroutine" => { + self.unsupported( + expr.span, + "subroutine event requires a canonical subroutine reference", + ); + None + } + other => { + self.unsupported( + expr.span, + format!("unsupported canonical Workshop event '{other}'"), + ); + None + } + } + } + + fn player_event(&self, kind: wir::PlayerEventKind) -> wir::Event { + wir::Event::Player { + kind, + team: wir::EventTeam::All, + target: wir::EventTarget::All, + } + } + + fn lower_actions(&mut self, block: &crate::hir::HirBlock) -> Vec { + let mut actions = Vec::new(); + for stmt in &block.stmts { + actions.extend(self.lower_stmt(stmt)); + } + actions + } + + fn lower_stmt(&mut self, stmt: &HirStmt) -> Vec { + match &stmt.kind { + HirStmtKind::Block(block) => self.lower_actions(block), + HirStmtKind::Expr(expr) => self.lower_expr_action(*expr), + HirStmtKind::Assign { target, op, value } => { + self.lower_assignment(*target, *op, *value, stmt.span) + } + HirStmtKind::If { cond, then, els } => { + let Ok(condition) = self.lower_value(*cond) else { + return Vec::new(); + }; + let then_body = self.lower_stmt(then); + let else_body = els.as_ref().map(|body| self.lower_stmt(body)); + vec![self.out.actions.push(wir::Action::If { + branches: vec![wir::IfBranch { + condition, + body: then_body, + }], + else_body, + span: self.ws_span(stmt.span), + })] + } + HirStmtKind::While { cond, body } => { + let Ok(condition) = self.lower_value(*cond) else { + return Vec::new(); + }; + let body = self.lower_stmt(body); + vec![self.out.actions.push(wir::Action::While { + condition, + body, + span: self.ws_span(stmt.span), + })] + } + HirStmtKind::AutoFor { + var, + start, + end, + step, + body, + } => { + let Ok(start) = self.lower_value(*start) else { + return Vec::new(); + }; + let Ok(stop) = self.lower_value(*end) else { + return Vec::new(); + }; + let Ok(step) = self.lower_value(*step) else { + return Vec::new(); + }; + let body = self.lower_stmt(body); + match ( + self.global_vars.get(var).copied(), + self.player_vars.get(var).copied(), + ) { + (Some(variable), _) => { + vec![self.out.actions.push(wir::Action::ForGlobalVariable { + variable, + start, + stop, + step, + body, + span: self.ws_span(stmt.span), + target_span: self + .hir + .vars + .get(*var as usize) + .and_then(|v| self.ws_span(v.span)), + })] + } + (None, Some(variable)) => { + let player = self.out.values.push(wir::ValueNode::new( + wir::Value::EventPlayer, + self.ws_span(stmt.span), + )); + vec![self.out.actions.push(wir::Action::ForPlayerVariable { + player, + variable, + start, + stop, + step, + body, + span: self.ws_span(stmt.span), + })] + } + _ => { + self.unsupported( + stmt.span, + "for-loop variable has no canonical Workshop storage", + ); + Vec::new() + } + } + } + HirStmtKind::VarDecl { .. } => { + self.unsupported( + stmt.span, + "rule-local variable declarations have no canonical WIR storage", + ); + Vec::new() + } + HirStmtKind::For { .. } + | HirStmtKind::Foreach { .. } + | HirStmtKind::Switch { .. } + | HirStmtKind::Return { .. } + | HirStmtKind::Break + | HirStmtKind::Continue + | HirStmtKind::Delete { .. } + | HirStmtKind::Hook { .. } + | HirStmtKind::Error => { + self.unsupported( + stmt.span, + "statement is not supported by the core Workshop lowering", + ); + Vec::new() + } + } + } + + fn lower_expr_action(&mut self, id: HirExprId) -> Vec { + let Some(expr) = self.hir.expr(id).cloned() else { + self.unsupported(self.fallback_span(), format!("unknown HIR expression {id}")); + return Vec::new(); + }; + match expr.kind { + HirExprKind::Assign { target, op, value } => { + self.lower_assignment(target, op, value, expr.span) + } + HirExprKind::Call { target, args } => match target { + CallTarget::External { + binding: Some(ExternalBinding::Action(info)), + .. + } => { + let Ok(args) = self.lower_args(&args) else { + return Vec::new(); + }; + vec![self.out.actions.push(wir::Action::Call { + name: info.canonical_id, + args, + span: self.ws_span(expr.span), + })] + } + CallTarget::Func(fid) => self.call_subroutine(fid, expr.span), + _ => { + self.unsupported( + expr.span, + "expression statement is not a canonical Workshop action", + ); + Vec::new() + } + }, + _ => { + self.unsupported( + expr.span, + "expression statement is not a canonical Workshop action", + ); + Vec::new() + } + } + } + + fn call_subroutine(&mut self, fid: HirFuncId, span: Span) -> Vec { + if let Some(subroutine) = self.subroutines.get(&fid).copied() { + vec![self.out.actions.push(wir::Action::CallSubroutine { + subroutine, + span: self.ws_span(span), + callee_span: self.ws_span(span), + })] + } else { + self.unsupported(span, "call target is not a canonical Workshop subroutine"); + Vec::new() + } + } + + fn lower_assignment( + &mut self, + target: HirExprId, + op: AssignOp, + value: HirExprId, + span: Span, + ) -> Vec { + let Some(target_expr) = self.hir.expr(target).cloned() else { + self.unsupported(span, "assignment target is not a known HIR expression"); + return Vec::new(); + }; + let HirExprKind::VarRef { var } = target_expr.kind else { + self.unsupported(span, "assignment target is not a Workshop variable"); + return Vec::new(); + }; + let Ok(value) = self.lower_value(value) else { + return Vec::new(); + }; + let target_span = self + .hir + .vars + .get(var as usize) + .and_then(|v| self.ws_span(v.span)); + let modify = self.modify_op(op, span); + match ( + self.global_vars.get(&var).copied(), + self.player_vars.get(&var).copied(), + ) { + (Some(variable), _) => { + if let Some(op) = modify { + vec![self.out.actions.push(wir::Action::ModifyGlobalVariable { + variable, + op, + value, + span: self.ws_span(span), + target_span, + })] + } else { + vec![self.out.actions.push(wir::Action::SetGlobalVariable { + variable, + value, + span: self.ws_span(span), + target_span, + })] + } + } + (None, Some(variable)) => { + let player = self.out.values.push(wir::ValueNode::new( + wir::Value::EventPlayer, + self.ws_span(span), + )); + if let Some(op) = modify { + vec![self.out.actions.push(wir::Action::ModifyPlayerVariable { + player, + variable, + op, + value, + span: self.ws_span(span), + target_span, + })] + } else { + vec![self.out.actions.push(wir::Action::SetPlayerVariable { + player, + variable, + value, + span: self.ws_span(span), + target_span, + })] + } + } + _ => { + self.unsupported(span, "assignment target has no canonical Workshop storage"); + Vec::new() + } + } + } + + fn modify_op(&mut self, op: AssignOp, _span: Span) -> Option { + match op { + AssignOp::Assign => None, + AssignOp::Add => Some(wir::ModifyOp::Add), + AssignOp::Sub => Some(wir::ModifyOp::Subtract), + AssignOp::Mul => Some(wir::ModifyOp::Multiply), + AssignOp::Div => Some(wir::ModifyOp::Divide), + AssignOp::Mod => Some(wir::ModifyOp::Modulo), + AssignOp::Pow => Some(wir::ModifyOp::RaiseToPower), + } + } + + fn lower_args(&mut self, args: &[HirArg]) -> Result, ()> { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + match arg { + HirArg::Pos(value) => values.push(self.lower_value(*value)?), + HirArg::Named { .. } => { + let span = self.hir_arg_span(arg); + self.unsupported( + span, + "named Workshop action arguments require canonical parameter ordering", + ); + return Err(()); + } + } + } + Ok(values) + } + + fn lower_value(&mut self, id: HirExprId) -> Result { + let expr = self.hir.expr(id).cloned().ok_or_else(|| { + self.unsupported(self.fallback_span(), format!("unknown HIR expression {id}")); + })?; + let span = self.ws_span(expr.span); + let value = match expr.kind { + HirExprKind::Literal(literal) => match literal { + LiteralValue::Number(value) => wir::Value::Number { + value, + text: format_number(value), + }, + LiteralValue::Str(value) => wir::Value::String(unquote(&value)), + LiteralValue::Bool(value) => wir::Value::Bool(value), + LiteralValue::Null => wir::Value::Null, + }, + HirExprKind::VarRef { var } => { + if let Some(variable) = self.global_vars.get(&var).copied() { + wir::Value::GlobalVariable(variable) + } else if let Some(variable) = self.player_vars.get(&var).copied() { + let player = self + .out + .values + .push(wir::ValueNode::new(wir::Value::EventPlayer, span)); + wir::Value::PlayerVariable { player, variable } + } else { + self.unsupported( + expr.span, + "value references a variable without canonical Workshop storage", + ); + return Err(()); + } + } + HirExprKind::External { + binding: Some(binding), + .. + } => self.value_from_binding(binding, Vec::new(), expr.span)?, + HirExprKind::Call { target, args } => { + let args = self.lower_args(&args)?; + match target { + CallTarget::External { + binding: Some(binding), + .. + } => self.value_from_binding(binding, args, expr.span)?, + CallTarget::BuiltinArrayMethod { member, base } => { + let mut all = vec![self.lower_value(base)?]; + all.extend(args); + let name = match member { + crate::hir::BuiltinArrayMember::Length => "countOf", + crate::hir::BuiltinArrayMember::IndexOf => "indexOfArrayValue", + crate::hir::BuiltinArrayMember::First => "firstOf", + crate::hir::BuiltinArrayMember::Last => "lastOf", + crate::hir::BuiltinArrayMember::Random => "randomValueInArray", + crate::hir::BuiltinArrayMember::Contains => "arrayContains", + crate::hir::BuiltinArrayMember::SortedArray => "sortedArray", + crate::hir::BuiltinArrayMember::FilteredArray => "filteredArray", + _ => { + self.unsupported( + expr.span, + "array method has no canonical core WIR lowering", + ); + return Err(()); + } + }; + wir::Value::Call { + name: name.to_string(), + args: all, + } + } + _ => { + self.unsupported( + expr.span, + "value call has no canonical Workshop value binding", + ); + return Err(()); + } + } + } + HirExprKind::Binary { op, lhs, rhs } => { + let name = binary_name(op); + let args = vec![self.lower_value(lhs)?, self.lower_value(rhs)?]; + wir::Value::Call { + name: name.to_string(), + args, + } + } + HirExprKind::Unary { op, operand } => match op { + UnaryOp::Negate => { + let operand = self.lower_value(operand)?; + let minus_one = self.out.values.push(wir::ValueNode::new( + wir::Value::Number { + value: -1.0, + text: "-1".to_string(), + }, + span, + )); + wir::Value::Call { + name: "multiply".to_string(), + args: vec![minus_one, operand], + } + } + UnaryOp::Not => wir::Value::Call { + name: "not".to_string(), + args: vec![self.lower_value(operand)?], + }, + UnaryOp::Indirect => { + self.unsupported(expr.span, "Workshop indirection has no core WIR lowering"); + return Err(()); + } + }, + HirExprKind::ArrayLit { elems } => { + let mut values = Vec::with_capacity(elems.len()); + for elem in elems { + values.push(self.lower_value(elem)?); + } + wir::Value::Array(values) + } + HirExprKind::Index { base, index } => wir::Value::Call { + name: "valueInArray".to_string(), + args: vec![self.lower_value(base)?, self.lower_value(index)?], + }, + HirExprKind::Ternary { cond, then, els } => wir::Value::Call { + name: "ifThenElse".to_string(), + args: vec![ + self.lower_value(cond)?, + self.lower_value(then)?, + self.lower_value(els)?, + ], + }, + HirExprKind::Convert { from, .. } | HirExprKind::Cast { expr: from, .. } => { + return self.lower_value(from); + } + HirExprKind::StrInterp { .. } + | HirExprKind::Assign { .. } + | HirExprKind::Member { .. } + | HirExprKind::FunctionValue { .. } + | HirExprKind::New { .. } + | HirExprKind::StructLit { .. } + | HirExprKind::EnumCtor { .. } + | HirExprKind::Async { .. } + | HirExprKind::This { .. } + | HirExprKind::Postfix { .. } + | HirExprKind::Error + | HirExprKind::External { binding: None, .. } => { + self.unsupported( + expr.span, + "expression has no core canonical Workshop value lowering", + ); + return Err(()); + } + }; + Ok(self.out.values.push(wir::ValueNode::new(value, span))) + } + + fn value_from_binding( + &mut self, + binding: ExternalBinding, + args: Vec, + span: Span, + ) -> Result { + match binding { + ExternalBinding::Value(info) => { + if let Some((value_type, value)) = info.canonical_id.split_once('.') { + if args.is_empty() { + return Ok(wir::Value::Enum { + value_type: value_type.to_string(), + value: value.to_string(), + }); + } + } + Ok(wir::Value::Call { + name: info.canonical_id, + args, + }) + } + ExternalBinding::Type(info) if info.constant => { + self.unsupported(span, "enum domain reference is not a canonical enum member"); + Err(()) + } + other => { + self.unsupported( + span, + format!( + "binding {:?} cannot be lowered as a Workshop value", + binding_kind(&other) + ), + ); + Err(()) + } + } + } + + fn validate_output(&mut self) { + if let Err(error) = self.out.validate() { + self.unsupported( + self.fallback_span(), + format!("canonical WIR validation failed: {error}"), + ); + } + if let Ok(catalog) = Catalog::builtin() { + if let Err(error) = workshop_rs::validate::validate_canonical_ids(&self.out, &catalog) { + self.unsupported( + self.fallback_span(), + format!("canonical Workshop identity validation failed: {error}"), + ); + } + } + } + + fn unsupported(&mut self, span: Span, message: impl Into) { + self.diagnostics + .push(error(Phase::Hir, "HI018", span, message)); + } + + fn has_new_errors(&self, start: usize) -> bool { + self.diagnostics + .get(start..) + .unwrap_or_default() + .iter() + .any(Diagnostic::is_error) + } + + fn fallback_span(&self) -> Span { + Span::new(FileId(0), 0, 0) + } + + fn ws_span(&self, span: Span) -> Option { + let source = self.sources.files().nth(span.file.0 as usize)?; + let start = self.sources.line_col(span, span.start); + let end = self.sources.line_col(span, span.end); + let file = workshop_rs::ids::Id::from_index(span.file.0 as usize); + let _ = source; + Some(WorkshopSpan::new( + file, + Position::new(start.line, start.col), + Position::new(end.line, end.col), + )) + } + + fn hir_arg_span(&self, arg: &HirArg) -> Span { + let id = match arg { + HirArg::Pos(id) | HirArg::Named { value: id, .. } => *id, + }; + self.hir + .expr(id) + .map(|e| e.span) + .unwrap_or_else(|| self.fallback_span()) + } +} + +fn binary_name(op: BinaryOp) -> &'static str { + match op { + BinaryOp::Add => "add", + BinaryOp::Sub => "subtract", + BinaryOp::Mul => "multiply", + BinaryOp::Div => "divide", + BinaryOp::Mod => "modulo", + BinaryOp::Pow => "raiseToPower", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::And => "and", + BinaryOp::Or => "or", + } +} + +fn format_number(value: f64) -> String { + if value.fract() == 0.0 { + format!("{value:.0}") + } else { + value.to_string() + } +} + +fn unquote(value: &str) -> String { + if value.len() >= 2 && (value.starts_with('"') || value.starts_with('\'')) { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + } +} + +fn binding_kind(binding: &ExternalBinding) -> &'static str { + match binding { + ExternalBinding::Value(_) => "value", + ExternalBinding::Action(_) => "action", + ExternalBinding::Event(_) => "event", + ExternalBinding::Type(_) => "type", + ExternalBinding::Namespace => "namespace", + } +} diff --git a/tests/workshop_lowering.rs b/tests/workshop_lowering.rs new file mode 100644 index 0000000..48ab4cb --- /dev/null +++ b/tests/workshop_lowering.rs @@ -0,0 +1,89 @@ +//! Core DEL HIR -> canonical Workshop WIR lowering evidence for #30. + +use del_rs::hir; +use del_rs::project::{load_project, ProjectOptions}; +use del_rs::semantic::check_project; +use del_rs::semantic::provider::CatalogProvider; +use del_rs::workshop::lower_to_wir; +use std::path::PathBuf; + +fn lower(text: &str) -> (workshop_rs::wir::Program, Vec) { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "del-rs-workshop-lowering-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("main.del"), text).unwrap(); + let project = load_project(ProjectOptions { + root, + entry: Some(PathBuf::from("main.del")), + config: None, + }); + let provider = CatalogProvider::new().expect("canonical catalog provider"); + let semantic = check_project(&project, &provider); + let mut diagnostics = semantic.diagnostics.clone(); + let (hir, hir_diags) = hir::lower::lower(&semantic); + diagnostics.extend(hir_diags); + let (program, lowering_diags) = lower_to_wir(&hir, &semantic.project.sources); + diagnostics.extend(lowering_diags); + (program, diagnostics) +} + +#[test] +fn core_rule_lowering_preserves_canonical_ids_and_provenance() { + let (program, diagnostics) = lower( + r#" +globalvar Number score = 1; +rule: "damage" Event.OnDamageDealt if (score > 0) { + score += 2; +} +"#, + ); + assert!( + diagnostics.iter().all(|diagnostic| !diagnostic.is_error()), + "{diagnostics:?}" + ); + program.validate().expect("structurally valid WIR"); + assert_eq!(program.global_variables.len(), 1); + assert_eq!( + program + .global_variables + .get(workshop_rs::wir::GlobalVarId::from_index(0)) + .unwrap() + .index, + 0 + ); + assert_eq!(program.rules.len(), 2); + let rule = program + .rules + .get(workshop_rs::wir::RuleId::from_index(1)) + .unwrap(); + assert!(matches!( + rule.event, + workshop_rs::wir::Event::Player { + kind: workshop_rs::wir::PlayerEventKind::DealtDamage, + .. + } + )); + assert_eq!(rule.conditions.len(), 1); + assert_eq!(rule.actions.len(), 1); + assert!(rule.span.is_some()); + assert!(program.dump().contains("PlayerDealtDamage")); +} + +#[test] +fn unsupported_rule_local_storage_is_structured_and_not_silently_dropped() { + let (program, diagnostics) = lower( + r#" +rule: "unsupported" Event.OngoingGlobal { + define local = 1; +} +"#, + ); + assert!(program.rules.is_empty()); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "HI018")); +} diff --git a/tests/workshop_provider.rs b/tests/workshop_provider.rs index 85af21c..f1c3e04 100644 --- a/tests/workshop_provider.rs +++ b/tests/workshop_provider.rs @@ -41,6 +41,29 @@ fn catalog_provider_resolves_del_event_names_to_canonical_ids() { assert_eq!(event.context, Some(EventContext::Player)); } +#[test] +fn catalog_provider_resolves_the_expanded_del_event_inventory() { + let provider = CatalogProvider::new().expect("built-in catalog"); + for (source, canonical) in [ + ("OnElimination", "playerEarnedElimination"), + ("OnFinalBlow", "playerDealtFinalBlow"), + ("OnDamageDealt", "playerDealtDamage"), + ("OnDamageTaken", "playerTookDamage"), + ("OnDeath", "playerDied"), + ("OnHealingDealt", "playerDealtHealing"), + ("OnHealingTaken", "playerReceivedHealing"), + ("OnPlayerJoin", "playerJoined"), + ("OnPlayerLeave", "playerLeft"), + ] { + let result = provider.resolve(&query(&["Event"], source, ExternalPosition::Value, 0)); + let ExternalResolution::Known(ExternalBinding::Event(event)) = result else { + panic!("expected canonical event binding for {source}"); + }; + assert_eq!(event.canonical_id, canonical); + assert_eq!(event.context, Some(EventContext::Player)); + } +} + #[test] fn catalog_provider_resolves_enum_member_without_copying_catalog_data() { let provider = CatalogProvider::new().expect("built-in catalog"); From 23ff25f4638416cdbf109e9a5bcf5596d50bf77f Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 19:32:13 +0800 Subject: [PATCH 03/11] chore: pin workshop-rs event contract head --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25bfdc5..3877419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,7 +264,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "workshop-rs" version = "0.1.0" -source = "git+https://github.com/wrightkit/workshop-rs.git?rev=5d9d77250462898d00e970d51141bcd87a44b8f3#5d9d77250462898d00e970d51141bcd87a44b8f3" +source = "git+https://github.com/wrightkit/workshop-rs.git?rev=14a15347211c3c334087008b09e21d0501242f18#14a15347211c3c334087008b09e21d0501242f18" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dc06cd6..9977882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/wrightkit/del-rs" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "1.1" -workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "5d9d77250462898d00e970d51141bcd87a44b8f3", package = "workshop-rs" } +workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "14a15347211c3c334087008b09e21d0501242f18", package = "workshop-rs" } [[bin]] name = "del-rs" From 4ee72b1761f47f84e57a28074309b9bf66f66e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:31:29 +0800 Subject: [PATCH 04/11] fix(lowering): use released workshop-rs crate --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9977882..a4fff57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/wrightkit/del-rs" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "1.1" -workshop-rs = { git = "https://github.com/wrightkit/workshop-rs.git", rev = "14a15347211c3c334087008b09e21d0501242f18", package = "workshop-rs" } +workshop-rs = "0.1.1" [[bin]] name = "del-rs" From ed608ef6882e34d44151fee2aba0e2ecb5de86cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:31:55 +0800 Subject: [PATCH 05/11] fix(lowering): lock released workshop-rs 0.1.1 --- Cargo.lock | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3877419..a5550b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,8 +263,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "workshop-rs" -version = "0.1.0" -source = "git+https://github.com/wrightkit/workshop-rs.git?rev=14a15347211c3c334087008b09e21d0501242f18#14a15347211c3c334087008b09e21d0501242f18" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad536c30d7c4765f71b6def933d06b10cb8aa8f6668da7196cda4c3fefcf5ca" dependencies = [ "serde", "serde_json", From 33c7570c2f53f1caff7b46404852fa08c4277ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:32:24 +0800 Subject: [PATCH 06/11] fix(lowering): drop SCM revision identity --- src/semantic/provider.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index 7b36530..4cbf8d8 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -7,10 +7,6 @@ use crate::span::{FileId, Span}; use workshop_rs::catalog::{Catalog, CatalogEntry, Kind, Locale}; use workshop_rs::WorkshopError; -/// The reviewed canonical Workshop dependency baseline consumed by this -/// integration slice. Keep this in sync with the git revision in `Cargo.toml`. -pub const WORKSHOP_RS_REVISION: &str = "5d9d77250462898d00e970d51141bcd87a44b8f3"; - /// Position a query name is used in. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ExternalPosition { @@ -149,7 +145,7 @@ pub struct CatalogProvider { } impl CatalogProvider { - /// Load the built-in catalog at the reviewed `workshop-rs` revision. + /// Load the built-in catalog from the released `workshop-rs` dependency. pub fn new() -> Result { Self::from_catalog(Catalog::builtin()?, Locale::new("en-US")) } From 5406344b1cc4cd9bf52a64ea7b1201d65d992cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:32:38 +0800 Subject: [PATCH 07/11] test(lowering): use catalog identity instead of git revision --- tests/workshop_provider.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/workshop_provider.rs b/tests/workshop_provider.rs index f1c3e04..4610cba 100644 --- a/tests/workshop_provider.rs +++ b/tests/workshop_provider.rs @@ -1,6 +1,6 @@ use del_rs::semantic::provider::{ CatalogProvider, EventContext, ExternalBinding, ExternalPosition, ExternalResolution, - NameQuery, WorkshopProvider, WORKSHOP_RS_REVISION, + NameQuery, WorkshopProvider, }; use del_rs::{FileId, Span}; @@ -75,10 +75,9 @@ fn catalog_provider_resolves_enum_member_without_copying_catalog_data() { } #[test] -fn catalog_provider_rejects_excess_arguments_and_exposes_identity() { +fn catalog_provider_rejects_excess_arguments_and_exposes_catalog_identity() { let provider = CatalogProvider::new().expect("built-in catalog"); let result = provider.resolve(&query(&[], "Wait", ExternalPosition::Value, 3)); assert!(matches!(result, ExternalResolution::DefiniteError(_))); - assert_eq!(WORKSHOP_RS_REVISION.len(), 40); assert_eq!(provider.catalog_identity().catalog_version, "0.1.0"); } From 59e71499d0b6f8afbd1999588b2519d878d197a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:32:56 +0800 Subject: [PATCH 08/11] docs(lowering): reference released workshop-rs --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ba7234f..d4581e8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ evidence.[^upstream-reference] | Lambdas & closures | โœ… Supported | | | Pattern matching & recursion | โœ… Supported | | | Embedded Workshop / lobby data | ๐ŸŸก Partial | Vanilla Workshop blocks parse; lobby-settings import not yet | -| Workshop builtins | ๐ŸŸก Partial | `CatalogProvider` resolves the pinned canonical catalog; lowering is tracked separately | +| Workshop builtins | ๐ŸŸก Partial | `CatalogProvider` resolves the released canonical `workshop-rs` catalog; lowering is tracked separately | | DEL/OSTW โ†’ Workshop compilation | โณ Not yet | Requires `workshop-rs` integration | | Workshop โ†’ DEL/OSTW reconstruction | โณ Not yet | | @@ -60,9 +60,10 @@ meanings. ## Building -Requirements: Rust 1.85+ (edition 2021). The crate consumes the pinned -canonical `workshop-rs` catalog in addition to `serde`, `serde_json`, and -`toml`; Workshop-independent workflows can continue using `NoopProvider`. +Requirements: Rust 1.85+ (edition 2021). The crate consumes the released +`workshop-rs 0.1.1` library from crates.io in addition to `serde`, +`serde_json`, and `toml`; Workshop-independent workflows can continue using +`NoopProvider`. ```sh cargo build --release From 43eedad79ebc1f309100958bf2a88f34718366f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:33:08 +0800 Subject: [PATCH 09/11] docs(lowering): remove git-pin wording --- docs/limitations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/limitations.md b/docs/limitations.md index dfe9831..436fb61 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -17,7 +17,7 @@ approximation areas. optimizer choices. The typed HIR expresses intent only (`architecture.md` ยง15). - Canonical Workshop catalog data (actions/values/events/constants): - `del-rs` never vendors it; `CatalogProvider` reads the pinned + `del-rs` never vendors it; `CatalogProvider` reads the released `workshop-rs` catalog through the documented `WorkshopProvider` seam. `NoopProvider` remains available for Workshop-independent workflows. - Vanilla Workshop superset bodies (`rule("...")`, `variables {}`, From a160a722579c7e628561f17001b4e6dcdb02f796 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:41:04 +0800 Subject: [PATCH 10/11] fix(lowering): preserve exact rule name provenance Refs #30 --- src/hir/lower.rs | 29 ++++++++++++++++++++++++++--- src/hir/mod.rs | 2 ++ src/workshop.rs | 2 +- tests/workshop_lowering.rs | 7 +++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hir/lower.rs b/src/hir/lower.rs index bda6580..b6890c7 100644 --- a/src/hir/lower.rs +++ b/src/hir/lower.rs @@ -361,6 +361,7 @@ impl<'a> Lowerer<'a> { let body = self.lower_stmt_block(&r.body); self.hir.rules.push(HirRule { name: Some(r.name.name_text()), + name_span: string_content_span(&r.name), disabled: r.disabled, sort_order: r.sort_order.as_ref().and_then(number_i64), event, @@ -373,6 +374,7 @@ impl<'a> Lowerer<'a> { ItemKind::VanillaRule(v) => { self.hir.rules.push(HirRule { name: v.name.as_ref().map(|e| e.name_text()), + name_span: v.name.as_ref().and_then(string_content_span), disabled: false, sort_order: None, event: None, @@ -1153,9 +1155,8 @@ impl NameText for Expr { fn name_text(&self) -> String { match &self.kind { ExprKind::Str(s) => { - let len = s.raw.len(); - if len >= 2 { - s.raw[1..len - 1].to_string() + if let Some((start, end)) = string_content_bounds(s) { + s.raw[start..end].to_string() } else { s.raw.clone() } @@ -1164,3 +1165,25 @@ impl NameText for Expr { } } } + +fn string_content_span(expr: &Expr) -> Option { + let ExprKind::Str(string) = &expr.kind else { + return None; + }; + let (start, end) = string_content_bounds(string)?; + Some(Span::new( + expr.span.file, + expr.span.start + start as u32, + expr.span.start + end as u32, + )) +} + +fn string_content_bounds(string: &crate::syntax::ast::StrLit) -> Option<(usize, usize)> { + let prefix = usize::from(matches!( + string.quote, + crate::syntax::ast::QuoteKind::Localized | crate::syntax::ast::QuoteKind::Interpolated + )); + let start = prefix + 1; + let end = string.raw.len().checked_sub(1)?; + (start <= end).then_some((start, end)) +} diff --git a/src/hir/mod.rs b/src/hir/mod.rs index 26fcba6..8fe26a4 100644 --- a/src/hir/mod.rs +++ b/src/hir/mod.rs @@ -161,6 +161,8 @@ pub struct HirReservation { #[derive(Clone, Debug)] pub struct HirRule { pub name: Option, + /// The exact source span of the rule name inside its string literal. + pub name_span: Option, pub disabled: bool, pub sort_order: Option, pub event: Option, diff --git a/src/workshop.rs b/src/workshop.rs index 02f6094..6776bc6 100644 --- a/src/workshop.rs +++ b/src/workshop.rs @@ -297,7 +297,7 @@ impl<'a> Lowerer<'a> { self.out.rules.push(wir::Rule { name: rule.name.clone().unwrap_or_default(), span: self.ws_span(rule.span), - name_span: self.ws_span(rule.span), + name_span: rule.name_span.and_then(|span| self.ws_span(span)), disabled: rule.disabled, event, conditions, diff --git a/tests/workshop_lowering.rs b/tests/workshop_lowering.rs index 48ab4cb..4c549b7 100644 --- a/tests/workshop_lowering.rs +++ b/tests/workshop_lowering.rs @@ -70,6 +70,13 @@ rule: "damage" Event.OnDamageDealt if (score > 0) { assert_eq!(rule.conditions.len(), 1); assert_eq!(rule.actions.len(), 1); assert!(rule.span.is_some()); + let name_span = rule.name_span.expect("rule name provenance"); + assert_eq!(name_span.file.index(), 0); + assert_eq!(name_span.start.line, 3); + assert_eq!(name_span.start.col, 8); + assert_eq!(name_span.end.line, 3); + assert_eq!(name_span.end.col, 14); + assert_ne!(name_span, rule.span.unwrap()); assert!(program.dump().contains("PlayerDealtDamage")); } From ef74b21cf24834226b11dd135cc7a929904f228a Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:56:57 +0800 Subject: [PATCH 11/11] fix(hir): keep provider bindings out of backend-neutral HIR Move canonical provider bindings into the DEL-owned lowering context and fail closed with HI018 when HIR is lowered without semantic resolution. Refs #30 --- src/hir/lower.rs | 18 +- src/hir/mod.rs | 6 +- src/workshop.rs | 424 +++++++++++++++++++++++++++++++++++-- tests/workshop_lowering.rs | 55 ++++- 4 files changed, 462 insertions(+), 41 deletions(-) diff --git a/src/hir/lower.rs b/src/hir/lower.rs index b6890c7..7e512f2 100644 --- a/src/hir/lower.rs +++ b/src/hir/lower.rs @@ -709,15 +709,13 @@ impl<'a> Lowerer<'a> { HirExprKind::External { name: self.ident_name(e), namespace: Vec::new(), - binding: self.external_binding(e.id), } } ExprKind::Member { base, name } => { - if let Some(binding) = self.external_binding(e.id) { + if matches!(self.program.resolution.get(&e.id), Some(Resolution::External(_))) { HirExprKind::External { name: name.name.clone(), namespace: self.member_namespace(base), - binding: Some(binding), } } else { HirExprKind::Member { @@ -793,7 +791,6 @@ impl<'a> Lowerer<'a> { HirExprKind::External { name: String::new(), namespace: Vec::new(), - binding: None, } } ExprKind::This => HirExprKind::This { class: 0 }, @@ -931,7 +928,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: id.name.clone(), namespace: Vec::new(), - binding: self.external_binding(call.callee.id), + span: call.callee.span, }, args, } @@ -940,7 +937,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: id.name.clone(), namespace: Vec::new(), - binding: self.external_binding(call.callee.id), + span: call.callee.span, }, args, }, @@ -1008,7 +1005,7 @@ impl<'a> Lowerer<'a> { target: CallTarget::External { name: name.name.clone(), namespace: self.member_namespace(base), - binding: self.external_binding(call.callee.id), + span: call.callee.span, }, args, }, @@ -1033,13 +1030,6 @@ impl<'a> Lowerer<'a> { } } - fn external_binding(&self, node: NodeId) -> Option { - match self.program.resolution.get(&node) { - Some(Resolution::External(binding)) => Some(binding.clone()), - _ => None, - } - } - fn lower_arg(&mut self, a: &Arg) -> HirArg { match &a.name { Some(n) => HirArg::Named { diff --git a/src/hir/mod.rs b/src/hir/mod.rs index 8fe26a4..081eb91 100644 --- a/src/hir/mod.rs +++ b/src/hir/mod.rs @@ -5,7 +5,6 @@ //! intent โ€” without any Workshop encoding (no slots, no helper rules, no //! reference bit layouts). -use crate::semantic::provider::ExternalBinding; use crate::semantic::types::Type; use crate::span::Span; use std::collections::HashMap; @@ -269,7 +268,6 @@ pub enum HirExprKind { External { name: String, namespace: Vec, - binding: Option, }, Error, } @@ -308,7 +306,9 @@ pub enum CallTarget { External { name: String, namespace: Vec, - binding: Option, + /// The source span of the external callee. Provider bindings are + /// resolved later by the DEL-owned lowering context. + span: Span, }, } diff --git a/src/workshop.rs b/src/workshop.rs index 6776bc6..dcbf556 100644 --- a/src/workshop.rs +++ b/src/workshop.rs @@ -11,8 +11,12 @@ use crate::hir::{ }; use crate::project::Project; use crate::semantic::provider::ExternalBinding; +use crate::semantic::resolve::Resolution; +use crate::semantic::SemanticProgram; use crate::span::{FileId, SourceMap, Span}; -use crate::syntax::ast::{AssignOp, BinaryOp, UnaryOp}; +use crate::syntax::ast::{ + self, AssignOp, BinaryOp, Expr, ExprKind, FuncBody, Item, ItemKind, Stmt, StmtKind, UnaryOp, +}; use std::collections::{HashMap, HashSet}; use workshop_rs::catalog::Catalog; use workshop_rs::source::{Position, SourceFile, Span as WorkshopSpan}; @@ -25,7 +29,7 @@ use workshop_rs::wir; /// fail-closed: an unsupported construct never becomes a successful but /// semantically incomplete WIR node. pub fn lower_to_wir(hir: &HirProgram, sources: &SourceMap) -> (wir::Program, Vec) { - Lowerer::new(hir, sources).run() + Lowerer::new(hir, sources, None).run() } /// Convenience entry point for callers that still own the checked semantic @@ -35,11 +39,21 @@ pub fn lower_project_to_wir( semantic: &crate::semantic::SemanticProgram, ) -> (wir::Program, Vec) { let (hir, mut diagnostics) = crate::hir::lower::lower(semantic); - let (program, mut lowering) = lower_to_wir(&hir, &semantic.project.sources); + let context = WorkshopLoweringContext::from_semantic(semantic); + let (program, mut lowering) = + lower_to_wir_with_context(&hir, &semantic.project.sources, &context); diagnostics.append(&mut lowering); (program, diagnostics) } +fn lower_to_wir_with_context( + hir: &HirProgram, + sources: &SourceMap, + context: &WorkshopLoweringContext, +) -> (wir::Program, Vec) { + Lowerer::new(hir, sources, Some(context)).run() +} + /// Lower a checked project directly. This preserves the public project /// boundary without making the WIR backend depend on the semantic provider. pub fn lower_project( @@ -50,9 +64,309 @@ pub fn lower_project( lower_project_to_wir(&semantic) } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ExternalKey { + span: Span, + name: String, + namespace: Vec, +} + +/// DEL-owned bridge from semantic provider resolution to canonical WIR +/// lowering. Provider bindings deliberately never cross into backend-neutral +/// HIR. +struct WorkshopLoweringContext { + bindings: HashMap, +} + +impl WorkshopLoweringContext { + fn from_semantic(semantic: &SemanticProgram) -> Self { + let mut context = Self { + bindings: HashMap::new(), + }; + for ast in semantic.asts.values() { + for item in &ast.items { + context.collect_item(item, semantic); + } + } + context + } + + fn lookup(&self, span: Span, name: &str, namespace: &[String]) -> Option { + self.bindings + .get(&ExternalKey { + span, + name: name.to_string(), + namespace: namespace.to_vec(), + }) + .cloned() + } + + fn collect_item(&mut self, item: &Item, semantic: &SemanticProgram) { + match &item.kind { + ItemKind::Rule(rule) => { + self.collect_expr(&rule.name, semantic); + if let Some(sort_order) = &rule.sort_order { + self.collect_expr(sort_order, semantic); + } + for setting in &rule.settings { + self.collect_expr(setting, semantic); + } + if let Some(event) = &rule.event { + self.collect_expr(event, semantic); + } + for condition in &rule.conditions { + self.collect_expr(&condition.expr, semantic); + } + self.collect_stmt(&rule.body, semantic); + } + ItemKind::VanillaRule(rule) => { + if let Some(name) = &rule.name { + self.collect_expr(name, semantic); + } + } + ItemKind::Var(var) => self.collect_var(var, semantic), + ItemKind::Function(function) => self.collect_function(function, semantic), + ItemKind::TypeDecl(decl) => { + for member in &decl.members { + match &member.kind { + ast::MemberDeclKind::Field(var) => self.collect_var(var, semantic), + ast::MemberDeclKind::Method(function) => { + self.collect_function(function, semantic) + } + ast::MemberDeclKind::Constructor(constructor) => { + if let Some(subroutine) = &constructor.subroutine { + self.collect_expr(subroutine, semantic); + } + self.collect_block(&constructor.body, semantic); + } + ast::MemberDeclKind::EnumMember(member) => { + if let Some(discriminant) = &member.discriminant { + self.collect_expr(discriminant, semantic); + } + } + } + } + } + ItemKind::Import(import) => self.collect_expr(&import.path, semantic), + ItemKind::VarReservation(reservation) => { + for name in &reservation.names { + self.collect_expr(name, semantic); + } + } + ItemKind::Hook { target, value } => { + self.collect_expr(target, semantic); + self.collect_expr(value, semantic); + } + ItemKind::VanillaBlock(_) | ItemKind::TypeAlias(_) | ItemKind::Error { .. } => {} + } + } + + fn collect_function(&mut self, function: &ast::FunctionDecl, semantic: &SemanticProgram) { + if let Some(subroutine) = &function.attrs.subroutine { + self.collect_expr(&subroutine.rule_name, semantic); + } + for param in &function.params { + if let Some(default) = ¶m.default { + self.collect_expr(default, semantic); + } + } + match &function.body { + FuncBody::Block(block) => self.collect_block(block, semantic), + FuncBody::Expr(expr) => self.collect_expr(expr, semantic), + FuncBody::None => {} + } + } + + fn collect_var(&mut self, var: &ast::VarDecl, semantic: &SemanticProgram) { + if let Some(var_id) = &var.var_id { + self.collect_expr(var_id, semantic); + } + if let Some((_, init)) = &var.init { + self.collect_expr(init, semantic); + } + } + + fn collect_block(&mut self, block: &ast::BlockStmt, semantic: &SemanticProgram) { + for stmt in &block.stmts { + self.collect_stmt(stmt, semantic); + } + } + + fn collect_stmt(&mut self, stmt: &Stmt, semantic: &SemanticProgram) { + match &stmt.kind { + StmtKind::Block(block) => self.collect_block(block, semantic), + StmtKind::Var(var) => self.collect_var(var, semantic), + StmtKind::If { cond, then, els } => { + self.collect_expr(cond, semantic); + self.collect_stmt(then, semantic); + if let Some(els) = els { + self.collect_stmt(els, semantic); + } + } + StmtKind::While { cond, body } => { + self.collect_expr(cond, semantic); + self.collect_stmt(body, semantic); + } + StmtKind::For(for_stmt) => { + if let Some(init) = &for_stmt.init { + self.collect_stmt(init, semantic); + } + if let Some(cond) = &for_stmt.cond { + self.collect_expr(cond, semantic); + } + if let Some(step) = &for_stmt.step { + self.collect_stmt(step, semantic); + } + self.collect_stmt(&for_stmt.body, semantic); + } + StmtKind::Foreach { + collection, body, .. + } => { + self.collect_expr(collection, semantic); + self.collect_stmt(body, semantic); + } + StmtKind::Switch(switch) => { + self.collect_expr(&switch.scrutinee, semantic); + for arm in &switch.arms { + if let Some(label) = &arm.label { + self.collect_expr(label, semantic); + } + for stmt in &arm.stmts { + self.collect_stmt(stmt, semantic); + } + } + } + StmtKind::Return { value } => { + if let Some(value) = value { + self.collect_expr(value, semantic); + } + } + StmtKind::Expr(expr) => self.collect_expr(expr, semantic), + StmtKind::Delete { target } => self.collect_expr(target, semantic), + StmtKind::Hook { target, value } => { + self.collect_expr(target, semantic); + self.collect_expr(value, semantic); + } + StmtKind::Break | StmtKind::Continue | StmtKind::Error { .. } => {} + } + } + + fn collect_expr(&mut self, expr: &Expr, semantic: &SemanticProgram) { + if let Some(Resolution::External(binding)) = semantic.resolution.get(&expr.id) { + if let Some((name, namespace)) = external_name(expr) { + self.bindings.insert( + ExternalKey { + span: expr.span, + name, + namespace, + }, + binding.clone(), + ); + } + } + match &expr.kind { + ExprKind::Member { base, .. } => self.collect_expr(base, semantic), + ExprKind::Index { base, index } => { + self.collect_expr(base, semantic); + self.collect_expr(index, semantic); + } + ExprKind::Call(call) => { + self.collect_expr(&call.callee, semantic); + for arg in &call.args { + self.collect_expr(&arg.value, semantic); + } + } + ExprKind::Unary { operand, .. } + | ExprKind::Cast { expr: operand, .. } + | ExprKind::Async { call: operand, .. } + | ExprKind::Postfix { operand, .. } => self.collect_expr(operand, semantic), + ExprKind::Binary { lhs, rhs, .. } + | ExprKind::Assign { + target: lhs, + value: rhs, + .. + } => { + self.collect_expr(lhs, semantic); + self.collect_expr(rhs, semantic); + } + ExprKind::Ternary { cond, then, els } => { + self.collect_expr(cond, semantic); + self.collect_expr(then, semantic); + self.collect_expr(els, semantic); + } + ExprKind::New { args, .. } => { + for arg in args { + self.collect_expr(&arg.value, semantic); + } + } + ExprKind::ArrayLit { elems } => { + for elem in elems { + self.collect_expr(elem, semantic); + } + } + ExprKind::StructLit(struct_lit) => { + for field in &struct_lit.fields { + self.collect_expr(&field.value, semantic); + } + if let Some(base) = &struct_lit.base { + self.collect_expr(base, semantic); + } + if let Some(value) = &struct_lit.single_value { + self.collect_expr(value, semantic); + } + } + ExprKind::Lambda(lambda) => match &lambda.body { + ast::LambdaBody::Expr(expr) => self.collect_expr(expr, semantic), + ast::LambdaBody::Block(block) => self.collect_block(block, semantic), + }, + ExprKind::StrInterp { args, .. } | ExprKind::Interp { args, .. } => { + for arg in args { + self.collect_expr(arg, semantic); + } + } + ExprKind::Is { operand, .. } => self.collect_expr(operand, semantic), + ExprKind::JsonImport { path, .. } => self.collect_expr(path, semantic), + ExprKind::VanillaTarget { index, .. } => { + if let Some(index) = index { + self.collect_expr(index, semantic); + } + } + ExprKind::Number(_) + | ExprKind::Str(_) + | ExprKind::Bool(_) + | ExprKind::Null + | ExprKind::Ident(_) + | ExprKind::This + | ExprKind::Root + | ExprKind::Error { .. } => {} + } + } +} + +fn external_name(expr: &Expr) -> Option<(String, Vec)> { + match &expr.kind { + ExprKind::Ident(ident) => Some((ident.name.clone(), Vec::new())), + ExprKind::Member { base, name } => Some((name.name.clone(), member_namespace(base))), + _ => None, + } +} + +fn member_namespace(base: &Expr) -> Vec { + match &base.kind { + ExprKind::Ident(ident) => vec![ident.name.clone()], + ExprKind::Member { base, name } => { + let mut namespace = member_namespace(base); + namespace.push(name.name.clone()); + namespace + } + _ => Vec::new(), + } +} + struct Lowerer<'a> { hir: &'a HirProgram, sources: &'a SourceMap, + context: Option<&'a WorkshopLoweringContext>, out: wir::Program, global_vars: HashMap, player_vars: HashMap, @@ -63,7 +377,11 @@ struct Lowerer<'a> { } impl<'a> Lowerer<'a> { - fn new(hir: &'a HirProgram, sources: &'a SourceMap) -> Self { + fn new( + hir: &'a HirProgram, + sources: &'a SourceMap, + context: Option<&'a WorkshopLoweringContext>, + ) -> Self { let mut out = wir::Program::default(); for source in sources.files() { out.files @@ -72,6 +390,7 @@ impl<'a> Lowerer<'a> { Self { hir, sources, + context, out, global_vars: HashMap::new(), player_vars: HashMap::new(), @@ -334,11 +653,17 @@ impl<'a> Lowerer<'a> { fn lower_event(&mut self, id: HirExprId) -> Option { let expr = self.hir.expr(id)?.clone(); - let HirExprKind::External { - binding: Some(ExternalBinding::Event(info)), - .. - } = expr.kind - else { + let HirExprKind::External { name, namespace } = expr.kind else { + self.unsupported( + expr.span, + "rule event is not a canonical Workshop event binding", + ); + return None; + }; + let Some(binding) = self.external_binding(expr.span, &name, &namespace) else { + return None; + }; + let ExternalBinding::Event(info) = binding else { self.unsupported( expr.span, "rule event is not a canonical Workshop event binding", @@ -523,9 +848,21 @@ impl<'a> Lowerer<'a> { } HirExprKind::Call { target, args } => match target { CallTarget::External { - binding: Some(ExternalBinding::Action(info)), - .. + name, + namespace, + span: callee_span, } => { + let Some(binding) = self.external_binding(callee_span, &name, &namespace) + else { + return Vec::new(); + }; + let ExternalBinding::Action(info) = binding else { + self.unsupported( + expr.span, + "expression statement is not a canonical Workshop action", + ); + return Vec::new(); + }; let Ok(args) = self.lower_args(&args) else { return Vec::new(); }; @@ -706,17 +1043,26 @@ impl<'a> Lowerer<'a> { return Err(()); } } - HirExprKind::External { - binding: Some(binding), - .. - } => self.value_from_binding(binding, Vec::new(), expr.span)?, + HirExprKind::External { name, namespace } => { + let Some(binding) = self.external_binding(expr.span, &name, &namespace) else { + return Err(()); + }; + self.value_from_binding(binding, Vec::new(), expr.span)? + } HirExprKind::Call { target, args } => { let args = self.lower_args(&args)?; match target { CallTarget::External { - binding: Some(binding), - .. - } => self.value_from_binding(binding, args, expr.span)?, + name, + namespace, + span: callee_span, + } => { + let Some(binding) = self.external_binding(callee_span, &name, &namespace) + else { + return Err(()); + }; + self.value_from_binding(binding, args, expr.span)? + } CallTarget::BuiltinArrayMethod { member, base } => { let mut all = vec![self.lower_value(base)?]; all.extend(args); @@ -815,8 +1161,7 @@ impl<'a> Lowerer<'a> { | HirExprKind::Async { .. } | HirExprKind::This { .. } | HirExprKind::Postfix { .. } - | HirExprKind::Error - | HirExprKind::External { binding: None, .. } => { + | HirExprKind::Error => { self.unsupported( expr.span, "expression has no core canonical Workshop value lowering", @@ -865,6 +1210,45 @@ impl<'a> Lowerer<'a> { } } + fn external_binding( + &mut self, + span: Span, + name: &str, + namespace: &[String], + ) -> Option { + let Some(context) = self.context else { + self.unsupported( + span, + format!( + "external Workshop binding for '{}{}' requires semantic lowering context", + if namespace.is_empty() { + String::new() + } else { + format!("{}.", namespace.join(".")) + }, + name + ), + ); + return None; + }; + let Some(binding) = context.lookup(span, name, namespace) else { + self.unsupported( + span, + format!( + "external Workshop binding for '{}{}' is unavailable in semantic resolution", + if namespace.is_empty() { + String::new() + } else { + format!("{}.", namespace.join(".")) + }, + name + ), + ); + return None; + }; + Some(binding) + } + fn validate_output(&mut self) { if let Err(error) = self.out.validate() { self.unsupported( diff --git a/tests/workshop_lowering.rs b/tests/workshop_lowering.rs index 4c549b7..773d7d9 100644 --- a/tests/workshop_lowering.rs +++ b/tests/workshop_lowering.rs @@ -4,7 +4,7 @@ use del_rs::hir; use del_rs::project::{load_project, ProjectOptions}; use del_rs::semantic::check_project; use del_rs::semantic::provider::CatalogProvider; -use del_rs::workshop::lower_to_wir; +use del_rs::workshop::{lower_project_to_wir, lower_to_wir}; use std::path::PathBuf; fn lower(text: &str) -> (workshop_rs::wir::Program, Vec) { @@ -24,13 +24,60 @@ fn lower(text: &str) -> (workshop_rs::wir::Program, Vec) { let provider = CatalogProvider::new().expect("canonical catalog provider"); let semantic = check_project(&project, &provider); let mut diagnostics = semantic.diagnostics.clone(); - let (hir, hir_diags) = hir::lower::lower(&semantic); - diagnostics.extend(hir_diags); - let (program, lowering_diags) = lower_to_wir(&hir, &semantic.project.sources); + let (program, lowering_diags) = lower_project_to_wir(&semantic); diagnostics.extend(lowering_diags); (program, diagnostics) } +#[test] +fn hir_is_backend_neutral_and_hir_only_external_lowering_fails_closed() { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "del-rs-workshop-lowering-hir-only-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("main.del"), + r#"rule: "damage" Event.OnDamageDealt { } +"#, + ) + .unwrap(); + let project = load_project(ProjectOptions { + root, + entry: Some(PathBuf::from("main.del")), + config: None, + }); + let provider = CatalogProvider::new().expect("canonical catalog provider"); + let semantic = check_project(&project, &provider); + let (hir, hir_diags) = hir::lower::lower(&semantic); + assert!( + hir_diags.iter().all(|diagnostic| !diagnostic.is_error()), + "{hir_diags:?}" + ); + + let external = hir + .exprs + .iter() + .find_map(|expr| match &expr.kind { + hir::HirExprKind::External { name, namespace } => { + Some((expr.span, name.as_str(), namespace.as_slice())) + } + _ => None, + }) + .expect("HIR external reference"); + assert_eq!(external.1, "OnDamageDealt"); + assert_eq!(external.2, ["Event"]); + assert!(!format!("{hir:?}").contains("ExternalBinding")); + + let (program, diagnostics) = lower_to_wir(&hir, &semantic.project.sources); + assert!(program.rules.is_empty()); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "HI018" && diagnostic.primary == external.0)); +} + #[test] fn core_rule_lowering_preserves_canonical_ids_and_provenance() { let (program, diagnostics) = lower(