From 5011ecb074e1d3d07ff4b1a05dd850ec55d80640 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 18:58:54 +0800 Subject: [PATCH 01/18] 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 c647f603393c61f529be27feb4013d5900d0693d 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:28:59 +0800 Subject: [PATCH 02/18] fix(provider): use released workshop-rs crate --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ac7bd70..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 = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f", package = "workshop-rs" } +workshop-rs = "0.1.1" [[bin]] name = "del-rs" From 69c9fe8701d20df2da2ef63b5a54622d1027648a 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:29:30 +0800 Subject: [PATCH 03/18] fix(provider): 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 4183053..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=7b0f8c38b9d1ee627565e8406de25293df9b4f7f#7b0f8c38b9d1ee627565e8406de25293df9b4f7f" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad536c30d7c4765f71b6def933d06b10cb8aa8f6668da7196cda4c3fefcf5ca" dependencies = [ "serde", "serde_json", From c83ce2351ab9766c01c54f28b02118d22b39f011 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:29:57 +0800 Subject: [PATCH 04/18] fix(provider): drop SCM revision identity --- src/semantic/provider.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index 437e523..1b213d3 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 = "7b0f8c38b9d1ee627565e8406de25293df9b4f7f"; - /// 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")) } @@ -193,7 +189,6 @@ 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 { From 92a5cea2eafcaa7866a8a100fc971a8ade1cc143 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:30:07 +0800 Subject: [PATCH 05/18] test(provider): 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 85af21c..6618fa7 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}; @@ -52,10 +52,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 3efd76231393ed814a20323e4892dcee01c8934d 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:30:22 +0800 Subject: [PATCH 06/18] docs(provider): 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 74c882b3b5f2ffdcf740d2efbf0e0d74f93940a4 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:30:34 +0800 Subject: [PATCH 07/18] docs(provider): 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 e3ce48f8f454a006da12f5cd97eab1401036e086 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:36:13 +0800 Subject: [PATCH 08/18] docs(provider): keep released workshop-rs wording From e47b88cb16328305546b9cb802f4fef7aef8513d 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:36:28 +0800 Subject: [PATCH 09/18] docs(provider): keep released workshop-rs wording From 69e1b73f34a117881eb64dcc6b3ed8d6f6f4a351 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:36:36 +0800 Subject: [PATCH 10/18] fix(provider): retain released workshop-rs dependency From 61f5b9a31f2057afdc8c11e757f48b1eb4f0cf25 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:36:46 +0800 Subject: [PATCH 11/18] test(provider): retain release-based identity From 5b096d8438600727cf39a33ede21c92518e6999a 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:37:14 +0800 Subject: [PATCH 12/18] fix(provider): retain released workshop-rs semantics From b71fd1fc03cb82639ee584ffc88a7c3652fd4fa1 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:37:46 +0800 Subject: [PATCH 13/18] fix(provider): retain registry lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a5550b5..aca1437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,7 +92,7 @@ dependencies = [ name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "8f42a60cbdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" [[package]] name = "libc" From 3021f17602249a96ce106119c3fca1a8f956131f 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:41:19 +0800 Subject: [PATCH 14/18] fix(provider): correct registry lock checksums --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index aca1437..a5550b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,7 +92,7 @@ dependencies = [ name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" From eb2de55d24a9f20021cf3e6008d8636733160a4b Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:42:40 +0800 Subject: [PATCH 15/18] fix(provider): align catalog ownership and enum resolution Fixes #29 --- README.md | 2 +- docs/architecture.md | 34 ++++++++++----------- docs/limitations.md | 12 ++++---- docs/support-matrix.toml | 4 +-- src/semantic/provider.rs | 60 +++++++++++++++++++++++++------------- tests/workshop_provider.rs | 22 ++++++++++++++ 6 files changed, 85 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d4581e8..91f68a0 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ evidence.[^upstream-reference] | Pattern matching & recursion | βœ… Supported | | | Embedded Workshop / lobby data | 🟑 Partial | Vanilla Workshop blocks parse; lobby-settings import not yet | | 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 | +| DEL/OSTW β†’ Workshop compilation | ⏳ Not yet | Requires follow-up del-rs #30 HIR β†’ WIR lowering; #34 only establishes the catalog provider seam | | Workshop β†’ DEL/OSTW reconstruction | ⏳ Not yet | | > [!NOTE] diff --git a/docs/architecture.md b/docs/architecture.md index e6357a0..b1b56d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,8 @@ surface), `provenance.md` (pinned upstream oracle), `syntax-notes.md` (parser re 1. **Single crate at repo root.** Package `del-rs`, library `del_rs`, binary `del-rs` (`src/bin/del-rs.rs`). No workspace members. Dependencies: `serde`, `serde_json`, `toml` - (diagnostics JSON, `ds.toml`/manifests/matrix). Nothing else; no `workshop-rs` anywhere. + (diagnostics JSON, `ds.toml`/manifests/matrix), and the released registry + `workshop-rs 0.1.1` catalog core. 2. **Backend neutrality.** The frontend owns syntax, semantic analysis, diagnostics, provenance, and the typed HIR. It must never own canonical Workshop catalog data, WIR, localization, or emitter logic. Workshop-facing names bind through one narrow provider trait (Β§12). @@ -80,7 +81,7 @@ name = "workshop-lowering.workshop-catalog" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] # rationale in notes -notes = "Owned by workshop-rs integration (#8); never modeled in HIR." +notes = "#34 provides CatalogProvider and canonical catalog identity through workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs." [[features]] id = "editor.codelens" @@ -704,9 +705,9 @@ pub fn load_project(opts: ProjectOptions) -> Project; // total; errors become 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. +identities and metadata from the released registry `workshop-rs 0.1.1` catalog. No catalog data, +enum tables, event tables, or builtin signatures are copied into del-rs. The provider exposes +the catalog identity for reproducible diagnostics and tests. ```rust // semantic/provider.rs @@ -739,7 +740,7 @@ pub enum ExternalBinding { pub struct ExternalValueInfo { pub canonical_id: String, - pub ty: ExternalType, // Known(category) | Unknown + pub ty: Option, // known category when declared pub signature: Option, // param names + optionality, when known } pub struct ExternalActionInfo { pub canonical_id: String, pub params: Option> } @@ -751,14 +752,8 @@ pub struct ExternalParam { pub name: String, pub optional: bool } #[derive(Clone, Copy, Debug)] pub enum ExternalCategory { Number, String, Bool, Vector, Entity, Color, EnumLike, Constant, AnyLike } -pub struct ResolutionContext<'a> { - pub program: &'a SemanticProgram, - pub file: FileId, - pub scope: ScopeId, -} - pub trait WorkshopProvider: Send + Sync { - fn resolve(&self, query: &NameQuery, ctx: ResolutionContext<'_>) -> ExternalResolution; + fn resolve(&self, query: &NameQuery) -> ExternalResolution; } /// Permissive default: everything is NotFound (unresolved-but-legal). @@ -766,8 +761,8 @@ pub struct NoopProvider; impl WorkshopProvider for NoopProvider { /* NotFound for all queries */ } ``` -**Semantic interaction contract** (this is the documented contract workshop-rs implements -later): +**Semantic interaction contract** (implemented by `del-rs`'s provider seam and the +`workshop-rs 0.1.1` catalog API): 1. Name resolution order (Β§13) tries user scopes first; only failures reach the provider. 2. `NotFound` β‡’ the name is typed `Type::External(ExternalType::Unknown)` (or @@ -1655,10 +1650,11 @@ the relevant sections above already reflect them. Highlights: ## 22. Durable decision record This document is the decision record for the implemented frontend. D1–D6 (Β§2) are the -architecture-level decisions. When the workshop-rs integration (#8) begins, the provider -contract (Β§12) and HIR boundary (Β§15) are the two seams to formalize in an ADR with -workshop-rs; nothing in this document is contingent on those future decisions beyond the -documented seams. +architecture-level decisions. The #34 provider contract (Β§12) now consumes the released +`workshop-rs 0.1.1` catalog through public APIs; HIR-to-WIR lowering at the HIR boundary +(Β§15) is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned +by workshop-rs. Nothing in this document requires a private Workshop revision or duplicated +canonical catalog semantics. --- diff --git a/docs/limitations.md b/docs/limitations.md index 436fb61..d509968 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -5,14 +5,14 @@ support boundary of the `del-rs` frontend: what is deliberately not implemented, and why. The authoritative declared surface is [`support-matrix.toml`](support-matrix.toml); state meanings are defined in [`compatibility.md`](compatibility.md). Capabilities are classified as -**lowering-dependent** (concrete Workshop encoding owned by the `workshop-rs` -integration, issue #8) or **intentionally unsupported** (editor-only or -outside the language contract), plus a short list of evidence-backed -approximation areas. +**lowering-dependent** (concrete Workshop encoding is follow-up del-rs #30 +work; the canonical WIR/catalog contract remains owned by `workshop-rs`) or +**intentionally unsupported** (editor-only or outside the language contract), +plus a short list of evidence-backed approximation areas. -## Lowering-dependent (owned by #8 / workshop-rs) +## Lowering-dependent (del-rs #30 / workshop-rs canonical contract boundary) -- Concrete Workshop emission: actions, values, events, variable slots, +- Concrete Workshop emission (follow-up del-rs #30): actions, values, events, variable slots, helper rules, dispatch tables, recursion stacks, reference layouts, optimizer choices. The typed HIR expresses intent only (`architecture.md` Β§15). diff --git a/docs/support-matrix.toml b/docs/support-matrix.toml index 115879f..ae6fa80 100644 --- a/docs/support-matrix.toml +++ b/docs/support-matrix.toml @@ -601,8 +601,8 @@ id = "workshop-lowering.workshop-catalog" name = "workshop-lowering.workshop-catalog" category = "workshop-lowering" state = "lowering-dependent" -evidence = ["docs/inventory.md"] -notes = "inventory-only until integration; del-rs must not vendor the catalog (issue #3 non-goal)" +evidence = ["docs/inventory.md", "tests/workshop_provider.rs"] +notes = "#34 covers CatalogProvider and canonical catalog identity through released workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs. del-rs must not vendor catalog data." [[features]] id = "workshop-lowering.events" diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index 1b213d3..6b7fac1 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -209,28 +209,18 @@ impl CatalogProvider { fn resolve_enum_member(&self, namespace: &[String], name: &str) -> Option<(String, String)> { let domain = namespace.first()?; - let canonical_name = lowercase_first(name); + if let Some(member) = self.catalog.resolve_enum_member(domain, &self.locale, name) { + return Some(member); + } + + let canonical_name = del_enum_identifier(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())) + .enum_domain(domain)? + .members + .iter() + .find_map(|member| { + (member.member == canonical_name) + .then(|| (domain.to_string(), member.member.clone())) }) } } @@ -356,6 +346,34 @@ fn lowercase_first(value: &str) -> String { first.to_lowercase().collect::() + chars.as_str() } +// DEL enum members use PascalCase source spellings; canonical catalog member +// IDs use uppercase snake case. The catalog remains the source of the ID. +fn del_enum_identifier(value: &str) -> Option { + let chars: Vec = value.chars().collect(); + if !chars.first()?.is_uppercase() { + return None; + } + + let mut canonical = String::new(); + for (index, &character) in chars.iter().enumerate() { + let previous = chars.get(index.wrapping_sub(1)).copied(); + let next = chars.get(index + 1).copied(); + let word_boundary = index > 0 + && (character.is_uppercase() + && (previous.is_some_and(char::is_lowercase) + || previous.is_some_and(char::is_numeric) + || (previous.is_some_and(char::is_uppercase) + && next.is_some_and(char::is_lowercase))) + || character.is_numeric() && previous.is_some_and(char::is_alphabetic) + || character.is_alphabetic() && previous.is_some_and(char::is_numeric)); + if word_boundary { + canonical.push('_'); + } + canonical.extend(character.to_uppercase()); + } + Some(canonical) +} + fn parameters(entry: &CatalogEntry) -> Vec { entry .params diff --git a/tests/workshop_provider.rs b/tests/workshop_provider.rs index 6618fa7..20d8241 100644 --- a/tests/workshop_provider.rs +++ b/tests/workshop_provider.rs @@ -51,6 +51,28 @@ fn catalog_provider_resolves_enum_member_without_copying_catalog_data() { assert_eq!(value.canonical_id, "Team.ALL"); } +#[test] +fn catalog_provider_does_not_accept_undeclared_enum_spellings() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&["Team"], "all", ExternalPosition::Value, 0)); + assert!(matches!(result, ExternalResolution::NotFound)); +} + +#[test] +fn catalog_provider_maps_del_enum_spelling_to_catalog_identity() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query( + &["Button"], + "PrimaryFire", + ExternalPosition::Value, + 0, + )); + let ExternalResolution::Known(ExternalBinding::Value(value)) = result else { + panic!("expected catalog-backed enum binding"); + }; + assert_eq!(value.canonical_id, "Button.PRIMARY_FIRE"); +} + #[test] fn catalog_provider_rejects_excess_arguments_and_exposes_catalog_identity() { let provider = CatalogProvider::new().expect("built-in catalog"); From 49fc121005827875c1d7cf1cf6311c915ab329a4 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:48:04 +0800 Subject: [PATCH 16/18] feat(provider): add source provenance bridge Refs #29 --- src/lib.rs | 1 + src/workshop_source.rs | 94 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/workshop_source.rs diff --git a/src/lib.rs b/src/lib.rs index 9ef096c..0615df0 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_source; pub use diagnostics::{Diagnostic, Phase, RelatedSpan, Severity}; pub use span::{FileId, LineCol, SourceFile, SourceMap, Span}; diff --git a/src/workshop_source.rs b/src/workshop_source.rs new file mode 100644 index 0000000..621c1c9 --- /dev/null +++ b/src/workshop_source.rs @@ -0,0 +1,94 @@ +//! Source provenance bridge for the DEL -> Workshop integration boundary. +//! +//! This module converts DEL source identity and byte spans into the canonical +//! `workshop-rs` source model. It does not lower HIR or define Workshop +//! semantics; those responsibilities remain in the later lowering layer and +//! in `workshop-rs`, respectively. + +use crate::span::{SourceMap, Span}; +use workshop_rs::ids::Id; +use workshop_rs::source::{Position, SourceFile, Span as WorkshopSpan}; + +/// Deterministic mapping from DEL source files to Workshop source files. +#[derive(Debug, Clone)] +pub struct WorkshopSourceMap { + files: Vec, +} + +impl WorkshopSourceMap { + /// Copy the DEL source registry in its stable file order. + pub fn from_source_map(sources: &SourceMap) -> Self { + Self { + files: sources + .files() + .map(|source| SourceFile::new(source.name.display().to_string())) + .collect(), + } + } + + /// Workshop source files in the same order as the DEL source registry. + pub fn files(&self) -> &[SourceFile] { + &self.files + } + + /// Convert a DEL byte span into a validated 1-based Workshop span. + pub fn span(&self, sources: &SourceMap, span: Span) -> Option { + let file = sources.get(span.file); + if span.end < span.start || span.end > file.text.len() as u32 { + return None; + } + let file_id = Id::from_index(span.file.0 as usize); + if file_id.index() >= self.files.len() { + return None; + } + Some(WorkshopSpan::new( + file_id, + position(file.line_col(span.start)), + position(file.line_col(span.end)), + )) + } +} + +fn position(line_col: crate::span::LineCol) -> Position { + Position::new(line_col.line, line_col.col) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn preserves_file_order_and_exact_one_based_unicode_spans() { + let mut sources = SourceMap::new(); + let first = sources.add_file(PathBuf::from("main.del"), "Γ©x\nrule".into()); + let second = sources.add_file(PathBuf::from("lib.del"), "ok".into()); + let bridge = WorkshopSourceMap::from_source_map(&sources); + + assert_eq!(bridge.files()[0].path, "main.del"); + assert_eq!(bridge.files()[1].path, "lib.del"); + + let converted = bridge + .span(&sources, Span::new(first, 0, 3)) + .expect("valid source span"); + assert_eq!(converted.file.index(), first.0 as usize); + assert_eq!(converted.start, Position::new(1, 1)); + assert_eq!(converted.end, Position::new(1, 3)); + + let second_span = bridge + .span(&sources, Span::new(second, 0, 2)) + .expect("second source span"); + assert_eq!(second_span.file.index(), second.0 as usize); + assert_eq!(second_span.start, Position::new(1, 1)); + assert_eq!(second_span.end, Position::new(1, 3)); + } + + #[test] + fn rejects_out_of_range_spans() { + let mut sources = SourceMap::new(); + let file = sources.add_file(PathBuf::from("main.del"), "ok".into()); + let bridge = WorkshopSourceMap::from_source_map(&sources); + assert!(bridge.span(&sources, Span::new(file, 1, 3)).is_none()); + assert!(bridge.span(&sources, Span::new(file, 2, 1)).is_none()); + } +} From 79b647b8327894d2ad06c787442d8f57bdfd4247 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 11:53:13 +0800 Subject: [PATCH 17/18] fix(provider): complete source provenance bridge Fixes #29 --- docs/README.md | 3 + docs/architecture.md | 16 ++++ docs/support-matrix.toml | 4 +- src/lib.rs | 1 + src/workshop_source.rs | 180 +++++++++++++++++++++++---------------- tests/workshop_source.rs | 87 +++++++++++++++++++ 6 files changed, 214 insertions(+), 77 deletions(-) create mode 100644 tests/workshop_source.rs diff --git a/docs/README.md b/docs/README.md index d775de8..c052367 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,9 @@ GitHub issues/PRs implementation scope, sequencing, acceptance (histo - [`workshop-conformance.md`](workshop-conformance.md) β€” evidence report schema and the integration boundary with canonical `workshop-rs` feature identities. +- The DEL-owned source/provenance bridge is documented in + [`architecture.md`](architecture.md) Β§6.1 and independently exercised by + `tests/workshop_source.rs`. ### Interfaces diff --git a/docs/architecture.md b/docs/architecture.md index b1b56d4..a37af2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,6 +156,7 @@ Cargo.toml # package del-rs; [lib] name = "del_rs"; [[bin]] name src/ lib.rs # crate root: module declarations + public re-exports span.rs # FileId, Span, LineCol, SourceFile, SourceMap, line/col mapping + workshop_source.rs # DEL source/provenance -> workshop-rs source arena/span bridge diagnostics.rs # Diagnostic, Severity, Phase, RelatedSpan, code registry syntax/ mod.rs # syntax facade: parse_source(), ParseOutput @@ -240,6 +241,21 @@ impl SourceMap { - `FileId` is stable across the whole pipeline (parse β†’ semantic β†’ HIR), so spans stay comparable everywhere. +### 6.1 Source/provenance bridge + +`workshop_source.rs` is a DEL-owned, source-only bridge for the integration boundary. Its +`WorkshopSourceBridge::from_source_map` inserts each DEL `SourceMap` file into a +`workshop_rs::arena::Arena` in source-map order and retains the +typed DEL `FileId` β†’ Workshop `SourceFileId` mapping. `position` and `span` convert DEL's +half-open byte offsets to workshop-rs's 1-based `Position` and typed `Span`. + +The bridge checks file existence, byte bounds, UTF-8 scalar boundaries, reversed spans, and +non-UTF-8 paths rather than clamping or lossy-converting provenance. The Workshop source entries +carry paths only; DEL source text remains owned by the DEL `SourceMap`. This module has no HIR, +lowering, backend encoding, provider-specific state, or catalog data, so del-rs #36 can reuse or +extend it later. HIR-to-WIR lowering remains follow-up del-rs #30 work, while the canonical +WIR/catalog contract remains owned by workshop-rs. + ## 7. Lexer ```rust diff --git a/docs/support-matrix.toml b/docs/support-matrix.toml index ae6fa80..6c31537 100644 --- a/docs/support-matrix.toml +++ b/docs/support-matrix.toml @@ -601,8 +601,8 @@ id = "workshop-lowering.workshop-catalog" name = "workshop-lowering.workshop-catalog" category = "workshop-lowering" state = "lowering-dependent" -evidence = ["docs/inventory.md", "tests/workshop_provider.rs"] -notes = "#34 covers CatalogProvider and canonical catalog identity through released workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs. del-rs must not vendor catalog data." +evidence = ["docs/inventory.md", "tests/workshop_provider.rs", "tests/workshop_source.rs"] +notes = "#34 covers CatalogProvider, canonical catalog identity, and the DEL-owned source/provenance bridge through released workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs. del-rs must not vendor catalog data." [[features]] id = "workshop-lowering.events" diff --git a/src/lib.rs b/src/lib.rs index 0615df0..9687d08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,5 +19,6 @@ pub mod workshop_source; pub use diagnostics::{Diagnostic, Phase, RelatedSpan, Severity}; pub use span::{FileId, LineCol, SourceFile, SourceMap, Span}; +pub use workshop_source::{SourceBridgeError, WorkshopSourceBridge}; pub use syntax::ast::*; pub use syntax::token::{StrForm, Token, TokenKind}; diff --git a/src/workshop_source.rs b/src/workshop_source.rs index 621c1c9..7939f7c 100644 --- a/src/workshop_source.rs +++ b/src/workshop_source.rs @@ -1,94 +1,124 @@ -//! Source provenance bridge for the DEL -> Workshop integration boundary. +//! DEL-owned source/provenance bridge for the `workshop-rs` boundary. //! -//! This module converts DEL source identity and byte spans into the canonical -//! `workshop-rs` source model. It does not lower HIR or define Workshop -//! semantics; those responsibilities remain in the later lowering layer and -//! in `workshop-rs`, respectively. +//! This module maps the frontend's byte-offset source model into the +//! canonical Workshop source model. It deliberately contains no HIR, +//! lowering, backend encoding, or catalog state. -use crate::span::{SourceMap, Span}; -use workshop_rs::ids::Id; -use workshop_rs::source::{Position, SourceFile, Span as WorkshopSpan}; +use workshop_rs::arena::Arena; +use workshop_rs::source::{FileId as WorkshopFileId, Position, SourceFile, Span as WorkshopSpan}; -/// Deterministic mapping from DEL source files to Workshop source files. -#[derive(Debug, Clone)] -pub struct WorkshopSourceMap { - files: Vec, +use crate::span::{FileId, SourceMap, Span}; + +/// Errors raised when a DEL source location cannot be represented exactly in +/// the Workshop source model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceBridgeError { + /// The DEL file ID does not belong to the source map used to build this + /// bridge. + UnknownFile(FileId), + /// A source path cannot be represented by workshop-rs's UTF-8 path field. + NonUtf8Path(FileId), + /// The byte offset is outside the source file. + OffsetOutOfBounds { file: FileId, offset: u32, len: u32 }, + /// The byte offset splits a UTF-8 scalar value. + OffsetNotCharBoundary { file: FileId, offset: u32 }, + /// The DEL span is reversed. Both source models use half-open spans, but + /// only workshop-rs validates their ordering. + ReversedSpan(Span), +} + +/// A reusable mapping from DEL source provenance to workshop-rs source data. +/// +/// The Workshop files are inserted in the same order as the DEL `SourceMap`. +/// The bridge retains a clone of the source map so byte offsets can be checked +/// before conversion; no source text is copied into workshop-rs's file entries. +#[derive(Clone)] +pub struct WorkshopSourceBridge { + source_map: SourceMap, + files: Arena, + del_to_workshop: Vec, } -impl WorkshopSourceMap { - /// Copy the DEL source registry in its stable file order. - pub fn from_source_map(sources: &SourceMap) -> Self { - Self { - files: sources - .files() - .map(|source| SourceFile::new(source.name.display().to_string())) - .collect(), +impl WorkshopSourceBridge { + /// Build Workshop source-file entries and a stable DEL-file-ID mapping. + pub fn from_source_map(sources: &SourceMap) -> Result { + let mut files = Arena::new(); + let mut del_to_workshop = Vec::new(); + + for source in sources.files() { + let Some(path) = source.name.to_str() else { + return Err(SourceBridgeError::NonUtf8Path(source.id)); + }; + let workshop_file = files.push(SourceFile::new(path)); + let index = source.id.0 as usize; + if del_to_workshop.len() <= index { + del_to_workshop.resize(index + 1, workshop_file); + } + del_to_workshop[index] = workshop_file; } + + Ok(Self { + source_map: sources.clone(), + files, + del_to_workshop, + }) } - /// Workshop source files in the same order as the DEL source registry. - pub fn files(&self) -> &[SourceFile] { + /// The workshop-rs source-file arena, in DEL source-map order. + pub fn files(&self) -> &Arena { &self.files } - /// Convert a DEL byte span into a validated 1-based Workshop span. - pub fn span(&self, sources: &SourceMap, span: Span) -> Option { - let file = sources.get(span.file); - if span.end < span.start || span.end > file.text.len() as u32 { - return None; - } - let file_id = Id::from_index(span.file.0 as usize); - if file_id.index() >= self.files.len() { - return None; - } - Some(WorkshopSpan::new( - file_id, - position(file.line_col(span.start)), - position(file.line_col(span.end)), - )) + /// Resolve a DEL `FileId` to the corresponding typed Workshop file ID. + pub fn workshop_file_id(&self, file: FileId) -> Option { + self.del_to_workshop.get(file.0 as usize).copied() } -} -fn position(line_col: crate::span::LineCol) -> Position { - Position::new(line_col.line, line_col.col) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn preserves_file_order_and_exact_one_based_unicode_spans() { - let mut sources = SourceMap::new(); - let first = sources.add_file(PathBuf::from("main.del"), "Γ©x\nrule".into()); - let second = sources.add_file(PathBuf::from("lib.del"), "ok".into()); - let bridge = WorkshopSourceMap::from_source_map(&sources); - - assert_eq!(bridge.files()[0].path, "main.del"); - assert_eq!(bridge.files()[1].path, "lib.del"); + /// Convert a DEL byte offset to a 1-based Workshop position. + pub fn position(&self, file: FileId, offset: u32) -> Result { + let source = self.source_file(file)?; + self.validate_offset(file, source.text.len(), offset)?; + let line_col = source.line_col(offset); + Ok(Position::new(line_col.line, line_col.col)) + } - let converted = bridge - .span(&sources, Span::new(first, 0, 3)) - .expect("valid source span"); - assert_eq!(converted.file.index(), first.0 as usize); - assert_eq!(converted.start, Position::new(1, 1)); - assert_eq!(converted.end, Position::new(1, 3)); + /// Convert a DEL half-open byte span to a typed Workshop source span. + pub fn span(&self, span: Span) -> Result { + if span.start > span.end { + return Err(SourceBridgeError::ReversedSpan(span)); + } + let file = self + .workshop_file_id(span.file) + .ok_or(SourceBridgeError::UnknownFile(span.file))?; + let start = self.position(span.file, span.start)?; + let end = self.position(span.file, span.end)?; + Ok(WorkshopSpan::new(file, start, end)) + } - let second_span = bridge - .span(&sources, Span::new(second, 0, 2)) - .expect("second source span"); - assert_eq!(second_span.file.index(), second.0 as usize); - assert_eq!(second_span.start, Position::new(1, 1)); - assert_eq!(second_span.end, Position::new(1, 3)); + fn source_file(&self, file: FileId) -> Result<&crate::span::SourceFile, SourceBridgeError> { + self.source_map + .files() + .find(|source| source.id == file) + .ok_or(SourceBridgeError::UnknownFile(file)) } - #[test] - fn rejects_out_of_range_spans() { - let mut sources = SourceMap::new(); - let file = sources.add_file(PathBuf::from("main.del"), "ok".into()); - let bridge = WorkshopSourceMap::from_source_map(&sources); - assert!(bridge.span(&sources, Span::new(file, 1, 3)).is_none()); - assert!(bridge.span(&sources, Span::new(file, 2, 1)).is_none()); + fn validate_offset( + &self, + file: FileId, + len: usize, + offset: u32, + ) -> Result<(), SourceBridgeError> { + let offset_usize = offset as usize; + if offset_usize > len { + return Err(SourceBridgeError::OffsetOutOfBounds { + file, + offset, + len: len.min(u32::MAX as usize) as u32, + }); + } + if !self.source_file(file)?.text.is_char_boundary(offset_usize) { + return Err(SourceBridgeError::OffsetNotCharBoundary { file, offset }); + } + Ok(()) } } diff --git a/tests/workshop_source.rs b/tests/workshop_source.rs new file mode 100644 index 0000000..580ce2a --- /dev/null +++ b/tests/workshop_source.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +use del_rs::{FileId, SourceBridgeError, SourceMap, Span, WorkshopSourceBridge}; +use workshop_rs::source::{Position, SourceFile}; + +#[test] +fn bridge_preserves_cross_file_identity_and_unicode_positions() { + let mut sources = SourceMap::new(); + let first = sources.add_file(PathBuf::from("src/main.del"), "rule Main {}".to_string()); + let second = sources.add_file(PathBuf::from("lib/Ξ².del"), "Ξ±\nδΈ–η•Œ\n".to_string()); + + let bridge = WorkshopSourceBridge::from_source_map(&sources).expect("source bridge"); + let first_workshop = bridge.workshop_file_id(first).expect("first file mapping"); + let second_workshop = bridge + .workshop_file_id(second) + .expect("second file mapping"); + + assert_ne!(first_workshop, second_workshop); + assert_eq!(bridge.files().len(), 2); + assert_eq!( + bridge.files().get(first_workshop), + Some(&SourceFile::new("src/main.del")) + ); + assert_eq!( + bridge.files().get(second_workshop), + Some(&SourceFile::new("lib/Ξ².del")) + ); + + // `δΈ–η•Œ` occupies bytes 3..9, but columns 1..3: columns count Unicode + // scalar values, not UTF-8 bytes. + let workshop_span = bridge + .span(Span::new(second, 3, 9)) + .expect("Unicode span should map exactly"); + assert_eq!(workshop_span.file, second_workshop); + assert_eq!(workshop_span.start, Position::new(2, 1)); + assert_eq!(workshop_span.end, Position::new(2, 3)); + assert!(workshop_span.is_valid()); +} + +#[test] +fn bridge_maps_one_based_line_boundaries_and_eof() { + let mut sources = SourceMap::new(); + let file = sources.add_file(PathBuf::from("boundary.del"), "Γ©x\nlast".to_string()); + let bridge = WorkshopSourceBridge::from_source_map(&sources).expect("source bridge"); + + assert_eq!(bridge.position(file, 0).unwrap(), Position::new(1, 1)); + assert_eq!(bridge.position(file, 2).unwrap(), Position::new(1, 2)); + assert_eq!(bridge.position(file, 3).unwrap(), Position::new(1, 3)); + assert_eq!(bridge.position(file, 4).unwrap(), Position::new(2, 1)); + assert_eq!(bridge.position(file, 8).unwrap(), Position::new(2, 5)); + assert_eq!( + bridge.span(Span::new(file, 3, 8)).unwrap(), + workshop_rs::source::Span::new( + bridge.workshop_file_id(file).unwrap(), + Position::new(1, 3), + Position::new(2, 5), + ) + ); +} + +#[test] +fn bridge_rejects_unknown_reversed_and_non_boundary_locations() { + let mut sources = SourceMap::new(); + let file = sources.add_file(PathBuf::from("errors.del"), "Γ©".to_string()); + let bridge = WorkshopSourceBridge::from_source_map(&sources).expect("source bridge"); + + assert_eq!( + bridge.position(FileId(99), 0), + Err(SourceBridgeError::UnknownFile(FileId(99))) + ); + assert_eq!( + bridge.span(Span::new(file, 2, 1)), + Err(SourceBridgeError::ReversedSpan(Span::new(file, 2, 1))) + ); + assert_eq!( + bridge.position(file, 1), + Err(SourceBridgeError::OffsetNotCharBoundary { file, offset: 1 }) + ); + assert_eq!( + bridge.position(file, 3), + Err(SourceBridgeError::OffsetOutOfBounds { + file, + offset: 3, + len: 2, + }) + ); +} From 0d9db8b8f1c690e5b9e80e5e36fd1af79c70daa2 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 12:03:44 +0800 Subject: [PATCH 18/18] fix(provider): align catalog ownership and metadata Fixes #29 --- README.md | 2 +- docs/architecture.md | 14 +++++++------- docs/compatibility.md | 10 +++++----- docs/limitations.md | 6 +++--- docs/support-matrix.toml | 36 +++++++++++++++++------------------ src/semantic/provider.rs | 26 +++++++++++++++++++------ tests/workshop_provider.rs | 39 +++++++++++++++++++++++++++++++++++--- 7 files changed, 90 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 91f68a0..725a1e9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ evidence.[^upstream-reference] | Pattern matching & recursion | βœ… Supported | | | Embedded Workshop / lobby data | 🟑 Partial | Vanilla Workshop blocks parse; lobby-settings import not yet | | Workshop builtins | 🟑 Partial | `CatalogProvider` resolves the released canonical `workshop-rs` catalog; lowering is tracked separately | -| DEL/OSTW β†’ Workshop compilation | ⏳ Not yet | Requires follow-up del-rs #30 HIR β†’ WIR lowering; #34 only establishes the catalog provider seam | +| DEL/OSTW β†’ Workshop compilation | 🟑 Partial | del-rs #30 owns the HIR β†’ WIR lowering adapter; canonical WIR/catalog/emission contracts remain owned by workshop-rs, so end-to-end support is not claimed | | Workshop β†’ DEL/OSTW reconstruction | ⏳ Not yet | | > [!NOTE] diff --git a/docs/architecture.md b/docs/architecture.md index a37af2f..3d9fc40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ surface), `provenance.md` (pinned upstream oracle), `syntax-notes.md` (parser re | D1 | **Direct typed AST with trivia retention; no separate CST tree.** The parser consumes a token stream that includes trivia tokens (`Whitespace`, `LineComment`, `BlockComment`, `DocComment`); the full `Vec` is kept on the parse output; AST nodes carry `Span`s. | #3 requires comments/trivia/identifiers/ranges retained "sufficiently for diagnostics and source tooling". A token stream plus spans satisfies every #3 acceptance criterion. A second typed CST tree would duplicate the AST grammar (real maintenance cost while #2 keeps churning the inventory); a generic (rowan-style) CST adds indirection no acceptance criterion needs. Recovery is handled by explicit `Error` AST nodes (Β§10). | | D2 | **AST node = `{ id: NodeId, span: Span, kind: ExprKind }`** (tagged-struct pattern, one shared `NodeId` counter). | Side tables (`type of node`, `symbol of node`) keyed by `NodeId` keep the AST immutable, cheap, and query-friendly; `type_at`/`symbol_at` queries become hash lookups. | | D3 | **Unresolved Workshop names are legal.** Semantic analysis resolves user declarations first; anything else goes to `WorkshopProvider::resolve`. A permissive `NoopProvider` returns `NotFound`, and the name is typed `External(...)` with structural checks only (arity when the provider says so, otherwise nothing). | #4 acceptance: Workshop-facing names "can remain externally bound/unresolved through a documented provider contract rather than copied catalog data". This lets every real OSTW project parse and check with zero catalog data. | -| D4 | **Types live in side tables on the semantic program; HIR is a fully typed tree.** `SemanticProgram::types: HashMap`, `resolution: HashMap`. HIR nodes carry `ty: Type` inline because HIR is a fresh tree produced by lowering. | AST stays a pure parse artifact (reusable for edits); HIR consumers (oracle, future workshop-rs adapter) get types inline for free. | +| D4 | **Types live in side tables on the semantic program; HIR is a fully typed tree.** `SemanticProgram::types: HashMap`, `resolution: HashMap`. HIR nodes carry `ty: Type` inline because HIR is a fresh tree produced by lowering. | AST stays a pure parse artifact (reusable for edits); HIR consumers (oracle, the DEL-owned #30 lowering adapter, and the canonical Workshop consumer) get types inline for free. | | D5 | **HIR expresses intent, never Workshop encodings.** `new`/`delete` are nodes with lifetime intent; virtual dispatch is `CallTarget::Method { dispatch: Virtual }` (runtime resolves); recursion is legal call-graph cycles with an `is_recursive` storage-intent flag; lambdas are functions with explicit capture lists; global/player/local storage is a `StorageIntent` enum derived from source keywords and rule event context. No slots, no helper rules, no array-of-vector layouts, no reference bit patterns. | #6 non-goals. The oracle (Β§16) and HIR invariants (Β§15.4) pin observable intent without encoding it. | | D6 | **The semantic oracle is a bounded tree-walking interpreter, not a runtime.** External calls are holes; events never fire; explicit step/recursion limits. | #6 acceptance needs to "distinguish correct/incorrect high-level behavior ... where practical". Bounded scope prevents a second Workshop runtime. | @@ -81,7 +81,7 @@ name = "workshop-lowering.workshop-catalog" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] # rationale in notes -notes = "#34 provides CatalogProvider and canonical catalog identity through workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs." +notes = "#34 provides CatalogProvider and canonical catalog identity through workshop-rs 0.1.1; the DEL-owned HIR-to-WIR lowering adapter is del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs." [[features]] id = "editor.codelens" @@ -253,8 +253,8 @@ The bridge checks file existence, byte bounds, UTF-8 scalar boundaries, reversed non-UTF-8 paths rather than clamping or lossy-converting provenance. The Workshop source entries carry paths only; DEL source text remains owned by the DEL `SourceMap`. This module has no HIR, lowering, backend encoding, provider-specific state, or catalog data, so del-rs #36 can reuse or -extend it later. HIR-to-WIR lowering remains follow-up del-rs #30 work, while the canonical -WIR/catalog contract remains owned by workshop-rs. +extend it later. The DEL-owned HIR-to-WIR lowering adapter is del-rs #30 work, while the +canonical WIR/catalog contract remains owned by workshop-rs. ## 7. Lexer @@ -1667,9 +1667,9 @@ the relevant sections above already reflect them. Highlights: This document is the decision record for the implemented frontend. D1–D6 (Β§2) are the architecture-level decisions. The #34 provider contract (Β§12) now consumes the released -`workshop-rs 0.1.1` catalog through public APIs; HIR-to-WIR lowering at the HIR boundary -(Β§15) is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned -by workshop-rs. Nothing in this document requires a private Workshop revision or duplicated +`workshop-rs 0.1.1` catalog through public APIs; the DEL-owned HIR-to-WIR lowering adapter +at the HIR boundary (Β§15) is del-rs #30 work, while the canonical WIR/catalog contract +remains owned by workshop-rs. Nothing in this document requires a private Workshop revision or duplicated canonical catalog semantics. --- diff --git a/docs/compatibility.md b/docs/compatibility.md index f844b70..a4e87bd 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -49,7 +49,7 @@ exactly one state, defined as follows: | `planned` | Inventoried and evidenced upstream, but not yet implemented; not claimed as supported. | | `frontend-supported` | Lexed/parsed into documented AST structures with stable spans; no semantic claims. | | `semantic-supported` | Resolved, type-checked, and diagnosed by the semantic model / HIR; no Workshop emission required. | -| `lowering-dependent` | Requires concrete Workshop encoding owned by the `workshop-rs` integration (issue #8); the frontend carries intent only. | +| `lowering-dependent` | Requires concrete Workshop encoding owned by del-rs #30; the canonical WIR, catalog, and emission contracts remain owned by `workshop-rs`, while the frontend carries intent only. | | `end-to-end-supported` | Fully supported through Workshop emission; currently unused (no end-to-end path exists in this crate). | | `out-of-scope` | Deliberately outside the `del-rs` language contract (e.g. editor behavior). | @@ -77,10 +77,10 @@ its per-entry evidence are the source of truth. End-to-end Workshop compilation (`DEL/OSTW -> Workshop text`) is **`lowering-dependent`**: the concrete encoding (variable slots, helper -rules, dispatch tables, recursion stacks, reference layouts, emitter) is owned -by the `workshop-rs` integration (issue #8), which consumes the typed HIR -across the documented boundary. Decompilation (`Workshop -> DEL/OSTW`) is -`planned` (issue #9). +rules, dispatch tables, recursion stacks, reference layouts, emitter) is +del-rs #30 work. It consumes typed HIR across the documented boundary; the +canonical WIR, catalog, and emission contracts remain owned by `workshop-rs`. +Decompilation (`Workshop -> DEL/OSTW`) is `planned` (issue #9). ## Corpus and differential-testing methodology diff --git a/docs/limitations.md b/docs/limitations.md index d509968..16b1689 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -5,14 +5,14 @@ support boundary of the `del-rs` frontend: what is deliberately not implemented, and why. The authoritative declared surface is [`support-matrix.toml`](support-matrix.toml); state meanings are defined in [`compatibility.md`](compatibility.md). Capabilities are classified as -**lowering-dependent** (concrete Workshop encoding is follow-up del-rs #30 -work; the canonical WIR/catalog contract remains owned by `workshop-rs`) or +**lowering-dependent** (concrete Workshop encoding is del-rs #30 work; the +canonical WIR/catalog contract remains owned by `workshop-rs`) or **intentionally unsupported** (editor-only or outside the language contract), plus a short list of evidence-backed approximation areas. ## Lowering-dependent (del-rs #30 / workshop-rs canonical contract boundary) -- Concrete Workshop emission (follow-up del-rs #30): actions, values, events, variable slots, +- Concrete Workshop emission (del-rs #30): actions, values, events, variable slots, helper rules, dispatch tables, recursion stacks, reference layouts, optimizer choices. The typed HIR expresses intent only (`architecture.md` Β§15). diff --git a/docs/support-matrix.toml b/docs/support-matrix.toml index 6c31537..99f8412 100644 --- a/docs/support-matrix.toml +++ b/docs/support-matrix.toml @@ -602,7 +602,7 @@ name = "workshop-lowering.workshop-catalog" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md", "tests/workshop_provider.rs", "tests/workshop_source.rs"] -notes = "#34 covers CatalogProvider, canonical catalog identity, and the DEL-owned source/provenance bridge through released workshop-rs 0.1.1; HIR-to-WIR lowering is follow-up del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs. del-rs must not vendor catalog data." +notes = "#34 covers CatalogProvider, canonical catalog identity, and the DEL-owned source/provenance bridge through released workshop-rs 0.1.1; the DEL-owned HIR-to-WIR lowering adapter is del-rs #30 work, while the canonical WIR/catalog contract remains owned by workshop-rs. del-rs must not vendor catalog data." [[features]] id = "workshop-lowering.events" @@ -610,7 +610,7 @@ name = "workshop-lowering.events" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.actions" @@ -618,7 +618,7 @@ name = "workshop-lowering.actions" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.values" @@ -626,7 +626,7 @@ name = "workshop-lowering.values" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.constants" @@ -634,7 +634,7 @@ name = "workshop-lowering.constants" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.variables" @@ -642,7 +642,7 @@ name = "workshop-lowering.variables" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.extended-collection" @@ -650,7 +650,7 @@ name = "workshop-lowering.extended-collection" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.classes" @@ -658,7 +658,7 @@ name = "workshop-lowering.classes" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.structs" @@ -666,7 +666,7 @@ name = "workshop-lowering.structs" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.enums" @@ -674,7 +674,7 @@ name = "workshop-lowering.enums" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.vanilla-superset" @@ -682,7 +682,7 @@ name = "workshop-lowering.vanilla-superset" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.auto-for" @@ -690,7 +690,7 @@ name = "workshop-lowering.auto-for" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.chase-modify" @@ -698,7 +698,7 @@ name = "workshop-lowering.chase-modify" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.subroutines" @@ -706,7 +706,7 @@ name = "workshop-lowering.subroutines" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.strings" @@ -714,7 +714,7 @@ name = "workshop-lowering.strings" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.lobby-settings" @@ -722,7 +722,7 @@ name = "workshop-lowering.lobby-settings" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.workshop-comments" @@ -730,7 +730,7 @@ name = "workshop-lowering.workshop-comments" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "workshop-lowering.output-formats" @@ -738,7 +738,7 @@ name = "workshop-lowering.output-formats" category = "workshop-lowering" state = "lowering-dependent" evidence = ["docs/inventory.md"] -notes = "workshop-rs integration (#8); inventory-only per #2 non-goals" +notes = "Concrete HIR-to-WIR lowering is del-rs #30 work; canonical WIR, catalog, and emission contracts remain owned by workshop-rs. Inventory evidence for the lowering-dependent contract; this is not an end-to-end support claim." [[features]] id = "compiler-utility.optimizer" diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index 6b7fac1..73052a8 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -223,6 +223,15 @@ impl CatalogProvider { .then(|| (domain.to_string(), member.member.clone())) }) } + + fn resolve_enum_type(&self, name: &str) -> Option { + let domain = self.catalog.enum_domain(name)?; + Some(ExternalTypeInfo { + canonical_id: domain.domain.clone(), + category: ExternalCategory::EnumLike, + constant: true, + }) + } } impl WorkshopProvider for CatalogProvider { @@ -242,6 +251,13 @@ impl WorkshopProvider for CatalogProvider { })); } + if query.position == ExternalPosition::Type { + return self + .resolve_enum_type(&query.name) + .map(ExternalBinding::Type) + .map_or(ExternalResolution::NotFound, ExternalResolution::Known); + } + if !query.namespace.is_empty() { if let Some((domain, member)) = self.resolve_enum_member(&query.namespace, &query.name) { @@ -254,9 +270,12 @@ impl WorkshopProvider for CatalogProvider { } let kinds: &[Kind] = match query.position { + // Type queries return from the enum-domain branch above; this + // arm only keeps the match exhaustive without consulting + // `Kind::Enum` entries. + ExternalPosition::Type => &[], 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. @@ -291,11 +310,6 @@ impl WorkshopProvider for CatalogProvider { }), })) } - 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(), diff --git a/tests/workshop_provider.rs b/tests/workshop_provider.rs index 20d8241..25ab193 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, + CatalogProvider, EventContext, ExternalBinding, ExternalCategory, ExternalPosition, + ExternalResolution, NameQuery, WorkshopProvider, }; use del_rs::{FileId, Span}; @@ -22,7 +22,40 @@ fn catalog_provider_preserves_canonical_action_identity_and_metadata() { panic!("expected catalog-backed action binding"); }; assert_eq!(action.canonical_id, "smallMessage"); - assert_eq!(action.params.as_ref().map(Vec::len), Some(2)); + let params = action.params.expect("action parameters"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "VisibleTo"); + assert!(params[0].optional, "VisibleTo has a catalog default"); + assert_eq!(params[1].name, "Header"); + assert!(!params[1].optional, "Header has no catalog default"); +} + +#[test] +fn catalog_provider_resolves_direct_value_identity_and_parameters() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&[], "Add", ExternalPosition::Value, 2)); + let ExternalResolution::Known(ExternalBinding::Value(value)) = result else { + panic!("expected catalog-backed value binding"); + }; + assert_eq!(value.canonical_id, "add"); + let params = value.signature.expect("value signature").params; + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "a"); + assert!(!params[0].optional); + assert_eq!(params[1].name, "b"); + assert!(!params[1].optional); +} + +#[test] +fn catalog_provider_resolves_enum_type_through_enum_domain() { + let provider = CatalogProvider::new().expect("built-in catalog"); + let result = provider.resolve(&query(&[], "Team", ExternalPosition::Type, 0)); + let ExternalResolution::Known(ExternalBinding::Type(ty)) = result else { + panic!("expected catalog-backed enum type binding"); + }; + assert_eq!(ty.canonical_id, "Team"); + assert_eq!(ty.category, ExternalCategory::EnumLike); + assert!(ty.constant); } #[test]