diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd65ccb..6d203fb 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..a5550b5 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,41 @@ 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad536c30d7c4765f71b6def933d06b10cb8aa8f6668da7196cda4c3fefcf5ca" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index dbee647..a4fff57 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 = "0.1.1" [[bin]] name = "del-rs" diff --git a/README.md b/README.md index 6517757..725a1e9 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ 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 | -| DEL/OSTW → Workshop compilation | ⏳ Not yet | Requires `workshop-rs` integration | +| Workshop builtins | 🟡 Partial | `CatalogProvider` resolves the released canonical `workshop-rs` catalog; lowering is tracked separately | +| 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] @@ -60,8 +60,10 @@ 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 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 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 b90d828..3d9fc40 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). @@ -42,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. | @@ -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; 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" @@ -155,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 @@ -239,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. 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 ```rust @@ -702,10 +719,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 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 @@ -737,26 +755,21 @@ pub enum ExternalBinding { } pub struct ExternalValueInfo { - pub ty: ExternalType, // Known(category) | Unknown + pub canonical_id: String, + pub ty: Option, // known category when declared 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 } #[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). @@ -764,8 +777,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 @@ -1653,10 +1666,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; 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 461c5ac..16b1689 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -5,21 +5,21 @@ 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 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 (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). - 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 released + `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/docs/support-matrix.toml b/docs/support-matrix.toml index 115879f..99f8412 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", "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; 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/lib.rs b/src/lib.rs index 9ef096c..9687d08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,10 @@ 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}; +pub use workshop_source::{SourceBridgeError, WorkshopSourceBridge}; pub use syntax::ast::*; pub use syntax::token::{StrForm, Token, TokenKind}; diff --git a/src/semantic/provider.rs b/src/semantic/provider.rs index c6365ed..73052a8 100644 --- a/src/semantic/provider.rs +++ b/src/semantic/provider.rs @@ -1,9 +1,11 @@ //! 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; /// Position a query name is used in. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -62,12 +64,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 +85,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 +134,276 @@ 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 from the released `workshop-rs` dependency. + 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)> { + // 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()?; + 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)? + .members + .iter() + .find_map(|member| { + (member.member == canonical_name) + .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 { + 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.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) + { + return ExternalResolution::Known(ExternalBinding::Value(ExternalValueInfo { + canonical_id: format!("{domain}.{member}"), + ty: Some(ExternalCategory::EnumLike), + signature: None, + })); + } + } + + 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], + // 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::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() +} + +// 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 + .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/src/workshop_source.rs b/src/workshop_source.rs new file mode 100644 index 0000000..7939f7c --- /dev/null +++ b/src/workshop_source.rs @@ -0,0 +1,124 @@ +//! DEL-owned source/provenance bridge for the `workshop-rs` boundary. +//! +//! 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 workshop_rs::arena::Arena; +use workshop_rs::source::{FileId as WorkshopFileId, Position, SourceFile, Span as WorkshopSpan}; + +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 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, + }) + } + + /// The workshop-rs source-file arena, in DEL source-map order. + pub fn files(&self) -> &Arena { + &self.files + } + + /// 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() + } + + /// 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)) + } + + /// 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)) + } + + 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)) + } + + 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_provider.rs b/tests/workshop_provider.rs new file mode 100644 index 0000000..25ab193 --- /dev/null +++ b/tests/workshop_provider.rs @@ -0,0 +1,115 @@ +use del_rs::semantic::provider::{ + CatalogProvider, EventContext, ExternalBinding, ExternalCategory, ExternalPosition, + ExternalResolution, NameQuery, WorkshopProvider, +}; +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"); + 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] +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_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"); + let result = provider.resolve(&query(&[], "Wait", ExternalPosition::Value, 3)); + assert!(matches!(result, ExternalResolution::DefiniteError(_))); + assert_eq!(provider.catalog_identity().catalog_version, "0.1.0"); +} 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, + }) + ); +}