From 7086e13e2c3a486fe70de52466377906148c2aff Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sat, 25 Jul 2026 22:40:16 +0200 Subject: [PATCH 01/10] Trim each documentation file to what it owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/tests/README.md` restated its own `AGENTS.md` at length — the two crates' jobs, where post-codegen failures live, the target layout, the snapshot ownership rules. It is now the map of what is here and how to run it, and defers the rules to `AGENTS.md`, which is where a reader is sent for them anyway. `cgp-macro-test-util/README.md` duplicated the knowledge base's `snapshot_macros.md` across fifteen near-identical per-macro sections, plus a guide to a migration that has long since happened. It keeps the macro table, one canonical invocation, the `insta` workflow, and the two notes that are genuinely its own, and points at the implementation document for how the macros are built and at `crates/tests/AGENTS.md` for when to snapshot at all — 592 lines to 112. The crate-level `README.md` for `cgp`, which is the crates.io front page, still called CGP "a new modular programming paradigm in Rust" — the positioning the knowledge base records as retired. It now opens with the current line and points at the knowledge base for the exhaustive semantics. In `AGENTS.md`, the knowledge-base links spelled their full paths as link text, which read as noise where a name would do; a code span split across two lines in `crates/tests/AGENTS.md` is closed on one, per the backtick rule. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 27 +- crates/macros/cgp-macro-test-util/README.md | 572 ++------------------ crates/main/cgp/README.md | 9 +- crates/tests/AGENTS.md | 4 +- crates/tests/README.md | 68 +-- 5 files changed, 93 insertions(+), 587 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5aa264a6..bea39459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,12 +84,12 @@ lists every document in one page, which is the fastest way to find the few a tas `#[cgp_component]`/`#[cgp_impl]`/`#[cgp_fn]`, `delegate_components!`, `HasField`, `UseDelegate`, check traits, and so on). Re-invoke it whenever you move into an unfamiliar construct — the macros and core traits here are the ground truth the skill describes, so read the two together. -- **Always read [cgp-knowledge-base/cgp/README.md](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/README.md)** to orient in the knowledge base, then follow it - into the README of whichever section covers your task. -- **Read [cgp-knowledge-base/cgp/reference/README.md](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/reference/README.md) and the relevant reference documents - whenever the task requires understanding a CGP construct** — what it means, what syntax it accepts, - and what code it expands to. -- **Read [cgp-knowledge-base/cgp/implementation/README.md](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/implementation/README.md) and the relevant +- **Always read the knowledge base's [`cgp` section README](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/README.md)** to orient, + then follow it into the README of whichever part covers your task. +- **Read the [construct reference](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/reference/README.md) and the relevant reference + documents whenever the task requires understanding a CGP construct** — what it means, what syntax + it accepts, and what code it expands to. +- **Read the [implementation reference](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/implementation/README.md) and the relevant implementation documents whenever the task involves reading or modifying the CGP source code** — they map each macro to its `cgp-macro-core`/`cgp-macro-lib` internals, corner cases, and tests. - **Load the `/dual-reader-prose` skill whenever the task involves editing markdown documentation or @@ -187,13 +187,14 @@ as the behavior allows. ### Orient before touching anything Perform the standing steps in [Orient before any task](#orient-before-any-task) first, every -iteration. Then read the documentation specific to the macro under review, in the [knowledge base](https://github.com/contextgeneric/cgp-knowledge-base/tree/main/cgp): its -reference document under [cgp-knowledge-base/cgp/reference/](https://github.com/contextgeneric/cgp-knowledge-base/tree/main/cgp/reference), its implementation documents under -[cgp-knowledge-base/cgp/implementation/](https://github.com/contextgeneric/cgp-knowledge-base/tree/main/cgp/implementation) (the `entrypoints/` document, the `asts/` stack it -drives, and any `functions/` helpers it relies on), and the governing `AGENTS.md` files that define -how those documents stay in sync with the code: [cgp-knowledge-base/cgp/AGENTS.md](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/AGENTS.md), -[cgp-knowledge-base/cgp/implementation/AGENTS.md](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/implementation/AGENTS.md), and -[crates/macros/cgp-macro-core/AGENTS.md](crates/macros/cgp-macro-core/AGENTS.md). These establish +iteration. Then read the knowledge base's documentation for the macro under review: its +[reference document](https://github.com/contextgeneric/cgp-knowledge-base/tree/main/cgp/reference), its +[implementation documents](https://github.com/contextgeneric/cgp-knowledge-base/tree/main/cgp/implementation) — the `entrypoints/` document, the +`asts/` stack it drives, and any `functions/` helpers it relies on — and the `AGENTS.md` files that +define how those documents stay in sync with the code +([the `cgp` section's](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/AGENTS.md), +[the implementation tree's](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/implementation/AGENTS.md), and +[cgp-macro-core's](crates/macros/cgp-macro-core/AGENTS.md)). These establish that the source is the single source of truth and that reference, implementation, snapshot, and skill are four views of it that must never drift. diff --git a/crates/macros/cgp-macro-test-util/README.md b/crates/macros/cgp-macro-test-util/README.md index 3ecbf08f..f3db5726 100644 --- a/crates/macros/cgp-macro-test-util/README.md +++ b/crates/macros/cgp-macro-test-util/README.md @@ -2,45 +2,27 @@ Snapshot-testing macros for the CGP procedural macros. -This crate exposes a family of `snapshot_*!` procedural macros that make it easy -to write **golden / snapshot tests** for the code generated by the core CGP -macros such as `#[cgp_component]`, `#[cgp_impl]`, `#[cgp_auto_getter]`, -`#[cgp_getter]`, `#[cgp_fn]`, `#[derive(HasField)]`, and -`delegate_components!`. - -The snapshots are asserted using the [`insta`](https://insta.rs) crate. - -## Why snapshot test the macros? - -The CGP macros generate a fair amount of boilerplate: consumer traits, provider -traits, blanket delegation impls, `IsProviderFor` impls, `UseContext` / -`UseField` / `RedirectLookup` providers, and so on. When we change the macro -internals, it is easy to accidentally change the generated output in an -unintended way. - -A snapshot test pins down the *exact* generated code as a human-readable string. -When the generated code changes, the snapshot test fails and shows a diff, -letting us review the change deliberately (and `cargo insta` makes accepting an -intended change a one-liner). - -Crucially, each snapshot macro does **two** things at once: - -1. It **emits the real generated code** into the surrounding module, exactly as - the underlying CGP macro would. The generated traits, structs, and impls are - therefore live and usable by the rest of the module — you can still wire them - up and assert their runtime behavior. -2. It **generates a `#[test]` function** that captures a pretty-printed string of - that same generated code and asserts it against an inline `insta` snapshot. - -Because of (1), migrating an existing test to a snapshot macro does not lose any -compile-time or runtime coverage — it only *adds* a snapshot assertion on top. +This crate exposes a family of `snapshot_*!` procedural macros that pin the code the +core CGP macros generate. Each one does **two** things at once: it emits the real +generated code into the surrounding module, exactly as the underlying CGP macro +would — so the traits, structs, and impls stay live and the rest of the module can +still wire them up and assert their runtime behavior — and it generates a `#[test]` +that asserts a pretty-printed string of that same code against an inline +[`insta`](https://insta.rs) snapshot. Adding or removing a snapshot therefore +changes only the golden assertion, never the compile-time or runtime coverage. + +Two documents carry what this README does not. The +[implementation document](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/implementation/entrypoints/snapshot_macros.md) +in the knowledge base explains how the macros are built and what an invocation +expands to; [crates/tests/AGENTS.md](../../tests/AGENTS.md) sets the convention for +*when* to snapshot — a macro's expansion is pinned only in the concept target that +owns that macro's feature, and written plainly everywhere else. ## Crate layout -This is the proc-macro crate. It is a thin shell: every macro simply forwards to -the implementation crate [`cgp-macro-test-util-lib`](../cgp-macro-test-util-lib), -which is a normal library crate so that the logic can be unit-tested without the -`proc-macro = true` restriction. +This is the proc-macro crate, a thin shell: every macro forwards to +[`cgp-macro-test-util-lib`](../cgp-macro-test-util-lib), a normal library crate so +the logic can be unit-tested without the `proc-macro = true` restriction. ``` cgp-macro-test-util/ # proc-macro entry points (#[proc_macro] fns) @@ -50,9 +32,8 @@ cgp-macro-test-util/ # proc-macro entry points (#[proc_macro] fns) └── functions/ # parse_attribute, pretty_format ``` -The implementation reuses the real macro logic from `cgp-macro-lib` -(`cgp_macro_lib::cgp_component`, `::cgp_impl`, `::cgp_getter`, etc.), so the -snapshot output is guaranteed to match what the production macros generate. +The implementation calls the real macro logic in `cgp-macro-lib`, not a copy of it, +so a snapshot is guaranteed to show what the production macros generate. ## Available macros @@ -74,29 +55,15 @@ snapshot output is guaranteed to match what the production macros generate. | `snapshot_delegate_and_check_components!` | `delegate_and_check_components!` | | `snapshot_cgp_namespace!` | `cgp_namespace!` | -## Anatomy of a snapshot invocation - -Every snapshot macro takes two parts: - -1. **The item under test**, written *exactly* as you would normally write the - underlying CGP macro invocation (the attribute plus the `trait` / `impl`, or - the full `delegate_components! { ... }` call). -2. **A test block** of the form: +Each accepts the same argument forms as the macro it wraps — `snapshot_cgp_component!` +takes both `#[cgp_component(Greeter)]` and the brace form, for instance — precisely +because it drives the production entry function. - ```text - () { - > - } - ``` +## Anatomy of an invocation - - `` becomes the name of the generated `#[test]` function, so it - must be unique within the module. - - `` is the identifier bound to the pretty-printed `&str` of the - generated code inside the test body. It is conventionally named `output`. - - The body is typically a single `insta::assert_snapshot!(, @"...")` - call with an inline snapshot. - -For example: +Every snapshot macro takes the item under test, written exactly as you would write +the underlying macro invocation, followed by a test block naming the generated +`#[test]` and the identifier bound to the pretty-printed expansion: ```rust use cgp::prelude::*; @@ -115,478 +82,31 @@ snapshot_cgp_getter! { } ``` -This expands to roughly: - -```rust -// (1) the real generated code, identical to what `#[cgp_getter]` produces: -pub trait HasName { /* ... */ } -impl<__Context__> HasName for __Context__ where /* ... */ { /* ... */ } -pub trait NameGetter<__Context__>: /* ... */ { /* ... */ } -pub struct NameGetterComponent; -/* ...UseContext / UseField / UseFields / WithProvider / RedirectLookup impls... */ - -// (2) the generated snapshot test: -#[test] -fn expand_has_name() { - let output = "...pretty-printed generated code..."; - assert_snapshot!(output, @"...generated code..."); -} -``` - -### `snapshot_cgp_component!` - -```rust -snapshot_cgp_component! { - #[cgp_component(Greeter)] - pub trait CanGreet: HasName { - fn greet(&self) -> String; - } - - expand_greeter(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Both the parenthesized form `#[cgp_component(Greeter)]` and the brace form -`#[cgp_component { provider: Greeter, ... }]` are accepted, mirroring the real -macro. - -### `snapshot_cgp_impl!` - -```rust -snapshot_cgp_impl! { - #[cgp_impl(new ValueToString)] - impl FooProvider for Context { - fn foo(&self, value: u32) -> String { - value.to_string() - } - } - - expand_value_to_string(output) { - assert_snapshot!(output, @"...") - } -} -``` - -### `snapshot_cgp_provider!` - -Wraps `#[cgp_provider]` provider impls. The item under test is the full provider -`impl` written exactly as you would normally write it under `#[cgp_provider]` — -the provider struct itself is expected to already be defined elsewhere: - -```rust -pub struct GreetHello; - -snapshot_cgp_provider! { - #[cgp_provider] - impl Greeter for GreetHello - where - Context: HasName, - { - fn greet(context: &Context) { - println!("Hello, {}!", context.name()); - } - } - - expand_greet_hello(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Both the default form `#[cgp_provider]` and the explicit component name form -`#[cgp_provider(GreeterComponent)]` are accepted, mirroring the real macro. In -addition to re-emitting the provider impl, the snapshot captures the generated -`IsProviderFor` impl. - -### `snapshot_cgp_new_provider!` - -Has the identical shape as `snapshot_cgp_provider!`, but wraps -`#[cgp_new_provider]`, which additionally defines the provider struct. The -snapshot therefore also captures the generated `struct` definition: - -```rust -snapshot_cgp_new_provider! { - #[cgp_new_provider] - impl Greeter for GreetHello - where - Context: HasName, - { - fn greet(context: &Context) { - println!("Hello, {}!", context.name()); - } - } - - expand_greet_hello(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Both the default form `#[cgp_new_provider]` and the explicit component name form -`#[cgp_new_provider(GreeterComponent)]` are accepted, mirroring the real macro. - -### `snapshot_cgp_auto_getter!` / `snapshot_cgp_getter!` - -```rust -snapshot_cgp_auto_getter! { - #[cgp_auto_getter] - pub trait HasName { - fn name(&self) -> &str; - } - - expand_has_name(output) { - assert_snapshot!(output, @"...") - } -} -``` - -`snapshot_cgp_getter!` has the identical shape and also accepts the custom -provider name forms `#[cgp_getter(NameGetter)]` and -`#[cgp_getter { provider: ..., name: ... }]`. - -### `snapshot_cgp_fn!` - -The item under test is the full `#[cgp_fn]` function, written exactly as you -would normally write it — including any extra attributes such as `#[uses(...)]`, -`#[extend(...)]`, `#[extend_where(...)]`, `#[use_type(...)]`, `#[impl_generics(...)]`, -or `#[async_trait]`, all kept above the `fn` with `#[cgp_fn]` first: - -```rust -snapshot_cgp_fn! { - #[cgp_fn] - pub fn rectangle_area(&self, #[implicit] width: f64, #[implicit] height: f64) -> f64 { - width * height - } - - expand_rectangle_area(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Both the default form `#[cgp_fn]` and the custom trait name form -`#[cgp_fn(CanCalculateRectangleArea)]` are accepted, mirroring the real macro. - -### `snapshot_cgp_type!` - -Wraps `#[cgp_type]` abstract-type traits. The item under test is written exactly -as you would normally write the `#[cgp_type]` invocation: - -```rust -snapshot_cgp_type! { - #[cgp_type] - pub trait HasScalarType { - type Scalar; - } - - expand_has_scalar_type(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Both the default form `#[cgp_type]` and the custom provider name forms -`#[cgp_type(ScalarTypeProvider)]` and -`#[cgp_type { provider: ... }]` are accepted, mirroring the -real macro. In addition to the usual `#[cgp_component]` output, the snapshot -captures the extra `UseType` / `WithProvider` providers that `#[cgp_type]` -generates. - -### `snapshot_derive_has_field!` - -Wraps the `#[derive(HasField)]` derive macro. The item under test is the -`struct` definition written exactly as you would normally write it under -`#[derive(HasField)]`: - -```rust -snapshot_derive_has_field! { - #[derive(HasField)] - pub struct Inner { - pub name: String, - } - - expand_inner(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Unlike the attribute-based snapshot macros, the derive is re-emitted verbatim -above the struct (so the real `HasField` / `HasFieldMut` impls are still -generated by the compiler), while the snapshot captures the *derived* impls, -i.e. the `HasField` and `HasFieldMut` impls that `#[derive(HasField)]` produces -for each field. - -All struct shapes accepted by `#[derive(HasField)]` are accepted, since the body -is forwarded to the real derive verbatim: - -- **named-field structs**, keyed by `Symbol!("...")` field-name tags; -- **tuple structs**, keyed by `Index` positional tags; -- **structs with lifetime parameters**, whose lifetimes are propagated to the - generated impls. - -Because the snapshot macro emits a `#[test]` function, a `#[derive(HasField)]` -struct that currently lives *inside* a test function must be lifted out before it -can be wrapped — see [Migrating existing tests](#migrating-existing-tests). The -usual pattern is to move the struct into an inner `mod` alongside the snapshot -macro, and keep the original `#[test]` in that same module so its runtime -assertions are preserved. - -### `snapshot_derive_has_fields!` - -Wraps the `#[derive(HasFields)]` derive macro. The item under test is the type -definition written exactly as you would normally write it under -`#[derive(HasFields)]`, with any other derives kept alongside it: - -```rust -snapshot_derive_has_fields! { - #[derive(HasFields)] - #[derive(Clone, Debug, Eq, PartialEq)] - pub struct Person { - pub name: String, - } - - expand_person(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Like the other derive snapshot macros, the type is re-emitted verbatim with its -derives (so the compiler still generates the real impls), while the snapshot -captures the `HasFields` / `HasFieldsRef` / `FromFields` / `ToFields` / -`ToFieldsRef` impls that `#[derive(HasFields)]` produces. - -Both **`struct`s** and **`enum`s** are accepted: - -- For a **`struct`** (a *record*), the field list is a product type - (`Cons` / `Nil`), and the conversions destructure/build the struct fields. -- For an **`enum`** (a *variant*), the field list is a sum type - (`Either` / `Void`), and the conversions `match` over the variants. - -Generic type parameters and lifetimes are propagated to the generated impls. -As with the other derive snapshot macros, a `#[derive(HasFields)]` type that -currently lives *inside* a test function must be lifted out before it can be -wrapped — see [Migrating existing tests](#migrating-existing-tests). The usual -pattern is to move each type into its own inner `mod` alongside the snapshot -macro, and keep the original `#[test]` in that same module so its runtime -assertions are preserved. - -### `snapshot_derive_cgp_data!` - -Wraps the `#[derive(CgpData)]` derive macro. `CgpData` is the umbrella derive for -extensible data types: rather than a single trait, it generates the *entire* -field/variant toolkit for a `struct` or `enum`. The item under test is the type -definition written exactly as you would normally write it under -`#[derive(CgpData)]`, with any other derives kept alongside it: - -```rust -snapshot_derive_cgp_data! { - #[derive(CgpData)] - #[derive(Debug, Eq, PartialEq)] - pub struct FooBarBaz { - pub foo: u64, - pub bar: String, - pub baz: bool, - } - - expand_foo_bar_baz(output) { - assert_snapshot!(output, @"...") - } -} -``` - -Like `snapshot_derive_has_field!`, the type is re-emitted verbatim with its -derives (so the compiler still generates the real impls), while the snapshot -captures the *derived* impls that `#[derive(CgpData)]` produces. Because -`CgpData` generates a large amount of code, these snapshots are correspondingly -large — but that is precisely what makes them valuable as golden tests. - -What gets captured depends on whether the item is a `struct` (a *record*) or an -`enum` (a *variant*): - -- For a **record `struct`**, the snapshot covers the per-field `HasField` / - `HasFieldMut` impls, the `HasFields` / `HasFieldsRef` field-list impls, the - `FromFields` / `ToFields` / `ToFieldsRef` conversions, and the generated - `__Partial*` builder struct together with its `HasBuilder` / `IntoBuilder` / - `PartialData` / `FinalizeBuild` / `UpdateField` impls. -- For a **variant `enum`**, the snapshot covers the `HasFields` / - `HasFieldsRef` impls, the `FromFields` / `ToFields` / `ToFieldsRef` - conversions, the per-variant `FromVariant` impls, the generated `__Partial*` - / `__PartialRef*` enums, and the `HasExtractor` / `HasExtractorRef` / - `HasExtractorMut` / `FinalizeExtract` / `ExtractField` extractor impls. - -All shapes accepted by `#[derive(CgpData)]` are accepted, since the body is -forwarded to the real derive verbatim — including generic type parameters (with -`where` clauses), tuple structs keyed by `Index`, and generic enums. - -As with the other derive snapshot macros, a `#[derive(CgpData)]` type that -currently lives *inside* a test function must be lifted out before it can be -wrapped — see [Migrating existing tests](#migrating-existing-tests). When several -auxiliary types share a file, the usual convention is to wrap only the primary -type under test in `snapshot_derive_cgp_data!`, and leave the auxiliary fixture -types as plain `#[derive(CgpData)]` definitions. - -### `snapshot_delegate_components!` - -Here the *whole* `delegate_components! { ... }` invocation is written verbatim, -followed by the test block: - -```rust -snapshot_delegate_components! { - delegate_components! { - new FooComponents { - Index<0>: u64, - Index<1>: String, - } - } - - expand_foo_components(output) { - assert_snapshot!(output, @"...") - } -} -``` - -### `snapshot_check_components!` - -The *whole* `check_components! { ... }` invocation is written verbatim, followed -by the test block: - -```rust -snapshot_check_components! { - check_components! { - App { - ErrorRaiserComponent: String, - } - } - - expand_check_app(output) { - assert_snapshot!(output, @"...") - } -} -``` - -The body is forwarded to the real macro verbatim, so every `check_components!` -form is accepted — including the `#[check_trait(...)]` and -`#[check_providers(...)]` attributes, `#[check_params(...)]` / generic -parameters, the array syntax for grouping components and params, and multiple -check specs in a single invocation. The snapshot captures the generated check -trait(s) and their `impl` blocks. - -Note that the snapshot macro emits the same check trait/impls into the -surrounding module, so the compile-time wiring check is preserved — it only -*adds* a snapshot assertion on top. - -### `snapshot_delegate_and_check_components!` - -Likewise, the *whole* `delegate_and_check_components! { ... }` invocation is -written verbatim, followed by the test block: - -```rust -snapshot_delegate_and_check_components! { - delegate_and_check_components! { - #[check_trait(CheckMyContext)] - MyContext { - NameTypeProviderComponent: UseType, - NameGetterComponent: UseField, - } - } - - expand_my_context(output) { - assert_snapshot!(output, @"...") - } -} -``` - -All `delegate_and_check_components!` forms are accepted, since the body is -forwarded to the real macro verbatim — including `#[check_trait(...)]`, -`#[check_params(...)]`, `#[skip_check]`, generic parameters, and array syntax. -The snapshot captures both the generated `DelegateComponent` / `IsProviderFor` -impls *and* the generated check trait + impls. - -### `snapshot_cgp_namespace!` - -Like `snapshot_delegate_components!`, the *whole* `cgp_namespace! { ... }` -invocation is written verbatim, followed by the test block: - -```rust -snapshot_cgp_namespace! { - cgp_namespace! { - new MyNamespace { - FooProviderComponent => - @MyApp.MyFooComponent, - } - } - - expand_my_namespace(output) { - assert_snapshot!(output, @"...") - } -} -``` - -All `cgp_namespace!` forms are accepted, since the body is forwarded to the real -macro verbatim — including parent namespaces (`new Extended: DefaultNamespace { ... }`), -symbol/type path keys (`@my_app.MyFooComponent`), and array keys. +That emits the real `#[cgp_getter]` expansion — the consumer trait and its blanket +impl, the provider trait, the marker struct, and the `UseField`/`UseFields`/ +`RedirectLookup` provider impls — and then a `fn expand_has_name()` holding the same +code as a string. The test-function name must be unique within its module, so give +each snapshot in a file a distinct one. ## Workflow with `insta` -Write the test with an **empty** inline snapshot first: - -```rust -expand_has_name(output) { - assert_snapshot!(output, @"") -} -``` - -Then let `insta` fill it in: +Write the test with an **empty** inline snapshot first (`assert_snapshot!(output, @"")`), +then let `insta` fill it in: ```bash -# review interactively -cargo insta test -p cgp-tests --test getter -cargo insta review - -# or accept everything non-interactively -cargo insta test -p cgp-tests --accept - -# or via the env var -INSTA_UPDATE=always cargo test -p cgp-tests +cargo insta test -p cgp-tests --test getter # then: cargo insta review +cargo insta test -p cgp-tests --accept # accept everything non-interactively +INSTA_UPDATE=always cargo test -p cgp-tests # or via the env var ``` -After the first accept, the inline `@"..."` is populated with the pretty-printed -generated code. On subsequent runs the test fails if the generated code changes, -showing a diff you can re-accept once you've confirmed the change is intended. - -## Migrating existing tests - -When migrating an existing macro test, two situations come up: - -- **Module-level macro use** — wrap the existing `#[cgp_component]` / `#[cgp_impl]` - / `#[cgp_getter]` / `#[cgp_auto_getter]` / `#[derive(HasField)]` / - `delegate_components!` in the matching `snapshot_*!` macro and append a test - block. Nothing else needs to change, since the snapshot macro re-emits the same - code. - -- **CGP components defined *inside* a test function** — the snapshot macro - generates a `#[test]` function, which cannot be nested inside another function. - So the CGP components must be lifted out of the function body: - - - If the test is **purely compile-time** (it only checks that the wiring - compiles, with no runtime assertions), turn the whole test into an inner - `mod` and place the snapshot macro there. - - If the test has **runtime assertions**, create an inner `mod` that holds the - extracted CGP components wrapped in the snapshot macro, and keep the original - `#[test]` function, now referencing the items from the inner module. This - preserves the runtime coverage while adding the snapshot assertion. +After the first accept, the inline `@"..."` holds the pretty-printed generated code. +On later runs the test fails if that code changes, showing a diff to re-accept once +you have confirmed the change is intended. -## Notes / limitations +## Notes and limitations -- The pretty-printing is done with - [`prettyplease`](https://crates.io/crates/prettyplease), and any - macro-prelude noise is stripped beforehand - (`cgp_macro_core::functions::strip_macro_prelude`), so the snapshot is stable, - readable Rust source. -- The `` must be unique within its module — when a single file - contains several snapshots, give each a distinct name (e.g. `expand_has_name`, - `expand_greeter`). +- Pretty-printing goes through [`prettyplease`](https://crates.io/crates/prettyplease), + with macro-prelude noise stripped first (`cgp_macro_core::functions::strip_macro_prelude`), + so a snapshot is stable, readable Rust source. +- A derive member re-emits the annotated item itself, since a derive macro produces + only the code it adds and not the struct or enum it decorates. diff --git a/crates/main/cgp/README.md b/crates/main/cgp/README.md index d53d3ddd..8236647a 100644 --- a/crates/main/cgp/README.md +++ b/crates/main/cgp/README.md @@ -1,14 +1,13 @@ - -# `cgp` - Context-Generic Programming in Rust +# `cgp` — Context-Generic Programming in Rust ## Overview -The `cgp` project contains a collection of micro Rust crates that empowers _context-generic programming_ (CGP), a new modular programming paradigm in Rust. +Context-Generic Programming (CGP) is a language extension for Rust, with pluggable trait implementations at compile-time. In ordinary Rust a trait has one implementation per type; CGP lets one trait have many interchangeable implementations and lets each *context* choose which one it uses, through a small wiring table the compiler resolves statically — so the flexibility costs nothing at runtime. The `cgp` crate is the facade over a collection of micro crates that provide it, and it re-exports everything through `cgp::prelude`. -To learn more about context-generic programming, check out the our website [contextgeneric.dev](https://contextgeneric.dev/), and our book [Context-Generic Programming Patterns](https://patterns.contextgeneric.dev/). +To learn more, see the website [contextgeneric.dev](https://contextgeneric.dev/) and the book [Context-Generic Programming Patterns](https://patterns.contextgeneric.dev/).
-At the moment, the `cgp` crate its constructs are mostly undocumented within Rustdoc. The best way to understand CGP is to read the book [Context-Generic Programming Patterns](https://patterns.contextgeneric.dev/). +The `cgp` constructs are still mostly undocumented within Rustdoc. The best way to learn CGP today is the book [Context-Generic Programming Patterns](https://patterns.contextgeneric.dev/); for the exhaustive per-construct semantics, see the [CGP knowledge base](https://github.com/contextgeneric/cgp-knowledge-base).
diff --git a/crates/tests/AGENTS.md b/crates/tests/AGENTS.md index ba70f9db..2ffaf438 100644 --- a/crates/tests/AGENTS.md +++ b/crates/tests/AGENTS.md @@ -162,8 +162,8 @@ diagnostic *and* how cargo-cgp presents it, and links the backing `cargo-cgp` UI as a GitHub URL. When a construct change alters such a diagnostic, the cross-project [sync rule](../../AGENTS.md) applies — update the `cargo-cgp` fixture and the class doc here together when both repos are checked out. A macro's **implementation document** -still records the *rejection* cases it catches and its behavioral tests (its `## Known -issues` and `## Tests` sections); the accept-then-fail classes are documented in the +still records the *rejection* cases it catches and its behavioral tests (its +`## Known issues` and `## Tests` sections); the accept-then-fail classes are documented in the error catalog, and a macro's `## Failure modes` section links out to the catalog class and its `cargo-cgp` fixture rather than to a local fixture. diff --git a/crates/tests/README.md b/crates/tests/README.md index 7bf38235..22ee1392 100644 --- a/crates/tests/README.md +++ b/crates/tests/README.md @@ -1,16 +1,16 @@ # CGP test suite -This directory holds the test suite for Context-Generic Programming. The tests -are organized **by CGP concept** — basic delegation, abstract types, implicit -arguments, namespaces, and so on — rather than by the macro that implements each -concept, because a single macro (for example `delegate_components!`) serves many -concepts at once. If you are maintaining or extending the suite, read -[AGENTS.md](AGENTS.md) first; it is the authoritative guide to the conventions. -This README is the map. +This directory holds the test suite for Context-Generic Programming, organized **by +CGP concept** — basic delegation, abstract types, implicit arguments, namespaces, +and so on — rather than by the macro that implements each concept, because a single +macro such as `delegate_components!` serves many concepts at once. This README is +the map of what is here and how to run it; [AGENTS.md](AGENTS.md) is the +authoritative guide to the conventions, and you should read it before adding, +moving, or refactoring a test. ## The crates -The suite is split into two kinds of crate, each with a distinct job. +The suite is split into two crates, each with a distinct job. **`cgp-tests`** is the main suite: realistic example code that must compile and run. Because much of CGP is compile-time wiring, a test here often passes simply @@ -22,46 +22,28 @@ where the canonical macro-expansion snapshots live. refuses during expansion — and for pinning the invalid tokens a macro currently emits. -**Post-codegen compile failures live in `cargo-cgp`, not here.** The cases where a -macro *accepts* input but its *expansion* then fails to compile — and the -cross-crate coherence and orphan-rule fixtures that need a companion crate — were -migrated to `cargo-cgp`'s UI test suite, which snapshots the readable, root-cause-first -errors `cargo-cgp` renders for each class (its `.rust.stderr` still records the raw -compiler output as the "before"). `cargo-cgp` is CGP's first-class error toolchain, so -those diagnostics are pinned where the tool that improves them lives; the -[error catalog](https://github.com/contextgeneric/cgp-knowledge-base/blob/main/cgp/errors/README.md) links each class to the fixture that -backs it. See -[cargo-cgp's UI tests](https://github.com/contextgeneric/cargo-cgp/blob/main/tests/README.md). +A third category lives in another repository: the cases where a macro *accepts* +input whose *expansion* then fails to compile are UI fixtures in +[`cargo-cgp`](https://github.com/contextgeneric/cargo-cgp/blob/main/tests/README.md), +so each is pinned as the readable error the tool renders for it. AGENTS.md's +"Adding a failure case" says which of the three a new case belongs in. ## How the tests are laid out Inside `cgp-tests`, each concept is one **integration test target**, which Cargo -compiles as its own crate (its own coherence scope). A target is an entrypoint file -`tests/_tests.rs` plus a module directory `tests//` holding one -`.rs` file per unit test. Each unit-test file is self-contained: it defines its own -components, providers, and context types at module scope, so the type-level wiring -of one test never leaks into another. `tests/basic_delegation/` is the reference -example of this layout. +compiles as its own crate — and therefore its own coherence scope. A target is an +entrypoint file `tests/_tests.rs` plus a module directory +`tests//` holding one `.rs` file per unit test, each self-contained so the +type-level wiring of one test never leaks into another. `tests/basic_delegation/` +is the reference example of the layout. The concept targets currently cover: basic delegation, impl-side dependencies, implicit arguments, higher-order providers, generic components, abstract types, getters, field access, extensible records, extensible variants, checking, dispatching, namespaces, handlers, monadic handlers, async and Send bounds, and -blanket traits. This set grows and subdivides over time — when a concept -accumulates too many cases to stay coherent, it is split into finer targets. - -`cgp-macro-tests` follows the same target/`_tests.rs` shape: `ident_with_type_params` -for parser corner cases, and the failure-case targets `parser_rejections` and -`invalid_expansion`. - -## Snapshots - -Many tests assert the exact code a macro generates, using the `snapshot_*!` macros -from `cgp-macro-test-util`. Each such macro emits the real generated code into the -module **and** generates a `#[test]` asserting a pretty-printed inline `insta` -snapshot of it. Snapshots are used deliberately: a macro's expansion is snapshotted -only in the concept target that owns that macro's feature, and written plainly -everywhere else (see [AGENTS.md](AGENTS.md) for the ownership rules). +blanket traits. The set grows and subdivides over time. `cgp-macro-tests` follows +the same shape, with `ident_with_type_params` for parser corner cases and the +failure-case targets `parser_rejections` and `invalid_expansion`. ## Running the tests @@ -74,5 +56,9 @@ cargo insta test -p cgp-tests --review # review snapshot diffs interact cargo insta test -p cgp-tests --accept # accept intended snapshot changes ``` -When a `snapshot_*!` test fails it prints a diff of the generated code; accept the -new output with `cargo insta` only after confirming the change is intended. +Many tests assert the exact code a macro generates, through the `snapshot_*!` +macros from `cgp-macro-test-util`: each emits the real generated code into the +module *and* generates a `#[test]` asserting a pretty-printed inline `insta` +snapshot of it. So a failing snapshot prints a diff of the generated code — accept +it with `cargo insta` only after confirming the change is intended. Which target +owns a given macro's snapshot is a convention AGENTS.md sets out. From b232adc085eb9f254b8d15f5638ec0c06af280ff Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 28 Jul 2026 21:46:36 +0200 Subject: [PATCH 02/10] Ground `#[use_type]` aliases in nested pins and expression paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two positions where an imported alias was left unresolved, both inconsistencies rather than boundaries: the alias already resolved in neighbouring positions, so the substitution was incomplete rather than deliberately narrow. An equality pin's right-hand side was matched by whole-type equality, so `{HashedPassword = Password}` grounded but `{Transaction = Tx}` emitted a bare `Db` that resolved to nothing. `find_type_equality` now runs `SubstituteAbstractTypes` over the right-hand side, which grounds an alias wherever it occurs inside it — nested in a generic argument or inside a qualified path — and collapses the two cases into one rule. The pinned alias is excluded from its own substitution, so a degenerate `{Foo = Foo}` stays an unresolved-name error rather than becoming a vacuous bound. An alias qualifying an expression path was also passed through, even though a `let` annotation in the same body was rewritten. `SubstituteAbstractTypes` gains `visit_expr_path_mut`, rewriting `Transaction::begin_from(pool)` into the qualified-type form `<::Transaction>::begin_from(pool)`. The boundary kept is arity: a bare single-segment path in expression position names a value, which an abstract type can never be, so an alias sharing its name with a unit struct still resolves to the struct. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EVFcwx5jehpRRFieG8L8Q1 --- .../attributes/use_type/type_predicates.rs | 81 ++++----- .../src/visitors/substitute_abstract_type.rs | 45 ++++- .../cgp-tests/tests/abstract_types/mod.rs | 2 + .../use_type_fn_equality_nested.rs | 85 +++++++++ .../abstract_types/use_type_fn_expr_path.rs | 171 ++++++++++++++++++ 5 files changed, 342 insertions(+), 42 deletions(-) create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs index ebb9cfdd..ec95a412 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs @@ -1,11 +1,13 @@ use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; +use quote::quote; use syn::punctuated::Punctuated; use syn::token::Comma; +use syn::visit_mut::VisitMut; use syn::{Ident, Type, WherePredicate}; use crate::functions::parse_internal; use crate::types::attributes::{UseTypeAttribute, UseTypeIdent}; +use crate::visitors::SubstituteAbstractTypes; /// Derive the impl-side `where` predicates a set of `#[use_type]` specs /// contributes: one `Context: Trait` bound per spec, carrying any type-equality @@ -16,7 +18,7 @@ pub fn derive_use_type_predicates(specs: &[UseTypeAttribute]) -> syn::Result syn::Result<()> { fn find_type_equalities( current_spec: &UseTypeAttribute, specs: &[UseTypeAttribute], -) -> syn::Result> { - let mut equalities = Vec::new(); - - for current_type_ident in current_spec.type_idents.iter() { - if let Some(equality) = find_type_equality(current_type_ident, current_spec, specs)? { - equalities.push(equality); - } - } - - Ok(equalities) +) -> Vec<(Ident, Type)> { + current_spec + .type_idents + .iter() + .filter_map(|current_type_ident| find_type_equality(current_type_ident, specs)) + .collect() } +/// Ground one `= T` pin: rewrite every imported alias appearing *anywhere +/// inside* the pin's right-hand side into its fully-qualified projection, so +/// `{Transaction = Tx}` grounds its nested `Db` exactly as +/// `{HashedPassword = Password}` grounds a bare one. Substituting through the +/// shared visitor rather than comparing the whole type is what makes the two +/// cases one rule — the right-hand side is an ordinary type, and an alias is +/// resolved wherever it occurs in it. +/// +/// The pinned alias itself is excluded from the substitution set, so a +/// degenerate self-pin (`{Foo = Foo}`) stays the unresolved-name error it +/// already was rather than silently becoming a vacuous bound. fn find_type_equality( current_ident: &UseTypeIdent, - current_spec: &UseTypeAttribute, specs: &[UseTypeAttribute], -) -> syn::Result> { - if let Some(equal_target) = current_ident.equals.clone() { - for spec in specs.iter() { - if core::ptr::eq(spec, current_spec) { - // Skip the current spec - continue; - } +) -> Option<(Ident, Type)> { + let mut equal_target = current_ident.equals.clone()?; - for match_use_type in spec.type_idents.iter() { - let match_type: Type = - parse_internal(match_use_type.alias_ident().to_token_stream())?; - if match_type == equal_target { - let trait_path = &spec.trait_path; - let current_type_ident = ¤t_ident.type_ident; - let match_type_ident = &match_use_type.type_ident; - let context_type = &spec.context_type; - - let equal_target: Type = parse_internal! { - <#context_type as #trait_path>::#match_type_ident - }; - - return Ok(Some((current_type_ident.clone(), equal_target))); - } - } - } + let others = specs_excluding_alias(specs, current_ident.alias_ident()); + SubstituteAbstractTypes::new(&others).visit_type_mut(&mut equal_target); - Ok(Some((current_ident.type_ident.clone(), equal_target))) - } else { - Ok(None) - } + Some((current_ident.type_ident.clone(), equal_target)) +} + +/// The grounded specs with one alias dropped, for substituting inside that +/// alias's own equality pin. +fn specs_excluding_alias(specs: &[UseTypeAttribute], alias: &Ident) -> Vec { + specs + .iter() + .map(|spec| { + let mut spec = spec.clone(); + spec.type_idents + .retain(|type_ident| type_ident.alias_ident() != alias); + spec + }) + .collect() } diff --git a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs index 71d85f7c..4305a746 100644 --- a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs +++ b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs @@ -1,5 +1,6 @@ +use syn::punctuated::Punctuated; use syn::visit_mut::VisitMut; -use syn::{PathArguments, Type, TypePath, parse_quote, visit_mut}; +use syn::{ExprPath, PathArguments, PathSegment, Token, Type, TypePath, parse_quote, visit_mut}; use crate::types::attributes::UseTypeAttribute; @@ -57,4 +58,46 @@ impl VisitMut for SubstituteAbstractTypes<'_> { } visit_mut::visit_type_mut(self, ty); } + + /// Rewrite an alias that *qualifies* an expression path — `Transaction::begin_from(pool)` — + /// into the qualified-type form `<::Transaction>::begin_from(pool)`. + /// + /// The alias is already rewritten in every type position inside a body, a `let` annotation + /// included, so leaving it unresolved as the qualifier of an associated-function or + /// associated-const call was an inconsistency rather than a deliberate boundary: the author has + /// claimed the name by importing it, and `forbid_duplicate_aliases` guarantees nothing else in + /// the import list claims it too. + /// + /// Only a path of **two or more** segments is rewritten, which is what keeps the rewrite + /// unambiguous. A multi-segment `Transaction::foo` can only mean an associated item of the type, + /// while a bare single-segment `Transaction` in expression position is a *value* — a unit struct + /// or an enum variant the author means — and an abstract type can never be one, so it is left + /// alone. + fn visit_expr_path_mut(&mut self, expr: &mut ExprPath) { + if expr.qself.is_none() + && expr.path.leading_colon.is_none() + && expr.path.segments.len() > 1 + && matches!(expr.path.segments[0].arguments, PathArguments::None) + { + // Resolve against the specs before mutating, so the replacement is not computed while + // the path it replaces is still borrowed. + let replacement = self.specs.iter().find_map(|spec| { + let replacement_ident = spec.replace_ident(&expr.path.segments[0].ident)?; + let trait_path = &spec.trait_path; + let context_type = &spec.context_type; + Some(parse_quote! { <#context_type as #trait_path>::#replacement_ident }) + }); + + if let Some(ty) = replacement { + let ty: Type = ty; + let rest: Punctuated = + expr.path.segments.iter().skip(1).cloned().collect(); + + *expr = parse_quote! { <#ty>::#rest }; + self.is_changed = true; + return; + } + } + visit_mut::visit_expr_path_mut(self, expr); + } } diff --git a/crates/tests/cgp-tests/tests/abstract_types/mod.rs b/crates/tests/cgp-tests/tests/abstract_types/mod.rs index 612a5c40..7a1ad7b1 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/mod.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/mod.rs @@ -29,6 +29,8 @@ pub mod use_type_fn_alias; pub mod use_type_fn_deep_foreign; pub mod use_type_fn_equality; pub mod use_type_fn_equality_cross_trait; +pub mod use_type_fn_equality_nested; +pub mod use_type_fn_expr_path; pub mod use_type_fn_extend; pub mod use_type_fn_foreign; pub mod use_type_fn_foreign_equality; diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs new file mode 100644 index 00000000..5e1795eb --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs @@ -0,0 +1,85 @@ +//! `#[use_type]` type-equality whose right-hand side *contains* an imported alias +//! rather than *being* one: `#[use_type(HasDbType.Db, HasTransactionType.{Transaction = Tx})]`. +//! +//! The cross-trait sibling `use_type_fn_equality_cross_trait` pins the case where +//! the pin's right-hand side *is* another alias (`{Bar as Baz = Foo}`). This file +//! pins the general rule that supersedes it: the right-hand side is an ordinary +//! type, and an imported alias is grounded wherever it occurs inside it. Both +//! shapes an alias can hide in are covered — nested in a generic argument list +//! (`Tx`) and inside a qualified path (`::Transaction`) — each +//! emitting a bound whose right-hand side names `::Db` rather +//! than a bare `Db` that resolves to nothing. +//! +//! The pinned alias itself is excluded from its own substitution, so a degenerate +//! self-pin stays the unresolved-name error it already was. +//! +//! See cgp-knowledge-base/cgp/reference/attributes/use_type.md and +//! cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md. + +use cgp_macro_test_util::snapshot_cgp_fn; + +pub trait Database: Sized { + type Transaction; +} + +pub struct Tx(pub core::marker::PhantomData); + +pub trait HasDbType { + type Db: Database; +} + +pub trait HasTransactionType { + type Transaction; +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasTransactionType.{Transaction = Tx})] + pub fn begin_nested(&self) -> Transaction { + todo!() + } + + expand_begin_nested(output) { + insta::assert_snapshot!(output, @" + pub trait BeginNested: HasDbType + HasTransactionType { + fn begin_nested(&self) -> ::Transaction; + } + impl<__Context__> BeginNested for __Context__ + where + Self: HasDbType, + Self: HasTransactionType::Db>>, + { + fn begin_nested(&self) -> ::Transaction { + todo!() + } + } + ") + } +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasTransactionType.{Transaction = ::Transaction})] + pub fn begin_projected(&self) -> Transaction { + todo!() + } + + expand_begin_projected(output) { + insta::assert_snapshot!(output, @" + pub trait BeginProjected: HasDbType + HasTransactionType { + fn begin_projected(&self) -> ::Transaction; + } + impl<__Context__> BeginProjected for __Context__ + where + Self: HasDbType, + Self: HasTransactionType< + Transaction = <::Db as Database>::Transaction, + >, + { + fn begin_projected(&self) -> ::Transaction { + todo!() + } + } + ") + } +} diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs new file mode 100644 index 00000000..dddba11a --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs @@ -0,0 +1,171 @@ +//! `#[use_type]` resolving an alias that *qualifies* an expression path: +//! `Transaction::begin_from(pool)` inside a `#[cgp_fn]` body. +//! +//! The substitution rewrites an imported alias in every type position, including a +//! `let` annotation inside the body, so leaving it unresolved as the qualifier of an +//! associated function or associated const was an inconsistency rather than a +//! boundary. Both are rewritten here into the qualified-type form +//! `<::Assoc>::item`, and the `let` annotation in the same body pins +//! that the type-position rewrite still works alongside it. +//! +//! The boundary the rewrite keeps is arity: a path of two or more segments can only +//! name an associated item of the type, while a bare single-segment path in +//! expression position is a *value* — a unit struct or an enum variant — which an +//! abstract type can never be, so it is left alone. `unit_struct_untouched` pins +//! that half: `Marker` in type position is the imported alias while `Marker` as an +//! expression stays the unit struct. +//! +//! See cgp-knowledge-base/cgp/reference/attributes/use_type.md and +//! cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md. + +use cgp_macro_test_util::snapshot_cgp_fn; + +pub trait Database: Sized { + type Row; +} + +pub struct Pool(pub core::marker::PhantomData); + +pub struct Tx(pub core::marker::PhantomData); + +pub struct Postgres; + +impl Database for Postgres { + type Row = String; +} + +pub trait CanBeginFrom { + const LABEL: &'static str; + + fn begin_from(pool: &Pool) -> Self; +} + +impl CanBeginFrom for Tx { + const LABEL: &'static str = "pg"; + + fn begin_from(_pool: &Pool) -> Self { + Tx(core::marker::PhantomData) + } +} + +pub trait HasDbType { + type Db: Database; +} + +pub trait HasTransactionType { + type Transaction; +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasTransactionType.Transaction)] + pub fn begin_transaction(&self, #[implicit] db: &Pool) -> Transaction + where + Transaction: CanBeginFrom, + { + let started: Transaction = Transaction::begin_from(db); + started + } + + expand_begin_transaction(output) { + insta::assert_snapshot!(output, @" + pub trait BeginTransaction: HasDbType + HasTransactionType { + fn begin_transaction(&self) -> ::Transaction; + } + impl<__Context__> BeginTransaction for __Context__ + where + ::Transaction: CanBeginFrom<::Db>, + Self: HasField< + Symbol<2, Chars<'d', Chars<'b', Nil>>>, + Value = Pool<::Db>, + >, + Self: HasDbType, + Self: HasTransactionType, + { + fn begin_transaction(&self) -> ::Transaction { + let db: &Pool<::Db> = self + .get_field( + ::core::marker::PhantomData::>>>, + ); + let started: ::Transaction = <::Transaction>::begin_from( + db, + ); + started + } + } + ") + } +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasTransactionType.Transaction)] + pub fn transaction_label(&self) -> &'static str + where + Transaction: CanBeginFrom, + { + Transaction::LABEL + } + + expand_transaction_label(output) { + insta::assert_snapshot!(output, @" + pub trait TransactionLabel: HasDbType + HasTransactionType { + fn transaction_label(&self) -> &'static str; + } + impl<__Context__> TransactionLabel for __Context__ + where + ::Transaction: CanBeginFrom<::Db>, + Self: HasDbType, + Self: HasTransactionType, + { + fn transaction_label(&self) -> &'static str { + <::Transaction>::LABEL + } + } + ") + } +} + +pub struct Marker; + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.{Db as Marker})] + pub fn unit_struct_untouched(&self, #[implicit] db: &Pool) -> Marker2 { + let _ = db; + Marker.into() + } + + expand_unit_struct_untouched(output) { + insta::assert_snapshot!(output, @" + pub trait UnitStructUntouched: HasDbType { + fn unit_struct_untouched(&self) -> Marker2; + } + impl<__Context__> UnitStructUntouched for __Context__ + where + Self: HasField< + Symbol<2, Chars<'d', Chars<'b', Nil>>>, + Value = Pool<::Db>, + >, + Self: HasDbType, + { + fn unit_struct_untouched(&self) -> Marker2 { + let db: &Pool<::Db> = self + .get_field( + ::core::marker::PhantomData::>>>, + ); + let _ = db; + Marker.into() + } + } + ") + } +} + +pub struct Marker2; + +impl From for Marker2 { + fn from(_: Marker) -> Self { + Marker2 + } +} From 81efe18c95de6ce8c4f68223a13c7e23ebf6e130 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 28 Jul 2026 22:24:28 +0200 Subject: [PATCH 03/10] Recurse after rewriting an expression-path alias The expression-path rewrite mutated the node and returned, skipping the recursion, so an alias in a *later* segment's generic arguments was left unsubstituted: `Transaction::tagged::(db)` emitted a bare `Db` and failed with `E0425`. Falling through to the recursion fixes it and cannot loop, because the rewritten node now carries a `qself` and both visit guards require `qself: None`. The asymmetry that hid this is worth noting: `visit_type_mut` uses the same return-after-mutate shape and is correct there, because its guard requires `PathArguments::None` so the node it replaces has no children to visit. The expression case accepts later segments that do carry arguments, so it needs the recursion the type case does not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EVFcwx5jehpRRFieG8L8Q1 --- .../src/visitors/substitute_abstract_type.rs | 5 +- .../abstract_types/use_type_fn_expr_path.rs | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs index 4305a746..2e6de4ac 100644 --- a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs +++ b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs @@ -95,7 +95,10 @@ impl VisitMut for SubstituteAbstractTypes<'_> { *expr = parse_quote! { <#ty>::#rest }; self.is_changed = true; - return; + // Fall through to the recursion rather than returning: a later segment may carry + // generic arguments of its own that name an alias — `Transaction::make::()` — + // and those are still to be substituted. Re-visiting the rewritten node cannot + // loop, because it now carries a `qself` and both guards require `qself: None`. } } visit_mut::visit_expr_path_mut(self, expr); diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs index dddba11a..3e024416 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_expr_path.rs @@ -38,6 +38,8 @@ pub trait CanBeginFrom { const LABEL: &'static str; fn begin_from(pool: &Pool) -> Self; + + fn tagged(pool: &Pool) -> Self; } impl CanBeginFrom for Tx { @@ -46,6 +48,10 @@ impl CanBeginFrom for Tx { fn begin_from(_pool: &Pool) -> Self { Tx(core::marker::PhantomData) } + + fn tagged(_pool: &Pool) -> Self { + Tx(core::marker::PhantomData) + } } pub trait HasDbType { @@ -126,6 +132,48 @@ snapshot_cgp_fn! { } } +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasTransactionType.Transaction)] + pub fn tagged_transaction(&self, #[implicit] db: &Pool) -> Transaction + where + Transaction: CanBeginFrom, + { + // The alias appears twice: as the path qualifier, and inside a *later* segment's + // turbofish. Both are substituted — the rewrite recurses into the node it just + // replaced rather than stopping at it. + Transaction::tagged::(db) + } + + expand_tagged_transaction(output) { + insta::assert_snapshot!(output, @" + pub trait TaggedTransaction: HasDbType + HasTransactionType { + fn tagged_transaction(&self) -> ::Transaction; + } + impl<__Context__> TaggedTransaction for __Context__ + where + ::Transaction: CanBeginFrom<::Db>, + Self: HasField< + Symbol<2, Chars<'d', Chars<'b', Nil>>>, + Value = Pool<::Db>, + >, + Self: HasDbType, + Self: HasTransactionType, + { + fn tagged_transaction(&self) -> ::Transaction { + let db: &Pool<::Db> = self + .get_field( + ::core::marker::PhantomData::>>>, + ); + <::Transaction>::tagged::< + ::Db, + >(db) + } + } + ") + } +} + pub struct Marker; snapshot_cgp_fn! { From 5606c507cdc9a978bc967442723e7ac008757edd Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Wed, 29 Jul 2026 20:55:44 +0200 Subject: [PATCH 04/10] Harden `#[use_type]`: reject grounding cycles, ground trait arguments, merge pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects, found by asking of each position whether the construct already handled the neighbouring one. **A grounding cycle is now rejected at macro time.** Two imports whose contexts resolve through each other (`HasA.A in B, HasB.B in A`) have no valid grounding order, and neither does the one-node `in A`. `forbid_grounding_cycles` builds the spec dependency graph, searches it depth-first, and reports a back edge on the token that closes the loop, naming the cycle (`` `B` -> `A` -> `B` ``). The check has to run *before* grounding, because the obvious after-the-fact test finds nothing. Grounding does not leave a cyclic alias bare: each pass substitutes against the previous snapshot, in which the other context is itself ungrounded, so every pass wraps one more layer and the alias ends up buried four levels down a projection. Whether a context is "still a bare alias" is therefore the wrong question; the spec graph states the problem directly. **An alias in the imported trait's own generic arguments is now grounded.** It was resolved in the context and in a pin's right-hand side but not here, which is two of the three positions it can occupy — a missing case rather than a boundary. So `HasDbType.Db, HasPoolType.Pool` now projects against `HasPoolType<::Db>` instead of emitting a bare `Db` that resolves to nothing. Grounding deliberately still stops at the trait path's *head*. That is not the missing third case: the head must name a trait and an alias names a type, so grounding it would turn a clear `E0405` into an unparseable projection. **A pin on a generic trait emitted invalid Rust.** `HasFooType.{Foo = u32}` built `HasFooType` and died inside the macro with a bare "failed to parse internal tokens to type `syn::generics::WherePredicate`" naming no cause. Pins now merge into the trait path's existing argument list, so the bound reads `HasFooType` — and `HasRefType<'a, Ref = &'a str>` where a lifetime must keep leading the list. A circular *pin* is deliberately left to the compiler, unlike a circular context. It grounds in one pass and means something well defined — two abstract types are equal, which a context may satisfy — so only the solver's ability to discharge it fails, and that is not knowable at expansion time. `bare_alias_ident` is factored out of the substitution visitor so the new `CollectBareAliases` reads dependency edges by exactly the rule the substitution rewrites by; a reference the check cannot see is precisely the input grounding then fails on. `groundable_types` names the positions once, so the grounding pass, the emitted bounds, and the cycle check cannot disagree about what grounding reaches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017xGdHGrJEtoZjSmFEhsChw --- .../types/attributes/use_type/attribute.rs | 39 +++- .../types/attributes/use_type/attributes.rs | 41 +++-- .../attributes/use_type/type_predicates.rs | 174 +++++++++++++++++- .../src/visitors/collect_bare_aliases.rs | 42 +++++ .../macros/cgp-macro-core/src/visitors/mod.rs | 2 + .../src/visitors/substitute_abstract_type.rs | 64 +++++-- .../tests/parser_rejections/use_type.rs | 99 +++++++++- .../cgp-tests/tests/abstract_types/mod.rs | 13 +- .../use_type_fn_generic_trait_equality.rs | 135 ++++++++++++++ .../use_type_fn_reverse_order.rs | 5 +- .../use_type_fn_shared_context.rs | 66 +++++++ .../use_type_fn_trait_arg_alias.rs | 143 ++++++++++++++ .../use_type_trait_arg_component.rs | 71 +++++++ 13 files changed, 847 insertions(+), 47 deletions(-) create mode 100644 crates/macros/cgp-macro-core/src/visitors/collect_bare_aliases.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_generic_trait_equality.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_shared_context.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_trait_arg_component.rs diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attribute.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attribute.rs index 91f39165..1496bd73 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attribute.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attribute.rs @@ -4,7 +4,7 @@ use syn::{Ident, Type}; use crate::parse_internal; use crate::types::attributes::UseTypeIdent; -use crate::types::ident::PathWithTypeArgs; +use crate::types::ident::{PathWithTypeArgs, TypeArg}; /// One `#[use_type(...)]` import spec: the owning trait path, one or more /// associated types to import from it, and a rewrite target (`Self`, or a named @@ -17,6 +17,43 @@ pub struct UseTypeAttribute { } impl UseTypeAttribute { + /// The type positions in this spec that *grounding* resolves: the context the + /// imported types are projected against, and the generic arguments of the + /// owning trait path. + /// + /// Both positions end up inside the emitted `>::Assoc` + /// path, so an alias left bare in either is an identifier that resolves to + /// nothing. Naming the set once — rather than at each of the three places that + /// walk it — is what keeps the grounding pass, the emitted bounds, and the + /// cycle check in agreement about what grounding reaches. + pub fn groundable_types(&self) -> impl Iterator { + core::iter::once(&self.context_type).chain( + self.trait_path + .type_args + .args + .iter() + .filter_map(|arg| match arg { + TypeArg::Type(ty) => Some(ty), + // A lifetime or const argument can never name an abstract type. + TypeArg::Lifetime(_) | TypeArg::Const(_) => None, + }), + ) + } + + /// [`Self::groundable_types`] by mutable reference, for the grounding pass. + pub fn groundable_types_mut(&mut self) -> impl Iterator { + core::iter::once(&mut self.context_type).chain( + self.trait_path + .type_args + .args + .iter_mut() + .filter_map(|arg| match arg { + TypeArg::Type(ty) => Some(ty), + TypeArg::Lifetime(_) | TypeArg::Const(_) => None, + }), + ) + } + pub fn replace_ident(&self, ident: &Ident) -> Option { for type_ident in &self.type_idents { if type_ident.alias_ident() == ident { diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs index ea81307a..f22f1e3d 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs @@ -5,7 +5,7 @@ use syn::{ItemImpl, ItemTrait, Type}; use crate::functions::parse_internal; use crate::types::attributes::UseTypeAttribute; use crate::types::attributes::use_type::type_predicates::{ - derive_use_type_predicates, forbid_duplicate_aliases, + derive_use_type_predicates, forbid_duplicate_aliases, forbid_grounding_cycles, }; use crate::visitors::SubstituteAbstractTypes; @@ -15,18 +15,27 @@ pub struct UseTypeAttributes { } impl UseTypeAttributes { - /// Resolve every spec's context type into fully-qualified form before it is - /// used, so that both the body substitution and the appended bounds agree on - /// one grounded context. + /// Resolve every spec's groundable positions into fully-qualified form before + /// they are used, so that the body substitution and the appended bounds agree + /// on one grounded context and one grounded trait path. /// - /// An `in Context` suffix whose `Context` is itself imported by another spec — - /// as in `#[use_type(HasTypes.Types, HasScalarType.Scalar in Types)]` — is - /// rewritten from the bare alias `Types` to `::Types`. - /// Contexts that name a real generic parameter or `Self` are left untouched. - /// The pass iterates to a fixpoint so a chain of links resolves fully; each - /// pass grounds one more level, so `attributes.len()` passes cover any - /// acyclic chain, and a cyclic reference simply stops making progress and - /// surfaces later as an ordinary unresolved-type error rather than looping. + /// Grounding reaches both positions named by + /// [`UseTypeAttribute::groundable_types`], because an alias left bare in + /// either would end up inside an emitted `>::Assoc` + /// path, resolving to nothing. An `in Context` suffix whose `Context` is + /// itself imported by another spec — as in + /// `#[use_type(HasTypes.Types, HasScalarType.Scalar in Types)]` — is rewritten + /// from the bare alias `Types` to `::Types`, and a trait + /// argument that names an alias is grounded the same way, so + /// `#[use_type(HasDbType.Db, HasPoolType.Pool)]` projects against + /// `HasPoolType<::Db>`. A position naming a real generic + /// parameter or `Self` is left untouched. + /// + /// The pass iterates to a fixpoint so a chain of links resolves fully. Each + /// pass grounds one more level of the dependency chain, so `attributes.len()` + /// passes cover any acyclic graph over that many specs — which is every graph + /// that reaches here, since `forbid_grounding_cycles` has already rejected the + /// cyclic ones. fn grounded_specs(&self) -> Vec { let mut grounded = self.attributes.clone(); @@ -36,7 +45,11 @@ impl UseTypeAttributes { for spec in grounded.iter_mut() { let mut visitor = SubstituteAbstractTypes::new(&snapshot); - visitor.visit_type_mut(&mut spec.context_type); + + for ty in spec.groundable_types_mut() { + visitor.visit_type_mut(ty); + } + changed |= visitor.is_changed; } @@ -54,6 +67,7 @@ impl UseTypeAttributes { } forbid_duplicate_aliases(&self.attributes)?; + forbid_grounding_cycles(&self.attributes)?; let grounded = self.grounded_specs(); @@ -99,6 +113,7 @@ impl UseTypeAttributes { } forbid_duplicate_aliases(&self.attributes)?; + forbid_grounding_cycles(&self.attributes)?; let grounded = self.grounded_specs(); diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs index ec95a412..045ddc0f 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeMap; + use proc_macro2::TokenStream; -use quote::quote; +use quote::{ToTokens, quote}; use syn::punctuated::Punctuated; use syn::token::Comma; use syn::visit_mut::VisitMut; @@ -7,7 +9,7 @@ use syn::{Ident, Type, WherePredicate}; use crate::functions::parse_internal; use crate::types::attributes::{UseTypeAttribute, UseTypeIdent}; -use crate::visitors::SubstituteAbstractTypes; +use crate::visitors::{SubstituteAbstractTypes, collect_bare_aliases}; /// Derive the impl-side `where` predicates a set of `#[use_type]` specs /// contributes: one `Context: Trait` bound per spec, carrying any type-equality @@ -28,16 +30,30 @@ pub fn derive_use_type_predicates(specs: &[UseTypeAttribute]) -> syn::Result = Punctuated::new(); + // The pins become associated-type bindings *inside* the trait path's + // own argument list, appended after whatever generic arguments the + // path already carries. They cannot be a second `<…>` group after the + // path, because `HasFooType` is not a trait bound — + // a generic trait plus a pin has to render as + // `HasFooType`. + let mut arguments: Punctuated = Punctuated::new(); + + for type_arg in trait_path.type_args.args.iter() { + arguments.push(type_arg.to_token_stream()); + } for (alias_ident, equal_target) in type_equalities.into_iter() { - constraints.push(quote! { + arguments.push(quote! { #alias_ident = #equal_target }); } + // Emit the path's head separately from the merged argument list, since + // `PathWithTypeArgs` renders its own arguments as a trailing group. + let trait_head = &trait_path.path; + predicates.push(parse_internal! { - #context_type: #trait_path < #constraints > + #context_type: #trait_head < #arguments > }); } } @@ -69,6 +85,154 @@ pub fn forbid_duplicate_aliases(specs: &[UseTypeAttribute]) -> syn::Result<()> { Ok(()) } +/// Reject `#[use_type]` imports whose groundable positions resolve through one +/// another in a cycle, since such an arrangement has no valid grounding order. +/// +/// Grounding resolves each spec's context and trait arguments against every other +/// spec, iterating until nothing changes, which lets the imports be written in any +/// order — `HasC.C in B, HasB.B in A, HasA.A` grounds exactly like the +/// front-to-back spelling. A cycle is the one arrangement with no order at all: +/// `#[use_type(HasA.A in B, HasB.B in A)]` asks for each context to be resolved +/// before the other. This check rejects it at macro time with the caret on the +/// alias that closes the loop. +/// +/// Detecting it *before* grounding is what makes the check possible, because the +/// obvious after-the-fact test does not work. Grounding does not leave a cyclic +/// alias bare: each pass substitutes against the previous pass's snapshot, in +/// which the other context is still ungrounded, so every pass wraps one more layer +/// and the alias ends up buried at the innermost position of a deep projection +/// (`<<::B as HasA>::A as HasB>::B`). Asking whether a context is +/// "still a bare alias" afterwards therefore finds nothing. The spec graph, by +/// contrast, states the problem directly. +pub fn forbid_grounding_cycles(specs: &[UseTypeAttribute]) -> syn::Result<()> { + // Which spec imports each alias. Aliases are unique across all specs, which + // `forbid_duplicate_aliases` has already established, so one owner per name. + let mut owner_of_alias: BTreeMap = BTreeMap::new(); + + for (index, spec) in specs.iter().enumerate() { + for type_ident in spec.type_idents.iter() { + owner_of_alias.insert(type_ident.alias_ident().to_string(), index); + } + } + + // The dependency edges out of each spec: for every alias its groundable + // positions reference, the spec that owns that alias, together with the + // referencing token so a rejection can point at what the user wrote. + let edges: Vec> = specs + .iter() + .map(|spec| { + spec.groundable_types() + .flat_map(collect_bare_aliases) + .filter_map(|ident| { + let target = owner_of_alias.get(&ident.to_string())?; + Some((*target, ident)) + }) + .collect() + }) + .collect(); + + // A depth-first search over the spec graph, reporting the first back edge it + // finds. + let mut search = CycleSearch { + edges: &edges, + state: vec![VisitState::Unvisited; specs.len()], + path: Vec::new(), + labels: Vec::new(), + }; + + for start in 0..specs.len() { + if let Some(error) = search.visit(start) { + return Err(error); + } + } + + Ok(()) +} + +#[derive(Clone, Copy, PartialEq)] +enum VisitState { + Unvisited, + /// On the current search path, so an edge back to it closes a cycle. + InProgress, + /// Fully explored and known to reach no cycle. + Done, +} + +/// The depth-first search over the spec dependency graph. +/// +/// `path` holds the specs on the current search path and `labels` the referencing +/// tokens that led between them, so a back edge can be rendered as the alias names +/// the user actually wrote rather than as spec indices. +struct CycleSearch<'a, 'edges> { + edges: &'edges [Vec<(usize, &'a Ident)>], + state: Vec, + path: Vec, + labels: Vec<&'a Ident>, +} + +impl CycleSearch<'_, '_> { + fn visit(&mut self, spec: usize) -> Option { + if self.state[spec] != VisitState::Unvisited { + return None; + } + + self.state[spec] = VisitState::InProgress; + self.path.push(spec); + + for (target, reference) in self.edges[spec].iter() { + if self.state[*target] == VisitState::InProgress { + return Some(self.cycle_error(*target, reference)); + } + + self.labels.push(reference); + + if let Some(error) = self.visit(*target) { + return Some(error); + } + + self.labels.pop(); + } + + self.path.pop(); + self.state[spec] = VisitState::Done; + + None + } + + /// The rejection for a back edge into `target`, labelled `closing`. + /// + /// The cycle runs from wherever `target` sits on the current path back around + /// to it, so its hops are the labels from that point on plus the closing + /// reference — which for a self-reference is the single `A` -> `A` hop. + fn cycle_error(&self, target: usize, closing: &Ident) -> syn::Error { + let entry = self + .path + .iter() + .position(|spec| *spec == target) + .unwrap_or(0); + + let mut hops: Vec = self.labels[entry..] + .iter() + .map(|ident| format!("`{ident}`")) + .collect(); + + hops.push(format!("`{closing}`")); + // Close the loop by restating where it started. + hops.push(hops[0].clone()); + + syn::Error::new_spanned( + closing, + format!( + "cannot ground `#[use_type]` imports: they resolve through one another \ + in a cycle {}. An `in Context` clause or a trait argument may name another \ + import's alias only if the resulting chain is acyclic, since a cycle has \ + no valid grounding order.", + hops.join(" -> "), + ), + ) + } +} + fn find_type_equalities( current_spec: &UseTypeAttribute, specs: &[UseTypeAttribute], diff --git a/crates/macros/cgp-macro-core/src/visitors/collect_bare_aliases.rs b/crates/macros/cgp-macro-core/src/visitors/collect_bare_aliases.rs new file mode 100644 index 00000000..fc876993 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/visitors/collect_bare_aliases.rs @@ -0,0 +1,42 @@ +use syn::visit::Visit; +use syn::{Ident, Type, visit}; + +use crate::visitors::bare_alias_ident; + +/// Collect every **bare alias reference** a type contains, in source order. +/// +/// This is the read-only counterpart of +/// [`SubstituteAbstractTypes`](crate::visitors::SubstituteAbstractTypes): where +/// that visitor *rewrites* each reference, this one only reports where they are, +/// which is what the `#[use_type]` grounding-cycle check needs to read one spec's +/// dependencies on another. Both decide what counts as a reference through the +/// shared [`bare_alias_ident`], so a position the substitution would rewrite is +/// always a position the cycle check can see. +/// +/// The collected identifiers borrow from the traversed type, so each one carries +/// the span of the token the user wrote — which is what lets a rejected cycle put +/// its caret on the offending alias rather than on the whole macro block. +#[derive(Default)] +pub struct CollectBareAliases<'ast> { + pub idents: Vec<&'ast Ident>, +} + +impl<'ast> Visit<'ast> for CollectBareAliases<'ast> { + fn visit_type(&mut self, ty: &'ast Type) { + if let Some(ident) = bare_alias_ident(ty) { + self.idents.push(ident); + // A bare alias is a single argument-free segment, so it has no nested + // types to descend into. + return; + } + + visit::visit_type(self, ty); + } +} + +/// Every bare alias reference in `ty`, in source order. +pub fn collect_bare_aliases(ty: &Type) -> Vec<&Ident> { + let mut visitor = CollectBareAliases::default(); + visitor.visit_type(ty); + visitor.idents +} diff --git a/crates/macros/cgp-macro-core/src/visitors/mod.rs b/crates/macros/cgp-macro-core/src/visitors/mod.rs index 04f460a7..5ab2ad6e 100644 --- a/crates/macros/cgp-macro-core/src/visitors/mod.rs +++ b/crates/macros/cgp-macro-core/src/visitors/mod.rs @@ -1,9 +1,11 @@ +mod collect_bare_aliases; mod remove_self_path; mod replace_provider; mod replace_self; mod self_assoc_type; mod substitute_abstract_type; +pub use collect_bare_aliases::*; pub use remove_self_path::*; pub use replace_provider::*; pub use replace_self::*; diff --git a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs index 2e6de4ac..b01ce038 100644 --- a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs +++ b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs @@ -1,9 +1,41 @@ use syn::punctuated::Punctuated; use syn::visit_mut::VisitMut; -use syn::{ExprPath, PathArguments, PathSegment, Token, Type, TypePath, parse_quote, visit_mut}; +use syn::{ + ExprPath, Ident, PathArguments, PathSegment, Token, Type, TypePath, parse_quote, visit_mut, +}; use crate::types::attributes::UseTypeAttribute; +/// The identifier of a **bare alias reference** — an unqualified, single-segment, +/// argument-free type path such as `Scalar` — or `None` for any other type. +/// +/// This is the single rule that decides whether a type position *names* an +/// imported alias, and it is deliberately shared by everything that needs the +/// answer: the substitution below, which rewrites such a reference, and the +/// grounding-cycle check, which follows one as a dependency edge. Duplicating the +/// guard would let the two disagree about what counts as a reference, and a +/// reference the cycle check cannot see is exactly the input that grounding then +/// fails to resolve. +/// +/// The strictness is what keeps the rewrite from claiming syntax nobody wrote: a +/// path that already carries a qualifier, generic arguments, or more than one +/// segment is not a bare alias, so a genuine `Self::Error` or a `Foo` head +/// is left alone. +pub fn bare_alias_ident(ty: &Type) -> Option<&Ident> { + if let Type::Path(TypePath { qself: None, path }) = ty + && path.leading_colon.is_none() + && path.segments.len() == 1 + { + let segment = &path.segments[0]; + + if matches!(segment.arguments, PathArguments::None) { + return Some(&segment.ident); + } + } + + None +} + /// A single-pass `VisitMut` that rewrites every bare, single-segment, /// argument-free type path matching an imported alias into its fully-qualified /// `::AssocType` form. @@ -39,23 +71,23 @@ impl<'a> SubstituteAbstractTypes<'a> { impl VisitMut for SubstituteAbstractTypes<'_> { fn visit_type_mut(&mut self, ty: &mut Type) { - if let Type::Path(TypePath { qself: None, path }) = ty - && path.leading_colon.is_none() - && path.segments.len() == 1 - { - let segment = &path.segments[0]; - if matches!(segment.arguments, PathArguments::None) { - for spec in self.specs { - if let Some(replacement_ident) = spec.replace_ident(&segment.ident) { - let trait_path = &spec.trait_path; - let context_type = &spec.context_type; - *ty = parse_quote! { <#context_type as #trait_path>::#replacement_ident }; - self.is_changed = true; - return; - } - } + if let Some(ident) = bare_alias_ident(ty) { + // Resolve against the specs before mutating, so the replacement is not + // computed while the identifier it replaces is still borrowed. + let replacement = self.specs.iter().find_map(|spec| { + let replacement_ident = spec.replace_ident(ident)?; + let trait_path = &spec.trait_path; + let context_type = &spec.context_type; + Some(parse_quote! { <#context_type as #trait_path>::#replacement_ident }) + }); + + if let Some(replacement) = replacement { + *ty = replacement; + self.is_changed = true; + return; } } + visit_mut::visit_type_mut(self, ty); } diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/use_type.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_type.rs index 68e48a84..afc413cf 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/use_type.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_type.rs @@ -1,11 +1,12 @@ //! `#[use_type]` rejects imports it cannot lower unambiguously: a type-equality -//! constraint on a `#[cgp_component]` trait, and two imports that resolve to the -//! same identifier or alias — whether across specs or within one braced list, and -//! on any host macro. +//! constraint on a `#[cgp_component]` trait, two imports that resolve to the same +//! identifier or alias — whether across specs or within one braced list, and on any +//! host macro — and an import list whose contexts resolve through one another in a +//! cycle, which has no valid grounding order. //! -//! See cgp-knowledge-base/cgp/implementation/asts/attributes/README.md (Tests) for these -//! failure cases and cgp-knowledge-base/cgp/reference/attributes/use_type.md for the -//! user-facing semantics. +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md (Tests) for +//! these failure cases and cgp-knowledge-base/cgp/reference/attributes/use_type.md for +//! the user-facing semantics. use quote::quote; @@ -62,6 +63,92 @@ fn rejects_duplicate_alias_on_component() { }); } +#[test] +fn rejects_self_referential_context() { + // An import whose `in Context` names its own alias asks for the context to be + // grounded before itself — a one-node cycle. + assert_macro_rejects("use_type import whose context is its own alias", || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_type(HasAType.A in A)] + pub fn get_a(&self) -> A { + todo!() + } + ), + ) + }); +} + +#[test] +fn rejects_context_cycle_between_specs() { + // Two imports each naming the other's alias as its context: every grounding + // order needs one of the two resolved first, so neither can be. + assert_macro_rejects("use_type contexts forming a two-spec cycle", || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_type(HasAType.A in B, HasBType.B in A)] + pub fn deep(&self) -> A { + todo!() + } + ), + ) + }); +} + +#[test] +fn rejects_context_cycle_across_three_specs() { + // The cycle is found however long it is, so a three-hop loop is rejected the + // same way — a search that only compared adjacent pairs would miss this. + assert_macro_rejects("use_type contexts forming a three-spec cycle", || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_type(HasAType.A in C, HasBType.B in A, HasCType.C in B)] + pub fn deep(&self) -> A { + todo!() + } + ), + ) + }); +} + +#[test] +fn rejects_cycle_through_a_trait_argument() { + // Grounding resolves an alias in a trait path's generic arguments as well as in + // an `in Context` clause, so a cycle running through that position is a cycle + // too: `HasPoolType` needs `Pool`, which is projected against it. + assert_macro_rejects("use_type cycle through a trait argument", || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_type(HasPoolType.Pool)] + pub fn get_pool(&self) -> Pool { + todo!() + } + ), + ) + }); +} + +#[test] +fn rejects_cycle_on_component() { + // The cycle check runs on a component trait definition too, not only on impls + // and functions, since grounding is the same three-step transform there. + assert_macro_rejects("use_type context cycle on a component trait", || { + cgp_macro_lib::cgp_component( + quote!(FooProvider), + quote!( + #[use_type(HasAType.A in B, HasBType.B in A)] + pub trait CanFoo { + fn foo(&self) -> A; + } + ), + ) + }); +} + #[test] fn rejects_duplicate_alias_within_one_braced_list() { // Two entries of one braced list aliasing to the same name are also a diff --git a/crates/tests/cgp-tests/tests/abstract_types/mod.rs b/crates/tests/cgp-tests/tests/abstract_types/mod.rs index 7a1ad7b1..fb33c5ec 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/mod.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/mod.rs @@ -19,12 +19,14 @@ pub mod use_type_component; pub mod use_type_foreign; pub mod use_type_generic_param; pub mod use_type_path_qualified; +pub mod use_type_trait_arg_component; // The `#[use_type]` attribute rewriting abstract types inside `#[cgp_fn]`: the -// bare alias, alias renaming, type-equality bounds, and foreign/nested type -// sources (including a two-hop foreign chain). These keep the `#[cgp_fn]` snapshot -// because the abstract-type rewrite is the point (the `#[cgp_fn]` expansion itself -// is owned by `implicit_arguments`). +// bare alias, alias renaming, type-equality bounds, foreign/nested type sources +// (including a two-hop foreign chain), and the grounding of an alias that appears +// in the imported trait's own generic arguments. These keep the `#[cgp_fn]` +// snapshot because the abstract-type rewrite is the point (the `#[cgp_fn]` +// expansion itself is owned by `implicit_arguments`). pub mod use_type_fn_alias; pub mod use_type_fn_deep_foreign; pub mod use_type_fn_equality; @@ -35,7 +37,10 @@ pub mod use_type_fn_extend; pub mod use_type_fn_foreign; pub mod use_type_fn_foreign_equality; pub mod use_type_fn_foreign_equality_cross_trait; +pub mod use_type_fn_generic_trait_equality; pub mod use_type_fn_nested_foreign; pub mod use_type_fn_reverse_order; +pub mod use_type_fn_shared_context; +pub mod use_type_fn_trait_arg_alias; pub mod use_type_foreign_getter; pub mod use_type_uses_supertrait; diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_generic_trait_equality.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_generic_trait_equality.rs new file mode 100644 index 00000000..c34a0955 --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_generic_trait_equality.rs @@ -0,0 +1,135 @@ +//! `#[use_type]` type-equality on a trait path that already carries generic +//! arguments: `#[use_type(HasFooType.{Foo = u32})]`. +//! +//! A pin becomes an associated-type binding *inside* the trait path's own argument +//! list, appended after the arguments the path already carries, so the emitted +//! bound reads `Self: HasFooType`. It cannot be a second +//! angle-bracketed group after the path, because `HasFooType` is not +//! a trait bound at all — an arrangement that used to fail inside the macro with a +//! bare "failed to parse internal tokens" error naming no cause. +//! +//! The second case pins that the merge is per-spec and cumulative: two pins on one +//! generic trait land in the same argument list, while a plain generic import +//! beside them keeps its bare `Self: Trait` bound. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md and +//! cgp-knowledge-base/cgp/reference/attributes/use_type.md. + +use cgp_macro_test_util::snapshot_cgp_fn; + +pub trait HasFooType { + type Foo; +} + +pub trait HasPairType { + type Left; + + type Right; +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasFooType.{Foo = u32})] + pub fn get_foo(&self) -> Foo { + 7 + } + + expand_get_foo(output) { + insta::assert_snapshot!(output, @" + pub trait GetFoo: HasFooType { + fn get_foo(&self) -> >::Foo; + } + impl<__Context__> GetFoo for __Context__ + where + Self: HasFooType, + { + fn get_foo(&self) -> >::Foo { + 7 + } + } + ") + } +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasPairType.{Left = u32, Right = u64}, HasFooType.Foo)] + pub fn get_pair(&self) -> (Left, Right, Foo) { + todo!() + } + + expand_get_pair(output) { + insta::assert_snapshot!(output, @" + pub trait GetPair: HasPairType + HasFooType { + fn get_pair( + &self, + ) -> ( + >::Left, + >::Right, + >::Foo, + ); + } + impl<__Context__> GetPair for __Context__ + where + Self: HasPairType, + Self: HasFooType, + { + fn get_pair( + &self, + ) -> ( + >::Left, + >::Right, + >::Foo, + ) { + todo!() + } + } + ") + } +} + +pub trait HasRefType<'a> { + type Ref; +} + +// A pin merged after a *lifetime* argument, which is the ordering-sensitive case: +// Rust requires lifetimes to lead an argument list and associated-type bindings to +// trail it, so appending the binding to the existing arguments has to land on the +// far side of the lifetime. +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasRefType<'a>.{Ref = &'a str})] + pub fn get_ref<'a>(&self) -> Ref { + todo!() + } + + expand_get_ref(output) { + insta::assert_snapshot!(output, @" + pub trait GetRef<'a>: HasRefType<'a> { + fn get_ref(&self) -> >::Ref; + } + impl<'a, __Context__> GetRef<'a> for __Context__ + where + Self: HasRefType<'a, Ref = &'a str>, + { + fn get_ref(&self) -> >::Ref { + todo!() + } + } + ") + } +} + +pub struct App; + +impl HasFooType for App { + type Foo = u32; +} + +#[test] +fn test_pinned_generic_trait_import() { + // The pin makes the abstract `Foo` concrete, so the value flows out as a `u32` + // through a bound the macro could not previously even emit. + let foo: u32 = App.get_foo(); + assert_eq!(foo, 7); +} diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs index 0b72b656..29b40036 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs @@ -8,8 +8,9 @@ //! specs at once, so a spec may name a context imported by any other spec no matter //! where it sits in the list; the bare `C` still rewrites to the three-hop //! `<<::A as HasB>::B as HasC>::C`. Only a genuine *cycle* — which has -//! no valid order at all — fails to ground; that acceptable failure is pinned in -//! the `use_type_cyclic_context` compile-fail fixture. +//! no valid order at all — cannot be grounded, and that is rejected at macro time +//! rather than lowered; the rejections are pinned in `cgp-macro-tests`' +//! `parser_rejections::use_type`. //! //! `deep` takes and returns a value of the deep type, so the test asserts a //! concrete value flows through the fully-grounded signature at runtime. diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_shared_context.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_shared_context.rs new file mode 100644 index 00000000..44b37a00 --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_shared_context.rs @@ -0,0 +1,66 @@ +//! `#[use_type]` where two imports name the *same* alias as their context — +//! `#[use_type(HasA.A, HasB.B in A, HasC.C in A)]` — a diamond in the grounding +//! graph rather than a chain. +//! +//! Grounding resolves each spec against every other, so a shared context grounds +//! once and both dependents pick it up: `B` and `C` are each projected against +//! `::A`. The case is worth pinning separately from the linear chain +//! because it is the shape a *cycle* check must not mistake for a cycle — two edges +//! into one node revisit that node without ever returning to a node still on the +//! search path, so the graph is acyclic and must be accepted. +//! +//! The chain cases are pinned by `use_type_fn_deep_foreign` (front-to-back) and +//! `use_type_fn_reverse_order` (back-to-front); the arrangements with no valid +//! grounding order at all are rejected at macro time and pinned in +//! `cgp-macro-tests`' `parser_rejections::use_type`. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md and +//! cgp-knowledge-base/cgp/reference/attributes/use_type.md. + +use cgp::prelude::*; + +#[cgp_type] +pub trait HasA { + type A; +} + +#[cgp_type] +pub trait HasB { + type B; +} + +#[cgp_type] +pub trait HasC { + type C; +} + +// Both `B` and `C` are imported from the same foreign context `A`, so grounding +// resolves `A` once and both specs project against the result. +#[cgp_fn] +#[use_type(HasA.A, HasB.B in A, HasC.C in A)] +pub fn combine(&self, left: B, right: C) -> (B, C) { + (left, right) +} + +pub struct Aa; + +impl HasB for Aa { + type B = u32; +} + +impl HasC for Aa { + type C = u64; +} + +pub struct App; + +impl HasA for App { + type A = Aa; +} + +#[test] +fn test_shared_context_grounds() { + // `B` grounds to `<::A as HasB>::B` (a `u32`) and `C` to the `u64` + // its sibling names, so both values flow through the shared context. + assert_eq!(App.combine(1u32, 2u64), (1u32, 2u64)); +} diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs new file mode 100644 index 00000000..907daca4 --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs @@ -0,0 +1,143 @@ +//! `#[use_type]` grounding an imported alias that appears in a *trait path's own +//! generic arguments*: `#[use_type(HasDbType.Db, HasPoolType.Pool)]`. +//! +//! A trait argument ends up inside the emitted `>::Assoc` +//! path exactly as the context does, so an alias left bare in it names an +//! identifier that resolves to nothing. Grounding therefore resolves both +//! positions: `HasPoolType` becomes `HasPoolType<::Db>`, in +//! the rewritten signature and in the appended supertrait alike. +//! +//! This is the same rule that already grounds an `in Context` clause and a pin's +//! right-hand side, so the trait-argument position was an inconsistency rather than +//! a boundary — the alias resolved in two of the three positions it can occupy. +//! +//! The second case pins that grounding a trait argument composes with pinning the +//! type imported from it, since the merged argument list has to carry both the +//! grounded argument and the binding. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md and +//! cgp-knowledge-base/cgp/reference/attributes/use_type.md. + +use cgp_macro_test_util::snapshot_cgp_fn; + +pub trait HasDbType { + type Db; +} + +pub trait HasPoolType { + type Pool; +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasPoolType.Pool)] + pub fn get_pool(&self) -> Pool { + todo!() + } + + expand_get_pool(output) { + insta::assert_snapshot!(output, @" + pub trait GetPool: HasDbType + HasPoolType<::Db> { + fn get_pool(&self) -> ::Db>>::Pool; + } + impl<__Context__> GetPool for __Context__ + where + Self: HasDbType, + Self: HasPoolType<::Db>, + { + fn get_pool(&self) -> ::Db>>::Pool { + todo!() + } + } + ") + } +} + +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasPoolType.{Pool = u32})] + pub fn get_pinned_pool(&self) -> Pool { + 11 + } + + expand_get_pinned_pool(output) { + insta::assert_snapshot!(output, @" + pub trait GetPinnedPool: HasDbType + HasPoolType<::Db> { + fn get_pinned_pool(&self) -> ::Db>>::Pool; + } + impl<__Context__> GetPinnedPool for __Context__ + where + Self: HasDbType, + Self: HasPoolType<::Db, Pool = u32>, + { + fn get_pinned_pool(&self) -> ::Db>>::Pool { + 11 + } + } + ") + } +} + +pub trait HasHandleType { + type Handle; +} + +// A three-hop chain threaded entirely through trait arguments rather than through +// `in Context` clauses, so each hop's argument must be grounded before the next can +// project against it. This is the trait-argument analogue of the context chain in +// `use_type_fn_deep_foreign`, and it exercises the same grounding fixpoint over the +// new position. +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasDbType.Db, HasPoolType.Pool, HasHandleType.Handle)] + pub fn get_handle(&self) -> Handle { + todo!() + } + + expand_get_handle(output) { + insta::assert_snapshot!(output, @" + pub trait GetHandle: HasDbType + HasPoolType< + ::Db, + > + HasHandleType<::Db>>::Pool> { + fn get_handle( + &self, + ) -> ::Db>>::Pool, + >>::Handle; + } + impl<__Context__> GetHandle for __Context__ + where + Self: HasDbType, + Self: HasPoolType<::Db>, + Self: HasHandleType<::Db>>::Pool>, + { + fn get_handle( + &self, + ) -> ::Db>>::Pool, + >>::Handle { + todo!() + } + } + ") + } +} + +pub struct Postgres; + +pub struct App; + +impl HasDbType for App { + type Db = Postgres; +} + +impl HasPoolType for App { + type Pool = u32; +} + +#[test] +fn test_grounded_trait_argument() { + // `HasPoolType<::Db>` resolves to `HasPoolType`, so + // both functions type-check against the one impl and return its `u32` pool. + assert_eq!(App.get_pinned_pool(), 11); +} diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_trait_arg_component.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_trait_arg_component.rs new file mode 100644 index 00000000..e4492cde --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_trait_arg_component.rs @@ -0,0 +1,71 @@ +//! A grounded trait argument across the idiomatic host pair: a `#[cgp_component]` +//! and a `#[cgp_impl]` provider that both import through +//! `#[use_type(HasDbType.Db, HasPoolType.Pool)]`, wired onto a real context. +//! +//! The two hosts consume the grounded spec through different paths — a component +//! turns a `Self` import into a *supertrait*, while an impl turns it into a `where` +//! predicate that also carries any pin — so this exercises both halves of the +//! transform on one shared abstract type. The provider additionally pins the +//! imported type (`{Pool = u32}`), which the impl-side path merges into the already +//! grounded trait argument as `HasPoolType<::Db, Pool = u32>`. +//! +//! The `#[cgp_fn]` counterparts, which pin the emitted tokens as snapshots, are +//! `use_type_fn_trait_arg_alias` and `use_type_fn_generic_trait_equality`; this file +//! pins that the same grounding survives component wiring and dispatches at run +//! time. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md and +//! cgp-knowledge-base/cgp/reference/attributes/use_type.md. + +use cgp::prelude::*; + +#[cgp_type] +pub trait HasDbType { + type Db; +} + +#[cgp_type] +pub trait HasPoolType { + type Pool; +} + +#[cgp_component(PoolSizeReader)] +#[use_type(HasDbType.Db, HasPoolType.Pool)] +pub trait CanReadPoolSize { + fn read_pool_size(&self) -> Pool; +} + +// The provider pins the abstract pool type to `u32` while still projecting it +// against the grounded `HasPoolType<::Db>`. +#[cgp_impl(new ReadPoolSize)] +#[use_type(HasDbType.Db, HasPoolType.{Pool = u32})] +impl PoolSizeReader { + fn read_pool_size(&self) -> Pool { + 42 + } +} + +pub struct Postgres; + +pub struct App; + +delegate_components! { + App { + DbTypeProviderComponent: UseType, + PoolTypeProviderComponent: UseType, + PoolSizeReaderComponent: ReadPoolSize, + } +} + +check_components! { + App { + PoolSizeReaderComponent, + } +} + +#[test] +fn test_grounded_trait_argument_wiring() { + // The context binds `Db` to `Postgres` and `Pool` to `u32`, which is what makes + // the provider's grounded-and-pinned bound hold. + assert_eq!(App.read_pool_size(), 42); +} From 9c512433b61720d1b15618676a6488fbba9428b5 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Wed, 29 Jul 2026 21:22:55 +0200 Subject: [PATCH 05/10] Ground `#[use_type]` imports in dependency order, not by fixpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounding used one traversal to detect cycles and a second, a fixpoint, to resolve. The fixpoint substituted each pass against a *snapshot* of the previous pass, in which a dependency was still ungrounded — which is the mechanism that built the spiral the cycle check exists to prevent. Rejecting cycles upstream made that unreachable, but the robustness then came from a precondition in a neighbouring function rather than from the code itself. Both collapse into `ground_specs`, because they answer one question: grounding a spec means resolving its dependencies first, so the order it needs is a topological order, and a cycle is exactly the graph having no such order. One depth-first walk grounds each spec in post-order and reports a back edge as the cycle. Two properties are now structural instead of resting on an argument about iteration counts: the walk visits each spec once, so it terminates; and a spec is substituted only against fully-grounded specs, so a replacement can never carry a bare alias. The shape is gone rather than guarded — there is no previous pass to read a half-resolved spec out of. Resolution keeps source order in its output, so the emitted supertraits and predicates still read in the order the author wrote the imports. `resolve` tests `!= Unvisited` rather than `== Done`. An in-progress spec cannot arrive today, since the call site reports the cycle before recursing, but descending on one would recurse forever; a future call site that skipped the guard now fails as an ungrounded spec at the end of the walk — diagnosable — instead of as a stack overflow inside the compiler. `PathWithTypeArgs::to_bound_tokens` takes over merging associated-type bindings into a trait path's own argument list. The caller previously had to know that `ToTokens` renders those arguments as a *trailing* group, and so that appending bindings after the whole path yields the invalid `Trait`. Keeping the merge with the rendering it must agree with stops a caller from reintroducing that, and collapses the pinned and unpinned cases into one path, since an empty binding list renders the path unchanged. `is_changed` is removed from the substitution visitor: it existed only to drive the fixpoint and was left written but never read, which clippy misses on a `pub` field. Tests cover what the refactor claims. Order-independence is pinned for a nested pin whose right-hand side names an alias declared *after* it, and for specs split across stacked `#[use_type]` attributes in both orders — the latter a documented promise with nothing behind it, exercised on the case where it could plausibly break (`{Foo = Vec}` above `HasBarType.Bar`). All four arrangements emit the same bound. `to_bound_tokens` gains unit tests in the file that already covers its type, including that its output parses as a trait bound at all, which is the property the two-group form violated and which no unit test pinned. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017xGdHGrJEtoZjSmFEhsChw --- .../types/attributes/use_type/attributes.rs | 63 +----- .../types/attributes/use_type/grounding.rs | 190 ++++++++++++++++ .../src/types/attributes/use_type/mod.rs | 1 + .../attributes/use_type/type_predicates.rs | 207 ++---------------- .../src/types/ident/path_with_type_args.rs | 38 +++- .../src/visitors/substitute_abstract_type.rs | 31 +-- .../path_with_type_args.rs | 67 +++++- .../cgp-tests/tests/abstract_types/mod.rs | 11 +- .../use_type_fn_equality_nested.rs | 29 +++ .../use_type_fn_reverse_order.rs | 7 +- .../use_type_fn_stacked_attributes.rs | 103 +++++++++ .../use_type_fn_trait_arg_alias.rs | 4 +- 12 files changed, 479 insertions(+), 272 deletions(-) create mode 100644 crates/macros/cgp-macro-core/src/types/attributes/use_type/grounding.rs create mode 100644 crates/tests/cgp-tests/tests/abstract_types/use_type_fn_stacked_attributes.rs diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs index f22f1e3d..2b9b6416 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/attributes.rs @@ -4,8 +4,9 @@ use syn::{ItemImpl, ItemTrait, Type}; use crate::functions::parse_internal; use crate::types::attributes::UseTypeAttribute; +use crate::types::attributes::use_type::grounding::ground_specs; use crate::types::attributes::use_type::type_predicates::{ - derive_use_type_predicates, forbid_duplicate_aliases, forbid_grounding_cycles, + derive_use_type_predicates, forbid_duplicate_aliases, }; use crate::visitors::SubstituteAbstractTypes; @@ -15,50 +16,16 @@ pub struct UseTypeAttributes { } impl UseTypeAttributes { - /// Resolve every spec's groundable positions into fully-qualified form before - /// they are used, so that the body substitution and the appended bounds agree - /// on one grounded context and one grounded trait path. + /// Validate the import list and resolve every spec into fully-qualified form, + /// the two steps that must precede any substitution. /// - /// Grounding reaches both positions named by - /// [`UseTypeAttribute::groundable_types`], because an alias left bare in - /// either would end up inside an emitted `>::Assoc` - /// path, resolving to nothing. An `in Context` suffix whose `Context` is - /// itself imported by another spec — as in - /// `#[use_type(HasTypes.Types, HasScalarType.Scalar in Types)]` — is rewritten - /// from the bare alias `Types` to `::Types`, and a trait - /// argument that names an alias is grounded the same way, so - /// `#[use_type(HasDbType.Db, HasPoolType.Pool)]` projects against - /// `HasPoolType<::Db>`. A position naming a real generic - /// parameter or `Self` is left untouched. - /// - /// The pass iterates to a fixpoint so a chain of links resolves fully. Each - /// pass grounds one more level of the dependency chain, so `attributes.len()` - /// passes cover any acyclic graph over that many specs — which is every graph - /// that reaches here, since `forbid_grounding_cycles` has already rejected the - /// cyclic ones. - fn grounded_specs(&self) -> Vec { - let mut grounded = self.attributes.clone(); - - for _ in 0..grounded.len() { - let snapshot = grounded.clone(); - let mut changed = false; - - for spec in grounded.iter_mut() { - let mut visitor = SubstituteAbstractTypes::new(&snapshot); - - for ty in spec.groundable_types_mut() { - visitor.visit_type_mut(ty); - } - - changed |= visitor.is_changed; - } - - if !changed { - break; - } - } + /// Aliases are checked for uniqueness first, because [`ground_specs`] resolves a + /// reference through the spec that owns the alias and so needs one owner per + /// name; it then grounds each spec against its dependencies and rejects a cycle. + fn resolved_specs(&self) -> syn::Result> { + forbid_duplicate_aliases(&self.attributes)?; - grounded + ground_specs(&self.attributes) } pub fn transform_item_trait(&self, item_trait: &mut ItemTrait) -> syn::Result<()> { @@ -66,10 +33,7 @@ impl UseTypeAttributes { return Ok(()); } - forbid_duplicate_aliases(&self.attributes)?; - forbid_grounding_cycles(&self.attributes)?; - - let grounded = self.grounded_specs(); + let grounded = self.resolved_specs()?; SubstituteAbstractTypes::new(&grounded).visit_item_trait_mut(item_trait); @@ -112,10 +76,7 @@ impl UseTypeAttributes { return Ok(()); } - forbid_duplicate_aliases(&self.attributes)?; - forbid_grounding_cycles(&self.attributes)?; - - let grounded = self.grounded_specs(); + let grounded = self.resolved_specs()?; SubstituteAbstractTypes::new(&grounded).visit_item_impl_mut(item_impl); diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/grounding.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/grounding.rs new file mode 100644 index 00000000..24666701 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/grounding.rs @@ -0,0 +1,190 @@ +use std::collections::BTreeMap; + +use syn::Ident; +use syn::visit_mut::VisitMut; + +use crate::types::attributes::UseTypeAttribute; +use crate::visitors::{SubstituteAbstractTypes, collect_bare_aliases}; + +/// Resolve every `#[use_type]` spec's groundable positions into fully-qualified +/// form, rejecting an import list whose positions resolve through one another in a +/// cycle. +/// +/// Grounding reaches the positions named by +/// [`UseTypeAttribute::groundable_types`] — the context and the trait path's own +/// generic arguments — because an alias left bare in either ends up inside an +/// emitted `>::Assoc` path, resolving to nothing. So +/// `HasTypes.Types, HasScalarType.Scalar in Types` rewrites the second context to +/// `::Types`, and `HasDbType.Db, HasPoolType.Pool` projects +/// against `HasPoolType<::Db>`. A position naming a real generic +/// parameter or `Self` matches no alias and is left untouched. +/// +/// **Each spec is grounded against only the specs that are already grounded**, in +/// dependency order, which is what makes one pass per spec sufficient and makes a +/// half-resolved substitution impossible. A single depth-first walk of the +/// dependency graph gives that order and detects a cycle in the same traversal: a +/// spec is grounded once its dependencies return, and an edge back into a spec +/// still on the walk is a cycle. Both properties are therefore structural rather +/// than resting on an earlier validation pass. +/// +/// Aliases must already be unique (see `forbid_duplicate_aliases`), since the graph +/// assumes one owning spec per alias. +/// +/// The returned specs keep their **source order**, so the supertraits and `where` +/// predicates derived from them read in the order the author wrote the imports. +pub fn ground_specs(specs: &[UseTypeAttribute]) -> syn::Result> { + // Which spec imports each alias, so a reference can be resolved to the spec it + // depends on. + let mut owner_of_alias: BTreeMap = BTreeMap::new(); + + for (index, spec) in specs.iter().enumerate() { + for type_ident in spec.type_idents.iter() { + owner_of_alias.insert(type_ident.alias_ident().to_string(), index); + } + } + + // The dependency edges out of each spec: for every alias its groundable + // positions reference, the spec owning that alias, together with the referencing + // token so a rejected cycle can point at what the user wrote. + // + // Held outside `Grounding` so that indexing it inside the walk borrows the slice + // rather than `self`, leaving `self` free to mutate as the walk descends. + let edges: Vec> = specs + .iter() + .map(|spec| { + spec.groundable_types() + .flat_map(collect_bare_aliases) + .filter_map(|ident| Some((*owner_of_alias.get(&ident.to_string())?, ident))) + .collect() + }) + .collect(); + + let mut grounding = Grounding { + specs, + edges: &edges, + state: vec![VisitState::Unvisited; specs.len()], + grounded: vec![None; specs.len()], + labels: Vec::new(), + path: Vec::new(), + }; + + for index in 0..specs.len() { + grounding.resolve(index)?; + } + + Ok(grounding + .grounded + .into_iter() + .map(|spec| spec.expect("every spec is grounded once the walk has visited all of them")) + .collect()) +} + +#[derive(Clone, Copy, PartialEq)] +enum VisitState { + Unvisited, + /// On the current walk, so an edge back into it closes a cycle. + InProgress, + /// Grounded, along with everything it depends on. + Done, +} + +/// The depth-first walk that grounds each spec after its dependencies. +/// +/// `path` holds the specs on the current walk and `labels` the referencing tokens +/// that led between them, so a cycle can be reported in the alias names the author +/// wrote rather than as spec indices. +struct Grounding<'a, 'edges> { + specs: &'a [UseTypeAttribute], + edges: &'edges [Vec<(usize, &'a Ident)>], + state: Vec, + grounded: Vec>, + labels: Vec<&'a Ident>, + path: Vec, +} + +impl Grounding<'_, '_> { + fn resolve(&mut self, index: usize) -> syn::Result<()> { + // `Done` is the ordinary case: the spec and its dependencies are grounded, so + // there is nothing left to do. `InProgress` cannot arrive here, because the + // guard at the call site below reports a cycle before recursing — but + // descending on it anyway would recurse forever, so the test is written to + // stop on any state but `Unvisited`. A call site that ever skipped the guard + // then fails as an ungrounded spec at the end of the walk, which is + // diagnosable, rather than as a stack overflow inside the compiler. + if self.state[index] != VisitState::Unvisited { + return Ok(()); + } + + self.state[index] = VisitState::InProgress; + self.path.push(index); + + for (target, reference) in self.edges[index].iter() { + if self.state[*target] == VisitState::InProgress { + return Err(self.cycle_error(*target, reference)); + } + + self.labels.push(reference); + self.resolve(*target)?; + self.labels.pop(); + } + + // Every dependency has returned, so all of them are grounded and this spec + // can be resolved against them. + self.ground(index); + + self.path.pop(); + self.state[index] = VisitState::Done; + + Ok(()) + } + + /// Substitute one spec's groundable positions against the already-grounded + /// specs, and record the result. + fn ground(&mut self, index: usize) { + // Only grounded specs take part, so a replacement can never carry a bare + // alias of its own — which is what lets one pass per spec suffice. + let resolved: Vec = self.grounded.iter().flatten().cloned().collect(); + + let mut spec = self.specs[index].clone(); + let mut visitor = SubstituteAbstractTypes::new(&resolved); + + for ty in spec.groundable_types_mut() { + visitor.visit_type_mut(ty); + } + + self.grounded[index] = Some(spec); + } + + /// The rejection for an edge back into `target`, labelled `closing`. + /// + /// The cycle runs from wherever `target` sits on the current walk back around to + /// it, so its hops are the labels from that point on plus the closing reference — + /// which for a self-reference is the single `A` -> `A` hop. + fn cycle_error(&self, target: usize, closing: &Ident) -> syn::Error { + let entry = self + .path + .iter() + .position(|index| *index == target) + .unwrap_or(0); + + let mut hops: Vec = self.labels[entry..] + .iter() + .map(|ident| format!("`{ident}`")) + .collect(); + + hops.push(format!("`{closing}`")); + // Close the loop by restating where it started. + hops.push(hops[0].clone()); + + syn::Error::new_spanned( + closing, + format!( + "cannot ground `#[use_type]` imports: they resolve through one another \ + in a cycle {}. An `in Context` clause or a trait argument may name another \ + import's alias only if the resulting chain is acyclic, since a cycle has \ + no valid grounding order.", + hops.join(" -> "), + ), + ) + } +} diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/mod.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/mod.rs index be818cb8..ca179fb5 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/mod.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/mod.rs @@ -1,5 +1,6 @@ mod attribute; mod attributes; +mod grounding; mod ident; mod type_predicates; diff --git a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs index 045ddc0f..27a41d0e 100644 --- a/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs +++ b/crates/macros/cgp-macro-core/src/types/attributes/use_type/type_predicates.rs @@ -1,61 +1,36 @@ -use std::collections::BTreeMap; - use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; -use syn::punctuated::Punctuated; -use syn::token::Comma; +use quote::quote; use syn::visit_mut::VisitMut; use syn::{Ident, Type, WherePredicate}; use crate::functions::parse_internal; use crate::types::attributes::{UseTypeAttribute, UseTypeIdent}; -use crate::visitors::{SubstituteAbstractTypes, collect_bare_aliases}; +use crate::visitors::SubstituteAbstractTypes; /// Derive the impl-side `where` predicates a set of `#[use_type]` specs /// contributes: one `Context: Trait` bound per spec, carrying any type-equality -/// (`= T`) pins as associated-type bindings. The specs' contexts must already be -/// grounded (see [`UseTypeAttributes::grounded_specs`]), so this reads -/// `use_type.context_type` directly rather than re-resolving aliases. +/// (`= T`) pins as associated-type bindings on the trait. The specs must already be +/// grounded (see [`ground_specs`](super::grounding::ground_specs)), so this reads +/// each `context_type` and `trait_path` directly rather than re-resolving aliases. pub fn derive_use_type_predicates(specs: &[UseTypeAttribute]) -> syn::Result> { let mut predicates = Vec::new(); for use_type in specs.iter() { - let type_equalities = find_type_equalities(use_type, specs); + // The pins become associated-type bindings on the trait, which + // `to_bound_tokens` merges into whatever generic arguments the trait path + // already carries. An empty binding list renders the path unchanged, so the + // pinned and unpinned cases are one path through this code. + let bindings: Vec = find_type_equalities(use_type, specs) + .into_iter() + .map(|(alias_ident, equal_target)| quote! { #alias_ident = #equal_target }) + .collect(); - let trait_path = &use_type.trait_path; + let trait_bound = use_type.trait_path.to_bound_tokens(&bindings); let context_type = &use_type.context_type; - if type_equalities.is_empty() { - predicates.push(parse_internal! { - #context_type: #trait_path - }); - } else { - // The pins become associated-type bindings *inside* the trait path's - // own argument list, appended after whatever generic arguments the - // path already carries. They cannot be a second `<…>` group after the - // path, because `HasFooType` is not a trait bound — - // a generic trait plus a pin has to render as - // `HasFooType`. - let mut arguments: Punctuated = Punctuated::new(); - - for type_arg in trait_path.type_args.args.iter() { - arguments.push(type_arg.to_token_stream()); - } - - for (alias_ident, equal_target) in type_equalities.into_iter() { - arguments.push(quote! { - #alias_ident = #equal_target - }); - } - - // Emit the path's head separately from the merged argument list, since - // `PathWithTypeArgs` renders its own arguments as a trailing group. - let trait_head = &trait_path.path; - - predicates.push(parse_internal! { - #context_type: #trait_head < #arguments > - }); - } + predicates.push(parse_internal! { + #context_type: #trait_bound + }); } Ok(predicates) @@ -85,154 +60,6 @@ pub fn forbid_duplicate_aliases(specs: &[UseTypeAttribute]) -> syn::Result<()> { Ok(()) } -/// Reject `#[use_type]` imports whose groundable positions resolve through one -/// another in a cycle, since such an arrangement has no valid grounding order. -/// -/// Grounding resolves each spec's context and trait arguments against every other -/// spec, iterating until nothing changes, which lets the imports be written in any -/// order — `HasC.C in B, HasB.B in A, HasA.A` grounds exactly like the -/// front-to-back spelling. A cycle is the one arrangement with no order at all: -/// `#[use_type(HasA.A in B, HasB.B in A)]` asks for each context to be resolved -/// before the other. This check rejects it at macro time with the caret on the -/// alias that closes the loop. -/// -/// Detecting it *before* grounding is what makes the check possible, because the -/// obvious after-the-fact test does not work. Grounding does not leave a cyclic -/// alias bare: each pass substitutes against the previous pass's snapshot, in -/// which the other context is still ungrounded, so every pass wraps one more layer -/// and the alias ends up buried at the innermost position of a deep projection -/// (`<<::B as HasA>::A as HasB>::B`). Asking whether a context is -/// "still a bare alias" afterwards therefore finds nothing. The spec graph, by -/// contrast, states the problem directly. -pub fn forbid_grounding_cycles(specs: &[UseTypeAttribute]) -> syn::Result<()> { - // Which spec imports each alias. Aliases are unique across all specs, which - // `forbid_duplicate_aliases` has already established, so one owner per name. - let mut owner_of_alias: BTreeMap = BTreeMap::new(); - - for (index, spec) in specs.iter().enumerate() { - for type_ident in spec.type_idents.iter() { - owner_of_alias.insert(type_ident.alias_ident().to_string(), index); - } - } - - // The dependency edges out of each spec: for every alias its groundable - // positions reference, the spec that owns that alias, together with the - // referencing token so a rejection can point at what the user wrote. - let edges: Vec> = specs - .iter() - .map(|spec| { - spec.groundable_types() - .flat_map(collect_bare_aliases) - .filter_map(|ident| { - let target = owner_of_alias.get(&ident.to_string())?; - Some((*target, ident)) - }) - .collect() - }) - .collect(); - - // A depth-first search over the spec graph, reporting the first back edge it - // finds. - let mut search = CycleSearch { - edges: &edges, - state: vec![VisitState::Unvisited; specs.len()], - path: Vec::new(), - labels: Vec::new(), - }; - - for start in 0..specs.len() { - if let Some(error) = search.visit(start) { - return Err(error); - } - } - - Ok(()) -} - -#[derive(Clone, Copy, PartialEq)] -enum VisitState { - Unvisited, - /// On the current search path, so an edge back to it closes a cycle. - InProgress, - /// Fully explored and known to reach no cycle. - Done, -} - -/// The depth-first search over the spec dependency graph. -/// -/// `path` holds the specs on the current search path and `labels` the referencing -/// tokens that led between them, so a back edge can be rendered as the alias names -/// the user actually wrote rather than as spec indices. -struct CycleSearch<'a, 'edges> { - edges: &'edges [Vec<(usize, &'a Ident)>], - state: Vec, - path: Vec, - labels: Vec<&'a Ident>, -} - -impl CycleSearch<'_, '_> { - fn visit(&mut self, spec: usize) -> Option { - if self.state[spec] != VisitState::Unvisited { - return None; - } - - self.state[spec] = VisitState::InProgress; - self.path.push(spec); - - for (target, reference) in self.edges[spec].iter() { - if self.state[*target] == VisitState::InProgress { - return Some(self.cycle_error(*target, reference)); - } - - self.labels.push(reference); - - if let Some(error) = self.visit(*target) { - return Some(error); - } - - self.labels.pop(); - } - - self.path.pop(); - self.state[spec] = VisitState::Done; - - None - } - - /// The rejection for a back edge into `target`, labelled `closing`. - /// - /// The cycle runs from wherever `target` sits on the current path back around - /// to it, so its hops are the labels from that point on plus the closing - /// reference — which for a self-reference is the single `A` -> `A` hop. - fn cycle_error(&self, target: usize, closing: &Ident) -> syn::Error { - let entry = self - .path - .iter() - .position(|spec| *spec == target) - .unwrap_or(0); - - let mut hops: Vec = self.labels[entry..] - .iter() - .map(|ident| format!("`{ident}`")) - .collect(); - - hops.push(format!("`{closing}`")); - // Close the loop by restating where it started. - hops.push(hops[0].clone()); - - syn::Error::new_spanned( - closing, - format!( - "cannot ground `#[use_type]` imports: they resolve through one another \ - in a cycle {}. An `in Context` clause or a trait argument may name another \ - import's alias only if the resulting chain is acyclic, since a cycle has \ - no valid grounding order.", - hops.join(" -> "), - ), - ) - } -} - fn find_type_equalities( current_spec: &UseTypeAttribute, specs: &[UseTypeAttribute], diff --git a/crates/macros/cgp-macro-core/src/types/ident/path_with_type_args.rs b/crates/macros/cgp-macro-core/src/types/ident/path_with_type_args.rs index d370ef1c..c95fa042 100644 --- a/crates/macros/cgp-macro-core/src/types/ident/path_with_type_args.rs +++ b/crates/macros/cgp-macro-core/src/types/ident/path_with_type_args.rs @@ -1,6 +1,8 @@ use proc_macro2::TokenStream; -use quote::ToTokens; +use quote::{ToTokens, quote}; use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::token::Comma; use syn::{Error, Ident, Path, PathArguments, Type, parse_quote, parse2}; use crate::traits::ToType; @@ -30,6 +32,40 @@ pub struct PathWithTypeArgs { } impl PathWithTypeArgs { + /// The path rendered with `bindings` — associated-type bindings such as + /// `Item = u8` — appended to its own generic arguments as **one** + /// angle-bracketed list, for use as a trait bound. + /// + /// A generic trait carrying an associated-type binding has exactly one valid + /// spelling, `Trait`; the path's arguments followed by a second + /// group, `Trait`, is not a trait bound in any position. Since + /// [`ToTokens`] emits this type's arguments as a *trailing* group, a caller that + /// appended its own bindings after the whole path would produce exactly that + /// invalid form — so the merge belongs here, with the rendering it has to agree + /// with, rather than at each call site. + /// + /// Bindings are passed as token streams because [`TypeArgs`] deliberately + /// rejects them (they are invalid in a plain type-argument position), so there is + /// no argument type able to carry one. + pub fn to_bound_tokens(&self, bindings: &[TokenStream]) -> TokenStream { + if bindings.is_empty() { + return self.to_token_stream(); + } + + let path = &self.path; + + let mut arguments: Punctuated = self + .type_args + .args + .iter() + .map(ToTokens::to_token_stream) + .collect(); + + arguments.extend(bindings.iter().cloned()); + + quote! { #path < #arguments > } + } + /// The identifier of the final path segment, e.g. `Foo` in /// `path::to::Foo`. pub fn ident(&self) -> &Ident { diff --git a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs index b01ce038..f9f913f4 100644 --- a/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs +++ b/crates/macros/cgp-macro-core/src/visitors/substitute_abstract_type.rs @@ -12,10 +12,10 @@ use crate::types::attributes::UseTypeAttribute; /// This is the single rule that decides whether a type position *names* an /// imported alias, and it is deliberately shared by everything that needs the /// answer: the substitution below, which rewrites such a reference, and the -/// grounding-cycle check, which follows one as a dependency edge. Duplicating the -/// guard would let the two disagree about what counts as a reference, and a -/// reference the cycle check cannot see is exactly the input that grounding then -/// fails to resolve. +/// dependency graph `ground_specs` builds, which treats one as an edge between two +/// specs. Duplicating the guard would let the two disagree about what counts as a +/// reference, and a reference the graph cannot see is exactly the one whose +/// ordering — or cycle — grounding would then get wrong. /// /// The strictness is what keeps the rewrite from claiming syntax nobody wrote: a /// path that already carries a qualifier, generic arguments, or more than one @@ -46,26 +46,19 @@ pub fn bare_alias_ident(ty: &Type) -> Option<&Ident> { /// `forbid_duplicate_aliases`), at most one spec can match a given identifier, /// so the match order among specs is irrelevant. /// -/// Each spec's `context_type` must already be *grounded* — resolved to a fully -/// qualified path (`::Types`) with no remaining bare alias — -/// before the visitor runs. Grounding is what lets a single traversal suffice: -/// the replacement a spec emits contains no bare alias, so the visitor never has -/// to revisit its own output to finish a nested import. -/// -/// `is_changed` records whether any replacement was made during the traversal, -/// which the grounding fixpoint reads to decide when a further pass would be a -/// no-op. +/// Every spec passed in must already be *grounded* — its context and trait +/// arguments resolved to fully qualified paths (`::Types`) with +/// no remaining bare alias. That is what lets a single traversal suffice: the +/// replacement a spec emits contains no bare alias of its own, so the visitor never +/// has to revisit its own output to finish a nested import. `ground_specs` +/// establishes the property by resolving each spec against its dependencies. pub struct SubstituteAbstractTypes<'a> { pub specs: &'a [UseTypeAttribute], - pub is_changed: bool, } impl<'a> SubstituteAbstractTypes<'a> { pub fn new(specs: &'a [UseTypeAttribute]) -> Self { - Self { - specs, - is_changed: false, - } + Self { specs } } } @@ -83,7 +76,6 @@ impl VisitMut for SubstituteAbstractTypes<'_> { if let Some(replacement) = replacement { *ty = replacement; - self.is_changed = true; return; } } @@ -126,7 +118,6 @@ impl VisitMut for SubstituteAbstractTypes<'_> { expr.path.segments.iter().skip(1).cloned().collect(); *expr = parse_quote! { <#ty>::#rest }; - self.is_changed = true; // Fall through to the recursion rather than returning: a later segment may carry // generic arguments of its own that name an alias — `Transaction::make::()` — // and those are still to be substituted. Re-visiting the rewritten node cannot diff --git a/crates/tests/cgp-macro-tests/tests/ident_with_type_params/path_with_type_args.rs b/crates/tests/cgp-macro-tests/tests/ident_with_type_params/path_with_type_args.rs index c6431011..923f3382 100644 --- a/crates/tests/cgp-macro-tests/tests/ident_with_type_params/path_with_type_args.rs +++ b/crates/tests/cgp-macro-tests/tests/ident_with_type_params/path_with_type_args.rs @@ -3,7 +3,7 @@ use cgp_macro_core::types::ident::{PathWithTypeArgs, TypeArg}; use quote::quote; -use syn::parse2; +use syn::{WherePredicate, parse2}; use super::{assert_idempotent, assert_parses, assert_rejects}; @@ -91,6 +91,71 @@ fn single_segment_path_has_no_args_for_bare_ident() { assert_eq!(parsed.path.segments.len(), 3); } +#[test] +fn merges_bindings_into_the_argument_list() { + // Associated-type bindings are appended to the path's *own* arguments as one + // list, which is the only spelling Rust accepts for a bound that both + // instantiates a generic trait and pins its associated type. Rendering the + // path's arguments and then a second group — `Foo` — is not a trait + // bound in any position. + let bare: Subject = parse2(quote!(Foo)).unwrap(); + assert_eq!( + bare.to_bound_tokens(&[quote!(Item = X)]).to_string(), + quote!(Foo).to_string(), + ); + + let generic: Subject = parse2(quote!(Foo)).unwrap(); + assert_eq!( + generic.to_bound_tokens(&[quote!(Item = X)]).to_string(), + quote!(Foo).to_string(), + ); + + // A lifetime has to keep leading the list while the binding trails it. + let lifetime: Subject = parse2(quote!(Foo<'a>)).unwrap(); + assert_eq!( + lifetime.to_bound_tokens(&[quote!(Item = X)]).to_string(), + quote!(Foo<'a, Item = X>).to_string(), + ); + + let several: Subject = parse2(quote!(path::to::Foo)).unwrap(); + assert_eq!( + several + .to_bound_tokens(&[quote!(Item = X), quote!(Other = Y)]) + .to_string(), + quote!(path::to::Foo).to_string(), + ); +} + +#[test] +fn renders_unchanged_without_bindings() { + // An empty binding list is the unpinned case, and must leave the path exactly as + // `ToTokens` would render it, so one code path serves both. + for tokens in [quote!(Foo), quote!(Foo), quote!(::path::to::Foo<'a, A>)] { + let parsed: Subject = parse2(tokens.clone()).unwrap(); + assert_eq!( + parsed.to_bound_tokens(&[]).to_string(), + tokens.to_string(), + "empty bindings must not alter the rendering", + ); + } +} + +#[test] +fn bound_output_parses_as_a_trait_bound() { + // The property that matters: whatever the merge emits has to be usable as a + // trait bound. Emitting two argument groups instead once failed *inside* the + // macro with a bare "failed to parse internal tokens" naming no cause, so the + // malformed form never reached the compiler where it could be diagnosed. + for path in [quote!(Foo), quote!(Foo), quote!(path::to::Foo<'a, A>)] { + let parsed: Subject = parse2(path.clone()).unwrap(); + let bound = parsed.to_bound_tokens(&[quote!(Item = X)]); + + parse2::(quote!(Self: #bound)).unwrap_or_else(|error| { + panic!("`{path}` with a binding must parse as a trait bound, got: {error}") + }); + } +} + #[test] fn round_trips() { assert_idempotent::(quote!(Foo)); diff --git a/crates/tests/cgp-tests/tests/abstract_types/mod.rs b/crates/tests/cgp-tests/tests/abstract_types/mod.rs index fb33c5ec..2ec72b1b 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/mod.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/mod.rs @@ -23,10 +23,12 @@ pub mod use_type_trait_arg_component; // The `#[use_type]` attribute rewriting abstract types inside `#[cgp_fn]`: the // bare alias, alias renaming, type-equality bounds, foreign/nested type sources -// (including a two-hop foreign chain), and the grounding of an alias that appears -// in the imported trait's own generic arguments. These keep the `#[cgp_fn]` -// snapshot because the abstract-type rewrite is the point (the `#[cgp_fn]` -// expansion itself is owned by `implicit_arguments`). +// (including a two-hop foreign chain), the grounding of an alias that appears in +// the imported trait's own generic arguments, and the arrangements resolution must +// be indifferent to — a reversed chain, a shared context, and specs split across +// stacked attributes. These keep the `#[cgp_fn]` snapshot because the abstract-type +// rewrite is the point (the `#[cgp_fn]` expansion itself is owned by +// `implicit_arguments`). pub mod use_type_fn_alias; pub mod use_type_fn_deep_foreign; pub mod use_type_fn_equality; @@ -41,6 +43,7 @@ pub mod use_type_fn_generic_trait_equality; pub mod use_type_fn_nested_foreign; pub mod use_type_fn_reverse_order; pub mod use_type_fn_shared_context; +pub mod use_type_fn_stacked_attributes; pub mod use_type_fn_trait_arg_alias; pub mod use_type_foreign_getter; pub mod use_type_uses_supertrait; diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs index 5e1795eb..fc76d042 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_equality_nested.rs @@ -57,6 +57,35 @@ snapshot_cgp_fn! { } } +// The same nested pin with the imports written in the other order, so the pin's +// right-hand side names an alias declared *after* it. Resolution grounds each spec +// against the specs it depends on rather than the ones preceding it, so the emitted +// bound is identical; only the order the bounds are listed in follows the source. +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasTransactionType.{Transaction = Tx}, HasDbType.Db)] + pub fn begin_nested_reversed(&self) -> Transaction { + todo!() + } + + expand_begin_nested_reversed(output) { + insta::assert_snapshot!(output, @" + pub trait BeginNestedReversed: HasTransactionType + HasDbType { + fn begin_nested_reversed(&self) -> ::Transaction; + } + impl<__Context__> BeginNestedReversed for __Context__ + where + Self: HasTransactionType::Db>>, + Self: HasDbType, + { + fn begin_nested_reversed(&self) -> ::Transaction { + todo!() + } + } + ") + } +} + snapshot_cgp_fn! { #[cgp_fn] #[use_type(HasDbType.Db, HasTransactionType.{Transaction = ::Transaction})] diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs index 29b40036..31587097 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_reverse_order.rs @@ -4,9 +4,10 @@ //! declared *after* it. //! //! This is the order-independence counterpart to `use_type_fn_deep_foreign`, which -//! writes the same chain front-to-back. Grounding iterates to a fixpoint over all -//! specs at once, so a spec may name a context imported by any other spec no matter -//! where it sits in the list; the bare `C` still rewrites to the three-hop +//! writes the same chain front-to-back. Grounding resolves each spec against the +//! specs it depends on rather than the ones preceding it, so a spec may name a +//! context imported by any other spec no matter where it sits in the list; the bare +//! `C` still rewrites to the three-hop //! `<<::A as HasB>::B as HasC>::C`. Only a genuine *cycle* — which has //! no valid order at all — cannot be grounded, and that is rejected at macro time //! rather than lowered; the rejections are pinned in `cgp-macro-tests`' diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_stacked_attributes.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_stacked_attributes.rs new file mode 100644 index 00000000..73b559c8 --- /dev/null +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_stacked_attributes.rs @@ -0,0 +1,103 @@ +//! Several `#[use_type]` attributes *stacked* on one item, rather than one +//! attribute carrying a comma-separated list. +//! +//! A host collector appends each attribute's specs to one list, so stacking is +//! expected to behave exactly like the combined form. The combined form is +//! recommended for readability, but the equivalence is a documented promise and is +//! pinned here — including for the case where it could plausibly break, a pin whose +//! right-hand side names an alias imported by a *different attribute* +//! (`#[use_type(HasFooType.{Foo = Vec})]` above `#[use_type(HasBarType.Bar)]`). +//! +//! Both stacking orders are pinned, because nothing about resolution may depend on +//! where a spec was written: it grounds each spec against the specs it depends on, +//! not against the ones that precede it. The only thing source order decides is the +//! order the emitted bounds are *listed* in, which the two snapshots below show. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/attributes/use_type.md and +//! cgp-knowledge-base/cgp/reference/attributes/use_type.md. + +use cgp_macro_test_util::snapshot_cgp_fn; + +pub trait HasBarType { + type Bar; +} + +pub trait HasFooType { + type Foo; +} + +// The pinning attribute comes first, so its right-hand side names an alias the +// attribute *below* it imports. +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasFooType.{Foo = Vec})] + #[use_type(HasBarType.Bar)] + pub fn get_foo(&self) -> Foo { + Vec::new() + } + + expand_get_foo(output) { + insta::assert_snapshot!(output, @" + pub trait GetFoo: HasFooType + HasBarType { + fn get_foo(&self) -> ::Foo; + } + impl<__Context__> GetFoo for __Context__ + where + Self: HasFooType::Bar>>, + Self: HasBarType, + { + fn get_foo(&self) -> ::Foo { + Vec::new() + } + } + ") + } +} + +// The same two imports stacked the other way round. +snapshot_cgp_fn! { + #[cgp_fn] + #[use_type(HasBarType.Bar)] + #[use_type(HasFooType.{Foo = Vec})] + pub fn get_foo_reversed(&self) -> Foo { + Vec::new() + } + + expand_get_foo_reversed(output) { + insta::assert_snapshot!(output, @" + pub trait GetFooReversed: HasBarType + HasFooType { + fn get_foo_reversed(&self) -> ::Foo; + } + impl<__Context__> GetFooReversed for __Context__ + where + Self: HasBarType, + Self: HasFooType::Bar>>, + { + fn get_foo_reversed(&self) -> ::Foo { + Vec::new() + } + } + ") + } +} + +pub struct App; + +impl HasBarType for App { + type Bar = u32; +} + +impl HasFooType for App { + type Foo = Vec; +} + +#[test] +fn test_stacked_attributes_resolve() { + // `Foo` is pinned to `Vec<::Bar>`, so both functions return + // the context's `Vec` whichever order the attributes were stacked in. + let foo: Vec = App.get_foo(); + let reversed: Vec = App.get_foo_reversed(); + + assert!(foo.is_empty()); + assert!(reversed.is_empty()); +} diff --git a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs index 907daca4..1e7eeca4 100644 --- a/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs +++ b/crates/tests/cgp-tests/tests/abstract_types/use_type_fn_trait_arg_alias.rs @@ -85,8 +85,8 @@ pub trait HasHandleType { // A three-hop chain threaded entirely through trait arguments rather than through // `in Context` clauses, so each hop's argument must be grounded before the next can // project against it. This is the trait-argument analogue of the context chain in -// `use_type_fn_deep_foreign`, and it exercises the same grounding fixpoint over the -// new position. +// `use_type_fn_deep_foreign`, and it exercises the same dependency-ordered +// resolution over the new position. snapshot_cgp_fn! { #[cgp_fn] #[use_type(HasDbType.Db, HasPoolType.Pool, HasHandleType.Handle)] From e59ad03c4a96cb19206542f116e81b31f4f4a747 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sun, 2 Aug 2026 16:01:09 +0200 Subject: [PATCH 06/10] Lift nested tables out of namespaces and `for` bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DelegateTable::eval` collects every nested `Wrapper` value and emits its struct and `DelegateComponent` impls; two paths that reach the same value did not, so the table was parsed, named in the entry's `Delegate`, and then never emitted. Both failed with `E0425` on the dropped struct — a message that reads like a typo rather than a lost table. `NamespaceTable::eval` was the first. A namespace body parses into the same `DelegateEntries` a delegation table does, so it accepts the value either way; what it lacked was the extraction. Skipping it was an inconsistency rather than a boundary — the neighbouring case was already handled — which is the test the implementation documentation prescribes for exactly this judgement. `eval` now runs the same walk, and `EvaluatedNamespaceTable` grows an `inner_structs` field rendered after the trait, mirroring `EvaluatedDelegateTable`. The form earns its keep here rather than merely working: the dispatch table lives in the namespace, so every context that joins inherits it without restating it. `DelegateEntries::extract_inner_tables` was the second, and it reaches `delegate_components!` itself. It walked mappings but not statements, so a value inside a `for` loop's body was dropped the same way. It now walks both, with `ExtractInnerDelegateTables` implemented for the statement forms — `for` delegating to its body's mappings, `namespace` and `open` carrying no value. Writing such a value well-formed takes one specific shape, and it is worth knowing before reaching for the form. The loop binds a provider variable the entry must mention, or the generated impl leaves it unconstrained; but the loop's variables are not in scope inside the lifted table's own impls, so mentioning it only there fails on both counts at once. Declaring it as the inner table's own generic — `new Inner` — puts it in both places. The combination stays redundant in practice, since a loop already yields one provider per key, so the form is kept correct rather than recommended. Seven tests close gaps the audit of this grammar turned up, all of them cases where documentation made a claim nothing pinned. `redirect_mapping` covers the `=>` operator written on a context, which appeared nowhere in the suite. Its second snapshot is the `open` spelling of its first entry, so the two goldens are identical modulo the context name — which pins the `open C;` ≡ `C => @C,` equivalence by construction rather than asserting it. `combined_forms` puts every body form in one block, the only place the composition itself is checked. `namespace_nested_table` and `for_loop_nested_table` guard the two halves of the fix above; both were confirmed to fail without it. Three rejection cases pin body-grammar diagnostics whose wording is misleading enough to be worth guarding: a statement after a mapping and a braced path group followed by more path, both reported as `expected ':'`, and a bounded generic list on a nested table, reported as `expected ','` because the value parser falls back to reading the whole value as a plain type. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/types/delegate_component/entries.rs | 11 +- .../delegate_component/statement/combined.rs | 14 +- .../delegate_component/statement/for_loop.rs | 16 +- .../src/types/namespace/eval.rs | 10 + .../src/types/namespace/table.rs | 14 + .../parser_rejections/delegate_components.rs | 68 ++++ .../tests/namespaces/combined_forms.rs | 303 ++++++++++++++++++ .../tests/namespaces/for_loop_nested_table.rs | 66 ++++ .../tests/cgp-tests/tests/namespaces/mod.rs | 8 + .../namespaces/namespace_nested_table.rs | 106 ++++++ .../tests/namespaces/redirect_mapping.rs | 299 +++++++++++++++++ 11 files changed, 909 insertions(+), 6 deletions(-) create mode 100644 crates/tests/cgp-tests/tests/namespaces/combined_forms.rs create mode 100644 crates/tests/cgp-tests/tests/namespaces/for_loop_nested_table.rs create mode 100644 crates/tests/cgp-tests/tests/namespaces/namespace_nested_table.rs create mode 100644 crates/tests/cgp-tests/tests/namespaces/redirect_mapping.rs diff --git a/crates/macros/cgp-macro-core/src/types/delegate_component/entries.rs b/crates/macros/cgp-macro-core/src/types/delegate_component/entries.rs index f21e41ff..26107525 100644 --- a/crates/macros/cgp-macro-core/src/types/delegate_component/entries.rs +++ b/crates/macros/cgp-macro-core/src/types/delegate_component/entries.rs @@ -82,9 +82,16 @@ impl DelegateEntries { impl ExtractInnerDelegateTables for DelegateEntries { fn extract_inner_tables(&self) -> Vec { - self.entries + // Statements are walked as well as mappings, because a `for` loop's body + // holds mappings whose values may open a nested table. + self.statements .iter() - .flat_map(|entry| entry.extract_inner_tables()) + .flat_map(|statement| statement.extract_inner_tables()) + .chain( + self.entries + .iter() + .flat_map(|entry| entry.extract_inner_tables()), + ) .collect() } } diff --git a/crates/macros/cgp-macro-core/src/types/delegate_component/statement/combined.rs b/crates/macros/cgp-macro-core/src/types/delegate_component/statement/combined.rs index 4d4979ae..038f1531 100644 --- a/crates/macros/cgp-macro-core/src/types/delegate_component/statement/combined.rs +++ b/crates/macros/cgp-macro-core/src/types/delegate_component/statement/combined.rs @@ -4,8 +4,8 @@ use syn::{Error, Type}; use crate::traits::PeekKeyword; use crate::types::delegate_component::{ - EvalDelegateEntries, EvaluatedDelegateEntry, ForDelegateStatement, NamespaceDelegateStatement, - OpenDelegateStatement, + EvalDelegateEntries, EvaluatedDelegateEntry, ExtractInnerDelegateTables, ForDelegateStatement, + InnerDelegateTable, NamespaceDelegateStatement, OpenDelegateStatement, }; use crate::types::keywords::{Namespace, Open}; @@ -52,3 +52,13 @@ impl EvalDelegateEntries for DelegateStatement { } } } + +impl ExtractInnerDelegateTables for DelegateStatement { + fn extract_inner_tables(&self) -> Vec { + match self { + // `namespace` and `open` carry no values, so neither can open a table. + Self::Namespace(_) | Self::Open(_) => Vec::new(), + Self::For(statement) => statement.extract_inner_tables(), + } + } +} diff --git a/crates/macros/cgp-macro-core/src/types/delegate_component/statement/for_loop.rs b/crates/macros/cgp-macro-core/src/types/delegate_component/statement/for_loop.rs index 96964916..88b95611 100644 --- a/crates/macros/cgp-macro-core/src/types/delegate_component/statement/for_loop.rs +++ b/crates/macros/cgp-macro-core/src/types/delegate_component/statement/for_loop.rs @@ -5,8 +5,8 @@ use syn::{Ident, Type, WhereClause, braced}; use crate::types::delegate_component::{ EvalDelegateEntries, EvalDelegateKey, EvalDelegateValue, EvalForEntries, - EvaluatedDelegateEntry, EvaluatedForEntry, NormalDelegateMapping, - eval_delegate_entries_via_for, + EvaluatedDelegateEntry, EvaluatedForEntry, ExtractInnerDelegateTables, InnerDelegateTable, + NormalDelegateMapping, eval_delegate_entries_via_for, }; use crate::types::ident::PathWithTypeArgs; @@ -106,3 +106,15 @@ impl EvalDelegateEntries for ForDelegateStatement { eval_delegate_entries_via_for(self, table_type) } } + +impl ExtractInnerDelegateTables for ForDelegateStatement { + fn extract_inner_tables(&self) -> Vec { + // A loop body holds ordinary `:` mappings, whose values may open a nested + // table just as a top-level mapping's can. Without this the inner table is + // parsed, named by the entry's `Delegate`, and never emitted. + self.mappings + .iter() + .flat_map(|mapping| mapping.extract_inner_tables()) + .collect() + } +} diff --git a/crates/macros/cgp-macro-core/src/types/namespace/eval.rs b/crates/macros/cgp-macro-core/src/types/namespace/eval.rs index 5fa14135..7b3afc14 100644 --- a/crates/macros/cgp-macro-core/src/types/namespace/eval.rs +++ b/crates/macros/cgp-macro-core/src/types/namespace/eval.rs @@ -2,10 +2,16 @@ use proc_macro2::TokenStream; use quote::ToTokens; use syn::{ItemImpl, ItemStruct, ItemTrait}; +use crate::types::empty_struct::EmptyStruct; + pub struct EvaluatedNamespaceTable { pub item_impls: Vec, pub item_trait: Option, pub item_struct: Option, + /// The structs declared for nested `Wrapper` values lifted out + /// of the body, mirroring `EvaluatedDelegateTable::item_structs`. Their + /// `DelegateComponent` impls are appended to `item_impls`. + pub inner_structs: Vec, } impl ToTokens for EvaluatedNamespaceTable { @@ -18,6 +24,10 @@ impl ToTokens for EvaluatedNamespaceTable { item_trait.to_tokens(tokens); } + for inner_struct in &self.inner_structs { + inner_struct.to_tokens(tokens); + } + for item_impl in &self.item_impls { item_impl.to_tokens(tokens); } diff --git a/crates/macros/cgp-macro-core/src/types/namespace/table.rs b/crates/macros/cgp-macro-core/src/types/namespace/table.rs index 7a1f4580..8834afc9 100644 --- a/crates/macros/cgp-macro-core/src/types/namespace/table.rs +++ b/crates/macros/cgp-macro-core/src/types/namespace/table.rs @@ -6,6 +6,7 @@ use crate::functions::parse_internal; use crate::traits::ParseOptionalKeyword; use crate::types::delegate_component::{ DelegateEntries, EvalDelegateEntries, EvalDelegateEntry, EvalForEntry, + ExtractInnerDelegateTables, }; use crate::types::generics::ImplGenerics; use crate::types::ident::{IdentWithTypeArgs, PathWithTypeArgs}; @@ -162,10 +163,23 @@ impl NamespaceTable { item_impls.insert(0, item_impl); } + // Lift out each nested `Wrapper` value, exactly as + // `DelegateTable::eval` does. The entry's `Delegate` resolves to + // `Wrapper`, so without emitting `Inner` and its own + // `DelegateComponent` impls the entry would name a type nothing declares. + let mut inner_structs = Vec::new(); + + for inner_table in self.entries.extract_inner_tables() { + inner_structs.push(inner_table.build_table_struct()); + + item_impls.extend(inner_table.build_impls()?); + } + Ok(EvaluatedNamespaceTable { item_impls, item_trait, item_struct, + inner_structs, }) } } diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_components.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_components.rs index d97080fa..44400d24 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_components.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_components.rs @@ -4,6 +4,11 @@ //! rather than silently drop. It also rejects a braceless `open` header that //! lists more than one component, since the braceless form opens exactly one. //! +//! Three further rejections come from the body grammar rather than from +//! attributes, and each is a documented diagnostic whose wording is misleading +//! enough to be worth pinning: a statement written after a mapping, a braced path +//! group followed by more path, and a bounded generic list on a nested table. +//! //! See cgp-knowledge-base/cgp/implementation/entrypoints/delegate_components.md (Tests) for these //! failure cases, and cgp-knowledge-base/cgp/reference/macros/delegate_components.md for the //! user-facing semantics. @@ -71,3 +76,66 @@ fn rejects_attribute_on_inner_table_key() { }, ); } + +#[test] +fn rejects_statement_after_mapping() { + // Statements must lead the block. Once the parser has moved on to mappings it + // reads `open` as a *key* type, then looks for an operator and finds the + // component name — so the rejection is real but the message (`expected `:``) + // blames the component rather than the misplaced statement. + assert_macro_rejects( + "delegate_components with a statement written after a mapping", + || { + cgp_macro_lib::delegate_components(quote!( + Context { + BarComponent: Bar, + + open FooComponent; + + @FooComponent.String: Foo, + } + )) + }, + ); +} + +#[test] +fn rejects_braced_path_group_followed_by_more_path() { + // A braced group holds whole path *tails* and therefore terminates the path, + // unlike a bracketed group, which holds alternatives for one segment and may + // be followed by more. The trailing `.bool` is left where the mapping's + // operator should be, so this too reports `expected `:``. + assert_macro_rejects( + "delegate_components with a braced path group followed by more path", + || { + cgp_macro_lib::delegate_components(quote!( + Context { + open FooComponent; + + @FooComponent.{String, u32}.bool: Foo, + } + )) + }, + ); +} + +#[test] +fn rejects_bounded_generics_on_inner_table() { + // A nested table's name takes a bound-free generic list; a bound belongs on + // the entry's own generics instead. The value parser tries the nested-table + // form speculatively and falls back to reading the whole value as a plain + // type when it fails, so the message is an unrelated `expected `,`` rather + // than anything about generics. + assert_macro_rejects( + "delegate_components with a bounded generic list on a nested table", + || { + cgp_macro_lib::delegate_components(quote!( + Context { + BarKey: UseDelegate { + BazKey: BazValue, + }>, + } + )) + }, + ); +} diff --git a/crates/tests/cgp-tests/tests/namespaces/combined_forms.rs b/crates/tests/cgp-tests/tests/namespaces/combined_forms.rs new file mode 100644 index 00000000..c89c69ed --- /dev/null +++ b/crates/tests/cgp-tests/tests/namespaces/combined_forms.rs @@ -0,0 +1,303 @@ +//! Every `delegate_components!` body form in one block. +//! +//! The body's forms are independent choices — three mapping operators, three key +//! forms, and the leading statements — and nothing stops a table from using all of +//! them at once. Every other test in the suite stays inside one family, so this is +//! the only place the composition itself is pinned: an `open` statement, a `->` +//! forwarding entry into an aggregate provider, a bracketed list key sharing one +//! provider, and `@`-path keys using both grouping forms and a per-segment +//! generic, all against one context. +//! +//! The two grouping forms are the pair most easily confused, so both appear on the +//! same component: `{Circle, Ellipse}` groups whole path tails and ends the path, +//! while `[Square, Triangle]` groups alternatives for one segment. They fan out to +//! one impl pair per alternative either way. +//! +//! The `delegate_components!` snapshot is the golden output this file owns; the +//! components, the providers, and the `Bundle` aggregate are incidental +//! scaffolding. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/delegate_components.md and +//! cgp-knowledge-base/cgp/reference/macros/delegate_components.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_delegate_components; + +// Incidental: one generic component to dispatch per shape, and three plain ones +// wired through the other forms. +#[cgp_component(AreaCalculator)] +pub trait CanCalculateArea { + fn area(&self, shape: &Shape) -> f64; +} + +#[cgp_component(BarProvider)] +pub trait Bar { + fn bar(&self); +} + +#[cgp_component(BazProvider)] +pub trait Baz { + fn baz(&self); +} + +#[cgp_component(QuuxProvider)] +pub trait Quux { + fn quux(&self); +} + +pub struct Rectangle; +pub struct Circle; +pub struct Ellipse; +pub struct Square; +pub struct Triangle; + +// Incidental: per-shape and plain providers. +#[cgp_impl(new ShapeArea)] +impl AreaCalculator { + fn area(&self, _shape: &Shape) -> f64 { + 0.0 + } +} + +#[cgp_impl(new RefArea)] +impl AreaCalculator { + fn area(&self, _shape: &Shape) -> f64 { + 0.0 + } +} + +#[cgp_impl(new DummyBar)] +impl BarProvider { + fn bar(&self) {} +} + +#[cgp_impl(new DummyBaz)] +impl BazProvider { + fn baz(&self) {} +} + +#[cgp_impl(DummyBaz)] +impl QuuxProvider { + fn quux(&self) {} +} + +// Incidental: the aggregate provider the `->` entry forwards into. +delegate_components! { + new Bundle { + BarProviderComponent: DummyBar, + } +} + +pub struct App; + +snapshot_delegate_components! { + delegate_components! { + App { + // Statement, which must lead the block. + open AreaCalculatorComponent; + + // `->`: take whatever `Bundle` wires for this key. + BarProviderComponent -> + Bundle, + + // List key: one provider for two components. + [BazProviderComponent, QuuxProviderComponent]: + DummyBaz, + + // Path key with a braced group — whole tails, ends the path. + @AreaCalculatorComponent.{Rectangle, Circle}: + ShapeArea, + + // Path key with a bracketed group — alternatives for one segment. + @AreaCalculatorComponent.[Square, Triangle]: + ShapeArea, + + // Path key whose segment carries its own generics. + @AreaCalculatorComponent.<'a, T> &'a T: + RefArea, + } + } + + expand_combined_forms_app(output) { + insta::assert_snapshot!(output, @" + impl DelegateComponent for App { + type Delegate = RedirectLookup>; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + RedirectLookup< + App, + PathCons, + >: IsProviderFor, + {} + impl DelegateComponent for App + where + Bundle: DelegateComponent, + { + type Delegate = >::Delegate; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + Bundle: DelegateComponent, + >::Delegate: IsProviderFor, + {} + impl DelegateComponent for App { + type Delegate = DummyBaz; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + DummyBaz: IsProviderFor, + {} + impl DelegateComponent for App { + type Delegate = DummyBaz; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + DummyBaz: IsProviderFor, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = ShapeArea; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + ShapeArea: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = ShapeArea; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + ShapeArea: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = ShapeArea; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + ShapeArea: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = ShapeArea; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + ShapeArea: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + impl< + 'a, + T, + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = RefArea; + } + impl< + 'a, + T, + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + RefArea: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + ") + } +} + +check_components! { + App { + AreaCalculatorComponent: [ + Rectangle, + Circle, + Square, + Triangle, + <'a> &'a Ellipse, + ], + BarProviderComponent, + BazProviderComponent, + QuuxProviderComponent, + } +} diff --git a/crates/tests/cgp-tests/tests/namespaces/for_loop_nested_table.rs b/crates/tests/cgp-tests/tests/namespaces/for_loop_nested_table.rs new file mode 100644 index 00000000..4acf0642 --- /dev/null +++ b/crates/tests/cgp-tests/tests/namespaces/for_loop_nested_table.rs @@ -0,0 +1,66 @@ +//! A nested `UseDelegate` value inside a `for <..> in ..` loop body. +//! +//! A loop body holds ordinary `:` mappings, so its values can open a nested table +//! just as a top-level mapping's can — and the extraction that lifts such a table +//! out has to walk the body's statements, not only its mappings. A mapping-only +//! walk parses the table, names it in the entry's `Delegate`, and never emits it, +//! failing with an `E0425` on the dropped struct. +//! +//! What this file pins is that the table is emitted and carries its entry. It does +//! **not** wire the component through, because the combination is redundant by +//! construction: the loop already yields one provider per key, so a nested table +//! would dispatch the same parameter a second time. The form is worth keeping +//! correct rather than worth recommending. +//! +//! Note the inner table's own generic list. The loop binds a provider variable the +//! entry must mention, or the generated impl leaves it unconstrained (`E0207`); but +//! the loop's variables are not in scope inside the lifted table's impls, so +//! mentioning it only there fails on both counts (`E0207` and an `E0425` for the +//! `Provider` the inner impl cannot see). `new LoopInner` is what puts it +//! in both places at once. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/delegate_components.md. + +use cgp::prelude::*; + +// Incidental: a generic component routed into `DefaultNamespace` under a prefix. +#[cgp_component(FooProvider)] +#[prefix(@test in DefaultNamespace)] +#[derive_delegate(UseDelegate)] +pub trait Foo { + fn foo(&self, value: &T); +} + +#[cgp_impl(new DummyFoo)] +impl FooProvider { + fn foo(&self, _value: &T) {} +} + +// Incidental: the table the loop reads its (key, provider) pairs out of. +cgp_namespace! { + new FooSources { + String: DummyFoo, + } +} + +pub struct App; + +delegate_components! { + App { + namespace DefaultNamespace; + + for in FooSources { + @test.FooProviderComponent.T: UseDelegate { + String: Provider, + }>, + } + } +} + +// The lifted table exists and holds the entry the loop body gave it. Naming +// `LoopInner` at all is the regression this file guards: before the extraction +// walked statements, the struct was never declared and this bound would not +// resolve. +pub trait CheckLoopInner: DelegateComponent {} + +impl CheckLoopInner for LoopInner {} diff --git a/crates/tests/cgp-tests/tests/namespaces/mod.rs b/crates/tests/cgp-tests/tests/namespaces/mod.rs index d362862f..c3e0b4fb 100644 --- a/crates/tests/cgp-tests/tests/namespaces/mod.rs +++ b/crates/tests/cgp-tests/tests/namespaces/mod.rs @@ -21,6 +21,14 @@ pub mod open_dispatch; pub mod prefix_default_namespace; pub mod redirect_lookup; +// The rest of the shared `delegate_components!` body grammar: the `=>` operator +// written on a context (and its equivalence to `open`), every form combined in one +// block, and a nested `UseDelegate` table lifted out of a `cgp_namespace!` body. +pub mod combined_forms; +pub mod for_loop_nested_table; +pub mod namespace_nested_table; +pub mod redirect_mapping; + // Namespace inheritance and per-type default impls. `default_impls` and // `extended` define reusable namespaces/providers (with `cgp_namespace!`, // `#[prefix]`, and `#[default_impl]` snapshots); the `*_wiring` modules consume diff --git a/crates/tests/cgp-tests/tests/namespaces/namespace_nested_table.rs b/crates/tests/cgp-tests/tests/namespaces/namespace_nested_table.rs new file mode 100644 index 00000000..b2c3ae3b --- /dev/null +++ b/crates/tests/cgp-tests/tests/namespaces/namespace_nested_table.rs @@ -0,0 +1,106 @@ +//! A nested `UseDelegate` value inside a `cgp_namespace!` body. +//! +//! A namespace body is parsed by the same `DelegateEntries` type a +//! `delegate_components!` table is, so it accepts the legacy nested-table value — +//! and `cgp_namespace!` must lift that inner table out into its own struct and +//! `DelegateComponent` impls exactly as `delegate_components!` does. Drop that and +//! the entry's `Delegate` names a `FooTable` nothing declares, failing with an +//! `E0425` that reads like a typo rather than a dropped table — the regression +//! this file guards. +//! +//! Putting the dispatch table in the namespace rather than on the context is the +//! point of the form: `AppA` and `AppB` join `NestedNs` and both get the per-type +//! dispatch without either one spelling it out. The `cgp_namespace!` snapshot is +//! the golden output this file owns — it pins the lifted `FooTable` struct and its +//! two entries beside the namespace's own impl — and the component and provider +//! are incidental scaffolding. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/cgp_namespace.md and +//! cgp-knowledge-base/cgp/reference/macros/cgp_namespace.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_cgp_namespace; + +// Incidental: a generic component whose dispatch the nested table drives. The +// legacy nested-table form resolves through `#[derive_delegate]`, unlike `open`. +#[cgp_component(FooProvider)] +#[derive_delegate(UseDelegate)] +pub trait Foo { + fn foo(&self, value: &T); +} + +// Incidental: one per-value provider, shared by both dispatch entries. +#[cgp_impl(new DummyFoo)] +impl FooProvider { + fn foo(&self, _value: &T) {} +} + +snapshot_cgp_namespace! { + cgp_namespace! { + new NestedNs { + FooProviderComponent: + UseDelegate, + } + } + + expand_nested_table_namespace(output) { + insta::assert_snapshot!(output, @" + pub struct __NestedNsComponents; + pub trait NestedNs<__Table__> { + type Delegate; + } + pub struct FooTable; + impl<__Table__> NestedNs<__Table__> for FooProviderComponent { + type Delegate = UseDelegate; + } + impl DelegateComponent for FooTable { + type Delegate = DummyFoo; + } + impl<__Context__, __Params__> IsProviderFor for FooTable + where + DummyFoo: IsProviderFor, + {} + impl DelegateComponent for FooTable { + type Delegate = DummyFoo; + } + impl<__Context__, __Params__> IsProviderFor for FooTable + where + DummyFoo: IsProviderFor, + {} + ") + } +} + +pub struct AppA; + +delegate_components! { + AppA { + namespace NestedNs; + } +} + +check_components! { + AppA { + FooProviderComponent: [String, u64], + } +} + +// A second context joining the same namespace inherits the same dispatch table, +// which is what putting it in the namespace bought. +pub struct AppB; + +delegate_components! { + AppB { + namespace NestedNs; + } +} + +check_components! { + #[check_trait(__CheckAppB)] + AppB { + FooProviderComponent: [String, u64], + } +} diff --git a/crates/tests/cgp-tests/tests/namespaces/redirect_mapping.rs b/crates/tests/cgp-tests/tests/namespaces/redirect_mapping.rs new file mode 100644 index 00000000..e61cb722 --- /dev/null +++ b/crates/tests/cgp-tests/tests/namespaces/redirect_mapping.rs @@ -0,0 +1,299 @@ +//! The `=>` redirect operator written directly in a `delegate_components!` table. +//! +//! `=>` sets an entry's `Delegate` to a `RedirectLookup` along an `@`-path instead +//! of naming a provider, so the provider is decided wherever the path lands. Two +//! uses appear here. `FooProviderComponent => @FooProviderComponent` roots a +//! component's route at its own name, which is **exactly** what `open +//! FooProviderComponent;` generates — the snapshots below are the equivalence, +//! since `App` and `OpenApp` differ only in which spelling they use and their +//! golden output is identical. And `[BarProviderComponent, BazProviderComponent] +//! => @shared` points two components at one slot, answered by a single +//! `@shared: DummyImpl` entry. +//! +//! This is the only place the suite exercises `=>` outside a `cgp_namespace!` +//! body. The two `delegate_components!` snapshots are the golden output this file +//! owns; the components and providers are incidental scaffolding. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/delegate_components.md and +//! cgp-knowledge-base/cgp/reference/macros/delegate_components.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_delegate_components; + +// Incidental: one generic component dispatched per type, and two plain ones +// sharing a redirect slot. +#[cgp_component(FooProvider)] +pub trait Foo { + fn foo(&self, value: &T); +} + +#[cgp_component(BarProvider)] +pub trait Bar { + fn bar(&self); +} + +#[cgp_component(BazProvider)] +pub trait Baz { + fn baz(&self); +} + +// Incidental: plain providers for the three components. +#[cgp_impl(new DummyFoo)] +impl FooProvider { + fn foo(&self, _value: &T) {} +} + +#[cgp_impl(new DummyImpl)] +impl BarProvider { + fn bar(&self) {} +} + +#[cgp_impl(DummyImpl)] +impl BazProvider { + fn baz(&self) {} +} + +pub struct App; + +snapshot_delegate_components! { + delegate_components! { + App { + // The spelled-out form of `open FooProviderComponent;`. + FooProviderComponent => + @FooProviderComponent, + + @FooProviderComponent.String: + DummyFoo, + + // A list key redirecting two components onto one shared slot, which + // the single entry below answers. + [BarProviderComponent, BazProviderComponent] => + @shared, + + @shared: + DummyImpl, + } + } + + expand_redirect_mapping_app(output) { + insta::assert_snapshot!(output, @" + impl DelegateComponent for App { + type Delegate = RedirectLookup>; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + RedirectLookup< + App, + PathCons, + >: IsProviderFor, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for App { + type Delegate = DummyFoo; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for App + where + DummyFoo: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + impl DelegateComponent for App { + type Delegate = RedirectLookup< + App, + PathCons< + Symbol< + 6, + Chars< + 's', + Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>, + >, + >, + Nil, + >, + >; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + RedirectLookup< + App, + PathCons< + Symbol< + 6, + Chars< + 's', + Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>, + >, + >, + Nil, + >, + >: IsProviderFor, + {} + impl DelegateComponent for App { + type Delegate = RedirectLookup< + App, + PathCons< + Symbol< + 6, + Chars< + 's', + Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>, + >, + >, + Nil, + >, + >; + } + impl< + __Context__, + __Params__, + > IsProviderFor for App + where + RedirectLookup< + App, + PathCons< + Symbol< + 6, + Chars< + 's', + Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>, + >, + >, + Nil, + >, + >: IsProviderFor, + {} + impl< + __Wildcard__, + > DelegateComponent< + PathCons< + Symbol< + 6, + Chars<'s', Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>>, + >, + __Wildcard__, + >, + > for App { + type Delegate = DummyImpl; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons< + Symbol< + 6, + Chars<'s', Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>>, + >, + __Wildcard__, + >, + __Context__, + __Params__, + > for App + where + DummyImpl: IsProviderFor< + PathCons< + Symbol< + 6, + Chars< + 's', + Chars<'h', Chars<'a', Chars<'r', Chars<'e', Chars<'d', Nil>>>>>, + >, + >, + __Wildcard__, + >, + __Context__, + __Params__, + >, + {} + ") + } +} + +check_components! { + App { + FooProviderComponent: String, + BarProviderComponent, + BazProviderComponent, + } +} + +// The `open` spelling of the first entry above. Its `DelegateComponent` and +// `IsProviderFor` impls must match `App`'s `FooProviderComponent` pair exactly. +pub struct OpenApp; + +snapshot_delegate_components! { + delegate_components! { + OpenApp { + open FooProviderComponent; + + @FooProviderComponent.String: + DummyFoo, + } + } + + expand_redirect_mapping_open_app(output) { + insta::assert_snapshot!(output, @" + impl DelegateComponent for OpenApp { + type Delegate = RedirectLookup>; + } + impl< + __Context__, + __Params__, + > IsProviderFor for OpenApp + where + RedirectLookup< + OpenApp, + PathCons, + >: IsProviderFor, + {} + impl< + __Wildcard__, + > DelegateComponent>> + for OpenApp { + type Delegate = DummyFoo; + } + impl< + __Wildcard__, + __Context__, + __Params__, + > IsProviderFor< + PathCons>, + __Context__, + __Params__, + > for OpenApp + where + DummyFoo: IsProviderFor< + PathCons>, + __Context__, + __Params__, + >, + {} + ") + } +} + +check_components! { + #[check_trait(__CheckOpenApp)] + OpenApp { + FooProviderComponent: String, + } +} From 2561b477a6927a7d8b04d26a55ce3fbd6988454a Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sun, 2 Aug 2026 20:39:49 +0200 Subject: [PATCH 07/10] Pin five getter, implicit, and provider-attribute behaviours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of these cover behaviour that was implemented and untested; the fifth pins a defect rather than a feature. `basic_delegation/self_local_assoc_type.rs` covers the exemption in `#[cgp_impl]`'s `Self` rewrite: a `Self::Assoc` naming an associated type the block itself declares is left alone, so the emitted provider impl keeps it, while every other `Self` in the same body still becomes the context. `implicit_arguments/cgp_fn_mref.rs` covers the `MRef<'_, T>` implicit argument — the one reference-shaped mode with no mutable mirror, which stays a shared `HasField` read even under a `&mut self` receiver. `parser_rejections/use_provider.rs` pins the one-provider-per-attribute rule, together with the counterpart that stops it being read too broadly: `+` continues one provider's bound list and is accepted, while a comma is not. `parser_rejections/delegate_and_check_components.rs` covers the check attributes the macro refuses, including a `#[skip_check]` merged with a `#[check_params]` across a list key and its element — a conflict that exists only after the merge, so neither attribute is wrong on its own. `higher_order_providers/lifetime_inner_provider.rs` snapshots current, wrong behaviour: a higher-order provider over a lifetime-carrying component gets no `IsProviderFor` counterpart on its inner bound, because the rewrite reads the bound's first generic argument as the context and finds a lifetime there. The stack compiles and runs; what is lost is the propagation that lets `#[check_providers]` localize a broken layer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011BfN1sjgaBAA2sEVqVNeig --- .../delegate_and_check_components.rs | 97 +++++++++++++++++++ .../tests/parser_rejections/mod.rs | 2 + .../tests/parser_rejections/use_provider.rs | 75 ++++++++++++++ .../cgp-tests/tests/basic_delegation/mod.rs | 1 + .../basic_delegation/self_local_assoc_type.rs | 69 +++++++++++++ .../lifetime_inner_provider.rs | 87 +++++++++++++++++ .../tests/higher_order_providers/mod.rs | 4 + .../tests/implicit_arguments/cgp_fn_mref.rs | 47 +++++++++ .../cgp-tests/tests/implicit_arguments/mod.rs | 1 + 9 files changed, 383 insertions(+) create mode 100644 crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs create mode 100644 crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs create mode 100644 crates/tests/cgp-tests/tests/basic_delegation/self_local_assoc_type.rs create mode 100644 crates/tests/cgp-tests/tests/higher_order_providers/lifetime_inner_provider.rs create mode 100644 crates/tests/cgp-tests/tests/implicit_arguments/cgp_fn_mref.rs diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs new file mode 100644 index 00000000..c5f0c69d --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs @@ -0,0 +1,97 @@ +//! `delegate_and_check_components!` rejects the check attributes it cannot make +//! sense of: a `#[skip_check]` merged with a `#[check_params]` across a list key +//! and its element, two check attributes on one key, `#[skip_check]` given +//! arguments, an unrecognized per-entry attribute, and a table attribute that is +//! not `#[check_trait]`. +//! +//! The merge case is the one worth pinning: a list key's attribute is combined +//! with each element's rather than overridden by it, so the conflict only exists +//! once the two are merged and cannot be caught by looking at either alone. +//! +//! See cgp-knowledge-base/cgp/reference/macros/delegate_and_check_components.md +//! for the merge rules these enforce. + +use quote::quote; + +use super::assert_macro_rejects; + +#[test] +fn rejects_skip_check_merged_with_check_params() { + assert_macro_rejects( + "delegate_and_check_components merging #[skip_check] with #[check_params]", + || { + cgp_macro_lib::delegate_and_check_components(quote!( + MyApp { + #[skip_check] + [ + #[check_params(Rectangle)] + AreaCalculatorComponent, + ]: ShapeProvider, + } + )) + }, + ); +} + +#[test] +fn rejects_two_check_attributes_on_one_key() { + assert_macro_rejects( + "delegate_and_check_components with two check attributes on one key", + || { + cgp_macro_lib::delegate_and_check_components(quote!( + MyApp { + #[check_params(Rectangle)] + #[check_params(Circle)] + AreaCalculatorComponent: ShapeProvider, + } + )) + }, + ); +} + +#[test] +fn rejects_skip_check_with_arguments() { + assert_macro_rejects( + "delegate_and_check_components with #[skip_check(..)] taking arguments", + || { + cgp_macro_lib::delegate_and_check_components(quote!( + MyApp { + #[skip_check(Rectangle)] + AreaCalculatorComponent: ShapeProvider, + } + )) + }, + ); +} + +#[test] +fn rejects_unknown_entry_attribute() { + assert_macro_rejects( + "delegate_and_check_components with an unrecognized entry attribute", + || { + cgp_macro_lib::delegate_and_check_components(quote!( + MyApp { + #[check_provider(RectangleArea)] + AreaCalculatorComponent: ShapeProvider, + } + )) + }, + ); +} + +#[test] +fn rejects_unknown_table_attribute() { + // The table accepts only `#[check_trait]`; `#[check_providers]` belongs to + // the standalone `check_components!` and is refused here rather than ignored. + assert_macro_rejects( + "delegate_and_check_components with #[check_providers] on the table", + || { + cgp_macro_lib::delegate_and_check_components(quote!( + #[check_providers(RectangleArea)] + MyApp { + AreaCalculatorComponent: ShapeProvider, + } + )) + }, + ); +} diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs index 5c619e89..5d8a3808 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs @@ -31,5 +31,7 @@ pub mod check_components; pub mod delegate_components; pub mod derive_cgp_data; pub mod derive_from_variant; +pub mod delegate_and_check_components; pub mod getters; +pub mod use_provider; pub mod use_type; diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs new file mode 100644 index 00000000..336fee9b --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs @@ -0,0 +1,75 @@ +//! `#[use_provider]` binds exactly one inner provider per attribute, so a +//! comma-separated list of provider-and-trait pairs does not parse. +//! +//! The attribute's own argument ends in a `+`-separated bound list, which is +//! consumed to the end of the input. A comma after the first pair therefore lands +//! where the bound parser expects a `+`, and the whole attribute is rejected — +//! which makes this attribute the exception to the one-attribute-comma-separated +//! convention `#[uses]` and `#[use_type]` follow. Binding several inner providers +//! means stacking one attribute per provider. +//! +//! Both hosts that collect the attribute are covered, since the rejection comes +//! from the shared argument parser rather than from either host. +//! +//! See cgp-knowledge-base/cgp/reference/attributes/use_provider.md for the +//! user-facing rule and cgp-knowledge-base/cgp/implementation/asts/attributes/use_provider.md +//! for the parser. + +use quote::quote; + +use super::assert_macro_rejects; + +#[test] +fn rejects_comma_separated_pairs_on_impl() { + assert_macro_rejects("use_provider with a comma-separated pair list", || { + cgp_macro_lib::cgp_impl( + quote!(new ScaledAreaCalculator), + quote!( + #[use_provider(InnerA: AreaCalculator, InnerB: AreaCalculator)] + impl AreaCalculator { + fn area(&self) -> f64 { + InnerA::area(self) + InnerB::area(self) + } + } + ), + ) + }); +} + +#[test] +fn rejects_comma_separated_pairs_on_fn() { + assert_macro_rejects("use_provider with a comma-separated pair list on a fn", || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_provider(InnerA: AreaCalculator, InnerB: AreaCalculator)] + pub fn total_area(&self) -> f64 { + todo!() + } + ), + ) + }); +} + +#[test] +fn accepts_plus_separated_bounds_on_one_provider() { + // The counterpart the rejection above is easy to confuse with: `+` continues + // *one* provider's bound list and is accepted, so the comma is what fails + // rather than the presence of two bounds. + let result = cgp_macro_lib::cgp_impl( + quote!(new BothCalculator), + quote!( + #[use_provider(Inner: AreaCalculator + PerimeterCalculator)] + impl AreaCalculator { + fn area(&self) -> f64 { + Inner::area(self) + } + } + ), + ); + + assert!( + result.is_ok(), + "expected `+`-separated bounds on one provider to be accepted", + ); +} diff --git a/crates/tests/cgp-tests/tests/basic_delegation/mod.rs b/crates/tests/cgp-tests/tests/basic_delegation/mod.rs index 750b88de..a0a4d6a0 100644 --- a/crates/tests/cgp-tests/tests/basic_delegation/mod.rs +++ b/crates/tests/cgp-tests/tests/basic_delegation/mod.rs @@ -19,6 +19,7 @@ pub mod impl_self; pub mod owned_receiver; pub mod self_in_macro; pub mod self_in_nested_item; +pub mod self_local_assoc_type; // `delegate_components!` shape variants (compile-time checks only). pub mod delegate_generic_nested_value; diff --git a/crates/tests/cgp-tests/tests/basic_delegation/self_local_assoc_type.rs b/crates/tests/cgp-tests/tests/basic_delegation/self_local_assoc_type.rs new file mode 100644 index 00000000..8561e5f5 --- /dev/null +++ b/crates/tests/cgp-tests/tests/basic_delegation/self_local_assoc_type.rs @@ -0,0 +1,69 @@ +//! `Self::Assoc` inside a `#[cgp_impl]` body survives the rewrite when the block +//! declares `Assoc` itself. +//! +//! `#[cgp_impl]` rewrites `Self` into the context, which would break a component +//! whose provider supplies an associated type: `Self::Output` in a signature or a +//! body means the *provider's* own `type Output`, not something the context has. +//! The rewrite therefore collects the associated types the block declares and +//! skips any `Self::` path whose first segment names one of them, so the emitted +//! provider impl keeps `Self::Output` — where `Self` is now the provider struct +//! that declares it. Every other `Self` in the same block, including the `Self` +//! of an inherited abstract type, is still rewritten to the context. +//! +//! An associated *const* is deliberately not covered by the skip, since only +//! `ImplItem::Type` items are collected; `#[cgp_impl]`'s Known issues records +//! that asymmetry and the `>::CONST` form that works +//! around it. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/cgp_impl.md (Behavior and corner cases) +//! and cgp-knowledge-base/cgp/reference/macros/cgp_impl.md. + +use cgp::prelude::*; + +#[cgp_auto_getter] +pub trait HasName { + fn name(&self) -> &str; +} + +#[cgp_component(Labeller)] +pub trait CanLabel { + type Label; + + fn label(&self) -> Self::Label; +} + +#[cgp_impl(new LabelWithName)] +impl Labeller +where + Self: HasName, +{ + // The provider decides the associated type. Both the `Self::Label` in the + // return position and the one in the body name *this* item, so neither may be + // rewritten to the context — while `self.name()` must be. + type Label = String; + + fn label(&self) -> Self::Label { + let label: Self::Label = format!("<{}>", self.name()); + label + } +} + +#[derive(HasField)] +pub struct Person { + pub name: String, +} + +delegate_components! { + Person { + LabellerComponent: LabelWithName, + } +} + +#[test] +fn test_self_local_assoc_type() { + let person = Person { + name: "World".to_owned(), + }; + + assert_eq!(person.label(), ""); +} diff --git a/crates/tests/cgp-tests/tests/higher_order_providers/lifetime_inner_provider.rs b/crates/tests/cgp-tests/tests/higher_order_providers/lifetime_inner_provider.rs new file mode 100644 index 00000000..781645d5 --- /dev/null +++ b/crates/tests/cgp-tests/tests/higher_order_providers/lifetime_inner_provider.rs @@ -0,0 +1,87 @@ +//! A higher-order provider for a component that carries a **lifetime**, where the +//! inner-provider bound gets no `IsProviderFor` counterpart. +//! +//! Deriving a provider's `IsProviderFor` impl normally augments a bound naming the +//! same provider trait — the inner-provider bound of a higher-order provider — with +//! its `IsProviderFor` counterpart, so a dependency missing inside the inner +//! provider still propagates outward. That rewrite reads the provider trait's +//! *first* generic argument as the context, and Rust requires lifetime arguments to +//! come first, so on a lifetime-carrying component the first argument is a lifetime +//! and the rewrite finds no context to build the counterpart from. It then leaves +//! the bound alone rather than emitting something wrong. +//! +//! The snapshot pins that: the outer `IsProviderFor` impl is produced correctly, +//! with the lifetime lifted into `Life<'a>` in the params tuple, while the +//! `Inner: ReferenceGetter<'a, Context>` bound is copied verbatim with no +//! `IsProviderFor)>` beside it — +//! compare `use_provider_impl`, where the component has no lifetime and the +//! counterpart *is* added. The consequence is a weaker diagnostic rather than +//! broken code, and it is recorded under Known issues in the reference. +//! +//! See cgp-knowledge-base/cgp/reference/macros/cgp_provider.md (Known issues) and +//! cgp-knowledge-base/cgp/reference/types/life.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_cgp_new_provider; + +#[cgp_component(ReferenceGetter)] +pub trait HasReference<'a> { + fn get_reference(&self) -> &'a u32; +} + +snapshot_cgp_new_provider! { + #[cgp_new_provider] + impl<'a, Context, Inner> ReferenceGetter<'a, Context> for ForwardReference + where + Inner: ReferenceGetter<'a, Context>, + { + fn get_reference(context: &Context) -> &'a u32 { + Inner::get_reference(context) + } + } + + expand_forward_reference(output) { + insta::assert_snapshot!(output, @" + impl<'a, Context, Inner> ReferenceGetter<'a, Context> for ForwardReference + where + Inner: ReferenceGetter<'a, Context>, + { + fn get_reference(context: &Context) -> &'a u32 { + Inner::get_reference(context) + } + } + impl<'a, Context, Inner> IsProviderFor)> + for ForwardReference + where + Inner: ReferenceGetter<'a, Context>, + {} + pub struct ForwardReference(pub ::core::marker::PhantomData); + ") + } +} + +pub struct App<'a> { + pub value: &'a u32, +} + +#[cgp_impl(new GetReference)] +impl<'a> ReferenceGetter<'a> for App<'a> { + fn get_reference(&self) -> &'a u32 { + self.value + } +} + +delegate_components! { + <'a> App<'a> { + ReferenceGetterComponent: + ForwardReference, + } +} + +#[test] +fn test_lifetime_inner_provider() { + let value = 42; + let app = App { value: &value }; + + assert_eq!(app.get_reference(), &42); +} diff --git a/crates/tests/cgp-tests/tests/higher_order_providers/mod.rs b/crates/tests/cgp-tests/tests/higher_order_providers/mod.rs index c500454b..d97107d5 100644 --- a/crates/tests/cgp-tests/tests/higher_order_providers/mod.rs +++ b/crates/tests/cgp-tests/tests/higher_order_providers/mod.rs @@ -7,3 +7,7 @@ pub mod use_provider_impl; // The scaling pattern end-to-end: an outer calculator wraps an inner one. pub mod rectangle_or_circle; pub mod scaled_area; + +// The inner-provider bound of a lifetime-carrying component, which gets no +// `IsProviderFor` counterpart. +pub mod lifetime_inner_provider; diff --git a/crates/tests/cgp-tests/tests/implicit_arguments/cgp_fn_mref.rs b/crates/tests/cgp-tests/tests/implicit_arguments/cgp_fn_mref.rs new file mode 100644 index 00000000..0e0dd774 --- /dev/null +++ b/crates/tests/cgp-tests/tests/implicit_arguments/cgp_fn_mref.rs @@ -0,0 +1,47 @@ +//! An `#[implicit]` argument typed as `MRef<'_, T>`: the owned-or-borrowed form. +//! +//! `#[implicit]` picks its access mode from the argument's type, and `MRef<'a, T>` +//! is the one reference-shaped mode with no mutable mirror. It requires a plain +//! `T` field and wraps the borrow as `MRef::Ref(..)`, so the body receives a value +//! that may be either owned or borrowed without the context having to commit to +//! one — and, unlike a `&mut` argument, its access never depends on the receiver, +//! so it reads through `HasField` even under `&mut self`. +//! +//! The second function pins that receiver independence. The shape-directed parse +//! — `MRef` is recognized only as a single-segment path carrying exactly a +//! lifetime and a type — is not pinned here, because every way of writing it +//! differently (nested inside another type, or reached through a qualified path) +//! falls into the owned-and-cloned mode, and `MRef` is not `Clone`, so such a case +//! cannot compile at all. +//! +//! See cgp-knowledge-base/cgp/reference/attributes/implicit.md and +//! cgp-knowledge-base/cgp/reference/types/mref.md. + +use cgp::prelude::*; + +#[cgp_fn] +pub fn borrowed_name(&self, #[implicit] name: MRef<'_, String>) -> String { + name.as_ref().to_uppercase() +} + +#[cgp_fn] +pub fn borrowed_name_mut_self(&mut self, #[implicit] name: MRef<'_, String>) -> usize { + // A `&mut self` receiver does not make an `MRef` argument a mutable read, so + // this still resolves through `HasField` rather than `HasFieldMut`. + name.as_ref().len() +} + +#[derive(HasField)] +pub struct App { + pub name: String, +} + +#[test] +fn test_mref_implicit_argument() { + let mut app = App { + name: "world".to_owned(), + }; + + assert_eq!(app.borrowed_name(), "WORLD"); + assert_eq!(app.borrowed_name_mut_self(), 5); +} diff --git a/crates/tests/cgp-tests/tests/implicit_arguments/mod.rs b/crates/tests/cgp-tests/tests/implicit_arguments/mod.rs index f0904f0d..f5de7dc0 100644 --- a/crates/tests/cgp-tests/tests/implicit_arguments/mod.rs +++ b/crates/tests/cgp-tests/tests/implicit_arguments/mod.rs @@ -5,6 +5,7 @@ pub mod cgp_fn_calling_fn; pub mod cgp_fn_custom_trait_name; pub mod cgp_fn_greet; +pub mod cgp_fn_mref; pub mod cgp_fn_multi_and_use_type; pub mod cgp_fn_mut_slice; pub mod cgp_fn_mutable; From edee2309f8ecce65afdd1e0f6bc4cd3846c6dbfa Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sun, 2 Aug 2026 21:52:37 +0200 Subject: [PATCH 08/10] Pin the derive slices, and record a reserved-name expansion defect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the missing coverage for the extensible-data derives, and capture the first invalid-expansion case. Three building-block derives had no test anywhere — `#[derive(BuildField)]`, `#[derive(ExtractField)]`, and `#[derive(CgpRecord)]` — even though two of them are the form the concept documentation shows. Give the first two a snapshot macro of their own, alongside one for `#[derive(FromVariant)]`, so that each slice's defining claim is checkable rather than asserted: the snapshot shows the derive emitting its own items and *not* the neighbouring ones. `CgpRecord` and `CgpVariant` get no macro, since they emit what `CgpData` emits on the same shape and a snapshot would only duplicate one. Add seven test files covering those three derives, the four enum variant shapes `#[derive(HasFields)]` accepts and no other derive does, the unit struct, and the fieldless record. The variant-shape file is the substantive one: `HasFields` nests each variant's own fields as a product, so a unit variant becomes `Nil`, a newtype variant passes its payload through, and the two multi-field shapes become `Index`- and `Symbol!`-keyed products — none of which was tested or documented. Open the `invalid_expansion` target with the case it was reserved for. The variant derives name their own associated types through an unqualified `Self::…` path, so seven variant names cannot be used: `Fields` and `FieldsRef` break `HasFields`, `Value` breaks `FromVariant`, and `Value`, `Remainder`, `Extractor`, `ExtractorRef`, and `ExtractorMut` break `ExtractField`. The fix is a fully qualified projection in the codegen; until then the emitted paths are pinned as string snapshots so the test compiles even though the code it describes would not. How readable the resulting error is depends on the derive, which is the part worth recording: the representation and constructor impls target the user's enum, so a note points at the real variant, while the extractor's target the generated companions and point back at the derive, naming nothing. The three `parser_rejections` files carry `cargo fmt` reflowing only; their content is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018n2krDG9WF1HD9aA1aMASX --- Cargo.lock | 1 + .../src/entrypoints/mod.rs | 6 + .../snapshot_derive_build_field.rs | 25 ++ .../snapshot_derive_extract_field.rs | 25 ++ .../snapshot_derive_from_variant.rs | 25 ++ .../cgp-macro-test-util-lib/src/keywords.rs | 6 + crates/macros/cgp-macro-test-util/src/lib.rs | 21 + crates/tests/cgp-macro-tests/Cargo.toml | 1 + .../tests/invalid_expansion/mod.rs | 9 +- .../reserved_variant_names.rs | 127 ++++++ .../tests/invalid_expansion_tests.rs | 4 +- .../delegate_and_check_components.rs | 32 +- .../tests/parser_rejections/mod.rs | 2 +- .../tests/parser_rejections/use_provider.rs | 25 +- .../extensible_records/build_field_derive.rs | 367 ++++++++++++++++ .../extensible_records/cgp_record_derive.rs | 71 ++++ .../cgp-tests/tests/extensible_records/mod.rs | 8 + .../tests/extensible_records/record_empty.rs | 92 ++++ .../extensible_records/struct_unit_field.rs | 72 ++++ .../extract_field_derive.rs | 361 ++++++++++++++++ .../from_variant_derive.rs | 142 +++++++ .../has_fields_enum_shapes.rs | 402 ++++++++++++++++++ .../tests/extensible_variants/mod.rs | 12 +- 23 files changed, 1798 insertions(+), 38 deletions(-) create mode 100644 crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_build_field.rs create mode 100644 crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_extract_field.rs create mode 100644 crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_from_variant.rs create mode 100644 crates/tests/cgp-macro-tests/tests/invalid_expansion/reserved_variant_names.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_records/build_field_derive.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_records/cgp_record_derive.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_records/record_empty.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_records/struct_unit_field.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_variants/extract_field_derive.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_variants/from_variant_derive.rs create mode 100644 crates/tests/cgp-tests/tests/extensible_variants/has_fields_enum_shapes.rs diff --git a/Cargo.lock b/Cargo.lock index 6df59d02..74f19690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "cgp-macro-core", "cgp-macro-lib", "cgp-macro-test-util", + "cgp-macro-test-util-lib", "insta", "proc-macro2", "quote", diff --git a/crates/macros/cgp-macro-test-util-lib/src/entrypoints/mod.rs b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/mod.rs index ac64fa9b..aef0db18 100644 --- a/crates/macros/cgp-macro-test-util-lib/src/entrypoints/mod.rs +++ b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/mod.rs @@ -11,7 +11,10 @@ mod snapshot_cgp_type; mod snapshot_check_components; mod snapshot_delegate_and_check_components; mod snapshot_delegate_components; +mod snapshot_derive_build_field; mod snapshot_derive_cgp_data; +mod snapshot_derive_extract_field; +mod snapshot_derive_from_variant; mod snapshot_derive_has_field; mod snapshot_derive_has_fields; @@ -28,6 +31,9 @@ pub use snapshot_cgp_type::*; pub use snapshot_check_components::*; pub use snapshot_delegate_and_check_components::*; pub use snapshot_delegate_components::*; +pub use snapshot_derive_build_field::*; pub use snapshot_derive_cgp_data::*; +pub use snapshot_derive_extract_field::*; +pub use snapshot_derive_from_variant::*; pub use snapshot_derive_has_field::*; pub use snapshot_derive_has_fields::*; diff --git a/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_build_field.rs b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_build_field.rs new file mode 100644 index 00000000..bdf25675 --- /dev/null +++ b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_build_field.rs @@ -0,0 +1,25 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ItemStruct, parse2}; + +use crate::keywords::BuildField; +use crate::types::DeriveMacroSnapshot; + +pub fn snapshot_derive_build_field(body: TokenStream) -> syn::Result { + let item: DeriveMacroSnapshot = parse2(body)?; + + let body = &item.body; + + let output = cgp_macro_lib::derive_build_field(quote! { + #[derive(BuildField)] + #body + })?; + + let wrapped = item.snapshot.wrap_output(output)?; + + Ok(quote! { + #body + + #wrapped + }) +} diff --git a/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_extract_field.rs b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_extract_field.rs new file mode 100644 index 00000000..df263ed6 --- /dev/null +++ b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_extract_field.rs @@ -0,0 +1,25 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ItemEnum, parse2}; + +use crate::keywords::ExtractField; +use crate::types::DeriveMacroSnapshot; + +pub fn snapshot_derive_extract_field(body: TokenStream) -> syn::Result { + let item: DeriveMacroSnapshot = parse2(body)?; + + let body = &item.body; + + let output = cgp_macro_lib::derive_extract_field(quote! { + #[derive(ExtractField)] + #body + })?; + + let wrapped = item.snapshot.wrap_output(output)?; + + Ok(quote! { + #body + + #wrapped + }) +} diff --git a/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_from_variant.rs b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_from_variant.rs new file mode 100644 index 00000000..aab6ef9a --- /dev/null +++ b/crates/macros/cgp-macro-test-util-lib/src/entrypoints/snapshot_derive_from_variant.rs @@ -0,0 +1,25 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ItemEnum, parse2}; + +use crate::keywords::FromVariant; +use crate::types::DeriveMacroSnapshot; + +pub fn snapshot_derive_from_variant(body: TokenStream) -> syn::Result { + let item: DeriveMacroSnapshot = parse2(body)?; + + let body = &item.body; + + let output = cgp_macro_lib::derive_from_variant(quote! { + #[derive(FromVariant)] + #body + })?; + + let wrapped = item.snapshot.wrap_output(output)?; + + Ok(quote! { + #body + + #wrapped + }) +} diff --git a/crates/macros/cgp-macro-test-util-lib/src/keywords.rs b/crates/macros/cgp-macro-test-util-lib/src/keywords.rs index 6ae0ed20..104f5248 100644 --- a/crates/macros/cgp-macro-test-util-lib/src/keywords.rs +++ b/crates/macros/cgp-macro-test-util-lib/src/keywords.rs @@ -33,3 +33,9 @@ define_keyword!(HasField, "HasField"); define_keyword!(HasFields, "HasFields"); define_keyword!(CgpData, "CgpData"); + +define_keyword!(BuildField, "BuildField"); + +define_keyword!(ExtractField, "ExtractField"); + +define_keyword!(FromVariant, "FromVariant"); diff --git a/crates/macros/cgp-macro-test-util/src/lib.rs b/crates/macros/cgp-macro-test-util/src/lib.rs index 99dc5b79..6d6c2510 100644 --- a/crates/macros/cgp-macro-test-util/src/lib.rs +++ b/crates/macros/cgp-macro-test-util/src/lib.rs @@ -112,3 +112,24 @@ pub fn snapshot_derive_cgp_data(body: TokenStream) -> TokenStream { .unwrap_or_else(syn::Error::into_compile_error) .into() } + +#[proc_macro] +pub fn snapshot_derive_build_field(body: TokenStream) -> TokenStream { + entrypoints::snapshot_derive_build_field(body.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +#[proc_macro] +pub fn snapshot_derive_extract_field(body: TokenStream) -> TokenStream { + entrypoints::snapshot_derive_extract_field(body.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +#[proc_macro] +pub fn snapshot_derive_from_variant(body: TokenStream) -> TokenStream { + entrypoints::snapshot_derive_from_variant(body.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/crates/tests/cgp-macro-tests/Cargo.toml b/crates/tests/cgp-macro-tests/Cargo.toml index 01baee1b..3e067545 100644 --- a/crates/tests/cgp-macro-tests/Cargo.toml +++ b/crates/tests/cgp-macro-tests/Cargo.toml @@ -18,6 +18,7 @@ insta = { version = "1.48.0" } [dev-dependencies] cgp-macro-core = { workspace = true } cgp-macro-lib = { workspace = true } +cgp-macro-test-util-lib = { workspace = true } syn = { version = "2.0.95" } quote = { version = "1.0.38" } proc-macro2 = { version = "1.0.92" } diff --git a/crates/tests/cgp-macro-tests/tests/invalid_expansion/mod.rs b/crates/tests/cgp-macro-tests/tests/invalid_expansion/mod.rs index 28b00919..882bd535 100644 --- a/crates/tests/cgp-macro-tests/tests/invalid_expansion/mod.rs +++ b/crates/tests/cgp-macro-tests/tests/invalid_expansion/mod.rs @@ -1,7 +1,8 @@ //! Failure cases where a CGP macro emits invalid or incorrect Rust. //! //! See the entrypoint `invalid_expansion_tests.rs` for the pattern to follow when -//! adding a case. This module is intentionally empty until the first genuine -//! invalid-expansion case is captured; keeping the target in place means the -//! harness is ready and a future agent only has to add a `pub mod ;` line -//! and the snapshot file. +//! adding a case. + +// The variant derives name their own associated types through `Self::…`, so a variant whose name +// collides with one of them makes the emitted path ambiguous and the expansion does not compile. +pub mod reserved_variant_names; diff --git a/crates/tests/cgp-macro-tests/tests/invalid_expansion/reserved_variant_names.rs b/crates/tests/cgp-macro-tests/tests/invalid_expansion/reserved_variant_names.rs new file mode 100644 index 00000000..61f4b68d --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/invalid_expansion/reserved_variant_names.rs @@ -0,0 +1,127 @@ +//! The variant derives emit `Self::` to name their own associated types, so a variant +//! whose name collides with one of those makes the generated path ambiguous and the expansion does +//! not compile. +//! +//! **Why the output is wrong.** `#[derive(FromVariant)]` writes the payload's type as `Self::Value`, +//! and `#[derive(ExtractField)]` writes `Self::Value`, `Self::Remainder`, `Self::Extractor`, +//! `Self::ExtractorRef`, and `Self::ExtractorMut`. Inside an impl for an enum, `Self::Value` can +//! resolve to either the trait's associated type or a *variant* of that name, so an enum carrying a +//! variant called `Value` makes the path ambiguous and `rustc` reports +//! `error: ambiguous associated item`, headlined at the `#[derive(...)]` attribute. +//! +//! How readable that error is depends on the derive. `#[derive(HasFields)]` and +//! `#[derive(FromVariant)]` write their impls `for` the user's enum, so the colliding variant keeps its +//! own span and a note points straight at it. `#[derive(ExtractField)]` writes its impls for the +//! generated `__Partial…` companions, whose variant identifiers the codegen rebuilds — so both notes +//! land on the derive attribute and nothing in the output names the variant to rename. That is the case +//! worth guarding against. `#[derive(HasFields)]` also reserves `FieldsRef`, so an enum deriving the +//! whole family has seven names it cannot use. +//! +//! **What the correct output should be.** The codegen should write each associated type as a fully +//! qualified projection — `>::Value` rather than `Self::Value` — which is +//! unambiguous whatever the enum's variants are named. That is the same hygiene discipline the rest of +//! the suite follows for CGP's own paths, applied to the traits these derives implement. +//! +//! The snapshots below capture the emitted expansion as a string, so this test compiles even though +//! the code it describes would not. Only the offending lines are pinned; the surrounding items are the +//! ordinary output the reference documents already describe. +//! +//! Recorded in cgp-knowledge-base/cgp/reference/derives/derive_from_variant.md and +//! derive_extract_field.md, under `## Known issues`. + +use cgp_macro_test_util_lib::functions::pretty_format; +use quote::quote; + +/// `#[derive(FromVariant)]` on an enum with a variant named `Value`. +/// +/// The emitted `value: Self::Value` parameter is the ambiguous path: `Self::Value` could be the +/// `FromVariant::Value` associated type or the `Value` variant. +#[test] +fn test_from_variant_reserves_value() { + let output = cgp_macro_lib::derive_from_variant(quote! { + #[derive(FromVariant)] + pub enum Tagged { + Value(u32), + } + }) + .unwrap(); + + let formatted = pretty_format(output).unwrap(); + + assert!( + formatted.contains("value: Self::Value"), + "expected the ambiguous `Self::Value` parameter, got:\n{formatted}" + ); + + insta::assert_snapshot!(formatted, @r" + impl FromVariant< + Symbol<5, Chars<'V', Chars<'a', Chars<'l', Chars<'u', Chars<'e', Nil>>>>>>, + > for Tagged { + type Value = u32; + fn from_variant( + _tag: ::core::marker::PhantomData< + Symbol<5, Chars<'V', Chars<'a', Chars<'l', Chars<'u', Chars<'e', Nil>>>>>>, + >, + value: Self::Value, + ) -> Self { + Self::Value(value) + } + } + "); +} + +/// `#[derive(ExtractField)]` on an enum with a variant named `Remainder`. +/// +/// `Self::Remainder` in the `extract_field` return type is the ambiguous path here. The same enum +/// would also collide on `Value`, `Extractor`, `ExtractorRef`, and `ExtractorMut`. +#[test] +fn test_extract_field_reserves_remainder() { + let output = cgp_macro_lib::derive_extract_field(quote! { + #[derive(ExtractField)] + pub enum Tagged { + Remainder(u32), + } + }) + .unwrap(); + + let formatted = pretty_format(output).unwrap(); + + assert!( + formatted.contains("Result"), + "expected the ambiguous `Self::Remainder` return type, got:\n{formatted}" + ); + + // `Self::Extractor` appears in the owned accessor for the same reason. + assert!( + formatted.contains("-> Self::Extractor"), + "expected the ambiguous `Self::Extractor` return type, got:\n{formatted}" + ); +} + +/// `#[derive(HasFields)]` on an enum with a variant named `Fields`. +/// +/// `Self::Fields` in the `from_fields` parameter and `Self::FieldsRef` in the borrowed accessor are +/// the ambiguous paths. This is the same defect in the representation slice, which is why an enum +/// deriving the whole family has seven reserved names rather than five. +#[test] +fn test_has_fields_reserves_fields() { + let output = cgp_macro_lib::derive_has_fields(quote! { + #[derive(HasFields)] + pub enum Tagged { + Fields(u32), + } + }) + .unwrap(); + + let formatted = pretty_format(output).unwrap(); + + assert!( + formatted.contains("rest: Self::Fields"), + "expected the ambiguous `Self::Fields` parameter, got:\n{formatted}" + ); + + assert!( + formatted.contains("Self::FieldsRef<'__a>"), + "expected the ambiguous `Self::FieldsRef` projection, got:\n{formatted}" + ); +} diff --git a/crates/tests/cgp-macro-tests/tests/invalid_expansion_tests.rs b/crates/tests/cgp-macro-tests/tests/invalid_expansion_tests.rs index 1f9570d8..ac548d1f 100644 --- a/crates/tests/cgp-macro-tests/tests/invalid_expansion_tests.rs +++ b/crates/tests/cgp-macro-tests/tests/invalid_expansion_tests.rs @@ -15,7 +15,9 @@ //! 3. record the limitation in the owning reference document's `## Known issues` //! section (per cgp-knowledge-base/cgp/AGENTS.md), and link from the test to that document. //! -//! No cases are enumerated yet; see crates/tests/AGENTS.md ("Migration status"). +//! One case is captured so far: `reserved_variant_names`, where the variant derives name their +//! own associated types through `Self::…` and so cannot be applied to an enum with a variant of +//! a colliding name. #![allow(dead_code)] pub mod invalid_expansion; diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs index c5f0c69d..961b82a8 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/delegate_and_check_components.rs @@ -38,13 +38,11 @@ fn rejects_two_check_attributes_on_one_key() { assert_macro_rejects( "delegate_and_check_components with two check attributes on one key", || { - cgp_macro_lib::delegate_and_check_components(quote!( - MyApp { - #[check_params(Rectangle)] - #[check_params(Circle)] - AreaCalculatorComponent: ShapeProvider, - } - )) + cgp_macro_lib::delegate_and_check_components(quote!(MyApp { + #[check_params(Rectangle)] + #[check_params(Circle)] + AreaCalculatorComponent: ShapeProvider, + })) }, ); } @@ -54,12 +52,10 @@ fn rejects_skip_check_with_arguments() { assert_macro_rejects( "delegate_and_check_components with #[skip_check(..)] taking arguments", || { - cgp_macro_lib::delegate_and_check_components(quote!( - MyApp { - #[skip_check(Rectangle)] - AreaCalculatorComponent: ShapeProvider, - } - )) + cgp_macro_lib::delegate_and_check_components(quote!(MyApp { + #[skip_check(Rectangle)] + AreaCalculatorComponent: ShapeProvider, + })) }, ); } @@ -69,12 +65,10 @@ fn rejects_unknown_entry_attribute() { assert_macro_rejects( "delegate_and_check_components with an unrecognized entry attribute", || { - cgp_macro_lib::delegate_and_check_components(quote!( - MyApp { - #[check_provider(RectangleArea)] - AreaCalculatorComponent: ShapeProvider, - } - )) + cgp_macro_lib::delegate_and_check_components(quote!(MyApp { + #[check_provider(RectangleArea)] + AreaCalculatorComponent: ShapeProvider, + })) }, ); } diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs index 5d8a3808..b61d00cd 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs @@ -28,10 +28,10 @@ pub mod cgp_impl; pub mod cgp_namespace; pub mod cgp_provider; pub mod check_components; +pub mod delegate_and_check_components; pub mod delegate_components; pub mod derive_cgp_data; pub mod derive_from_variant; -pub mod delegate_and_check_components; pub mod getters; pub mod use_provider; pub mod use_type; diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs index 336fee9b..232b94c6 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/use_provider.rs @@ -38,17 +38,20 @@ fn rejects_comma_separated_pairs_on_impl() { #[test] fn rejects_comma_separated_pairs_on_fn() { - assert_macro_rejects("use_provider with a comma-separated pair list on a fn", || { - cgp_macro_lib::cgp_fn( - quote!(), - quote!( - #[use_provider(InnerA: AreaCalculator, InnerB: AreaCalculator)] - pub fn total_area(&self) -> f64 { - todo!() - } - ), - ) - }); + assert_macro_rejects( + "use_provider with a comma-separated pair list on a fn", + || { + cgp_macro_lib::cgp_fn( + quote!(), + quote!( + #[use_provider(InnerA: AreaCalculator, InnerB: AreaCalculator)] + pub fn total_area(&self) -> f64 { + todo!() + } + ), + ) + }, + ); } #[test] diff --git a/crates/tests/cgp-tests/tests/extensible_records/build_field_derive.rs b/crates/tests/cgp-tests/tests/extensible_records/build_field_derive.rs new file mode 100644 index 00000000..97a5c915 --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_records/build_field_derive.rs @@ -0,0 +1,367 @@ +//! `#[derive(BuildField)]` on its own: the builder slice of the record +//! machinery, and nothing else. +//! +//! The other record derives are supersets of this one, so it is easy to miss +//! what it emits by itself. The snapshot is the answer, and its shape is the +//! point: a `__Partial…` companion struct, the `HasBuilder`/`IntoBuilder` entry +//! points, `PartialData`, the all-present `FinalizeBuild`, a per-field +//! `UpdateField`, and a per-field `HasField` on the *partial* type — with no +//! `HasField` getters on the original struct and no `HasFields` representation +//! impls, which `#[derive(HasField)]` and `#[derive(HasFields)]` supply. +//! +//! Two capabilities come from field-crate blanket impls over the generated +//! `UpdateField`, rather than from the derive, and this file exercises both in +//! opposite directions: `BuildField` sets an absent field (`IsNothing` to +//! `IsPresent`) and `TakeField` removes a present one (`IsPresent` back to +//! `IsNothing`). Because presence lives in the partial type's parameters, a +//! premature `finalize_build` is a compile error rather than a runtime failure. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_build_field.md and +//! cgp-knowledge-base/cgp/reference/traits/has_builder.md. + +use core::marker::PhantomData; + +use cgp::core::field::traits::TakeField; +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_derive_build_field; + +snapshot_derive_build_field! { + #[derive(BuildField)] + #[derive(Debug, Eq, PartialEq)] + pub struct Person { + pub first_name: String, + pub last_name: String, + } + + expand_person(output) { + insta::assert_snapshot!(output, @" + pub struct __PartialPerson<__F0__: MapType, __F1__: MapType> { + pub first_name: <__F0__ as MapType>::Map, + pub last_name: <__F1__ as MapType>::Map, + } + impl HasBuilder for Person { + type Builder = __PartialPerson; + fn builder() -> Self::Builder { + __PartialPerson { + first_name: (), + last_name: (), + } + } + } + impl IntoBuilder for Person { + type Builder = __PartialPerson; + fn into_builder(self) -> Self::Builder { + __PartialPerson { + first_name: self.first_name, + last_name: self.last_name, + } + } + } + impl<__F0__: MapType, __F1__: MapType> PartialData for __PartialPerson<__F0__, __F1__> { + type Target = Person; + } + impl FinalizeBuild for __PartialPerson { + fn finalize_build(self) -> Self::Target { + Person { + first_name: self.first_name, + last_name: self.last_name, + } + } + } + impl< + __M1__: MapType, + __M2__: MapType, + __F1__: MapType, + > UpdateField< + Symbol< + 10, + Chars< + 'f', + Chars< + 'i', + Chars< + 'r', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + __M2__, + > for __PartialPerson<__M1__, __F1__> { + type Value = String; + type Mapper = __M1__; + type Output = __PartialPerson<__M2__, __F1__>; + fn update_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 10, + Chars< + 'f', + Chars< + 'i', + Chars< + 'r', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + >, + value: __M2__::Map, + ) -> (__M1__::Map, Self::Output) { + ( + self.first_name, + __PartialPerson { + first_name: value, + last_name: self.last_name, + }, + ) + } + } + impl< + __F0__: MapType, + __M1__: MapType, + __M2__: MapType, + > UpdateField< + Symbol< + 9, + Chars< + 'l', + Chars< + 'a', + Chars< + 's', + Chars< + 't', + Chars<'_', Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>>, + >, + >, + >, + >, + >, + __M2__, + > for __PartialPerson<__F0__, __M1__> { + type Value = String; + type Mapper = __M1__; + type Output = __PartialPerson<__F0__, __M2__>; + fn update_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 9, + Chars< + 'l', + Chars< + 'a', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + value: __M2__::Map, + ) -> (__M1__::Map, Self::Output) { + ( + self.last_name, + __PartialPerson { + first_name: self.first_name, + last_name: value, + }, + ) + } + } + impl< + __F1__: MapType, + > HasField< + Symbol< + 10, + Chars< + 'f', + Chars< + 'i', + Chars< + 'r', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + > for __PartialPerson { + type Value = String; + fn get_field( + &self, + tag: ::core::marker::PhantomData< + Symbol< + 10, + Chars< + 'f', + Chars< + 'i', + Chars< + 'r', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + >, + ) -> &Self::Value { + &self.first_name + } + } + impl< + __F0__: MapType, + > HasField< + Symbol< + 9, + Chars< + 'l', + Chars< + 'a', + Chars< + 's', + Chars< + 't', + Chars<'_', Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>>, + >, + >, + >, + >, + >, + > for __PartialPerson<__F0__, IsPresent> { + type Value = String; + fn get_field( + &self, + tag: ::core::marker::PhantomData< + Symbol< + 9, + Chars< + 'l', + Chars< + 'a', + Chars< + 's', + Chars< + 't', + Chars< + '_', + Chars<'n', Chars<'a', Chars<'m', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + ) -> &Self::Value { + &self.last_name + } + } + ") + } +} + +#[test] +fn test_build_field_by_field() { + let person: Person = Person::builder() + .build_field(PhantomData::, "Alice".to_owned()) + .build_field(PhantomData::, "Anderson".to_owned()) + .finalize_build(); + + assert_eq!( + person, + Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + } + ); +} + +#[test] +fn test_read_a_field_back_out_of_a_partial_value() { + // The per-field `HasField` impl on the partial type is in scope only once + // that field is present, so a field can be read back mid-build. + let partial = + Person::builder().build_field(PhantomData::, "Alice".to_owned()); + + assert_eq!( + partial.get_field(PhantomData::), + "Alice" + ); +} + +#[test] +fn test_round_trip_through_into_builder_and_take_field() { + let person1 = Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + }; + + // `IntoBuilder` goes the other way from `HasBuilder`: an existing value + // becomes an all-present partial value. + let builder = person1.into_builder(); + + // `TakeField` is `BuildField` reversed — it pulls a present field out and + // hands back the value alongside a partial value with that field absent. + let (first_name, remainder) = builder.take_field(PhantomData::); + assert_eq!(first_name, "Alice"); + + // Putting it back restores the all-present configuration, which is the only + // one `finalize_build` is implemented for. + let person2 = remainder + .build_field(PhantomData::, first_name) + .finalize_build(); + + assert_eq!( + person2, + Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + } + ); +} diff --git a/crates/tests/cgp-tests/tests/extensible_records/cgp_record_derive.rs b/crates/tests/cgp-tests/tests/extensible_records/cgp_record_derive.rs new file mode 100644 index 00000000..a11e3e3c --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_records/cgp_record_derive.rs @@ -0,0 +1,71 @@ +//! `#[derive(CgpRecord)]`, the struct-only face of `#[derive(CgpData)]`. +//! +//! `CgpRecord` and `CgpData`-on-a-struct run the same codegen, so this file +//! does not re-snapshot the expansion that `record_derive` already pins. What it +//! does check is that the one derive really delivers all three record slices at +//! once — the per-field getters, the whole-shape field list, and the builder — +//! since that is the reason to reach for it over deriving the three separately. +//! +//! The difference from `CgpData` is at the parser rather than in the output: +//! `CgpRecord` refuses a non-struct item outright, which is what makes it worth +//! writing when the type will always be a struct. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_cgp_record.md. + +use core::marker::PhantomData; + +use cgp::prelude::*; + +#[derive(CgpRecord, Clone, Debug, Eq, PartialEq)] +pub struct Person { + pub first_name: String, + pub last_name: String, +} + +#[test] +fn test_per_field_getters() { + let mut person = Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + }; + + // The `HasField`/`HasFieldMut` slice. + assert_eq!( + person.get_field(PhantomData::), + "Alice" + ); + + *person.get_field_mut(PhantomData::) = "Baker".to_owned(); + assert_eq!(person.last_name, "Baker"); +} + +#[test] +fn test_whole_shape_round_trip() { + // The `HasFields`/`ToFields`/`FromFields` slice. + let person1 = Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + }; + + let fields = person1.clone().to_fields(); + let person2 = Person::from_fields(fields); + + assert_eq!(person1, person2); +} + +#[test] +fn test_incremental_builder() { + // The `BuildField` slice. + let person: Person = Person::builder() + .build_field(PhantomData::, "Alice".to_owned()) + .build_field(PhantomData::, "Anderson".to_owned()) + .finalize_build(); + + assert_eq!( + person, + Person { + first_name: "Alice".to_owned(), + last_name: "Anderson".to_owned(), + } + ); +} diff --git a/crates/tests/cgp-tests/tests/extensible_records/mod.rs b/crates/tests/cgp-tests/tests/extensible_records/mod.rs index a2886f90..871b6b98 100644 --- a/crates/tests/cgp-tests/tests/extensible_records/mod.rs +++ b/crates/tests/cgp-tests/tests/extensible_records/mod.rs @@ -9,9 +9,16 @@ pub mod optional_builder; pub mod person_record; pub mod point_cast; pub mod record_derive; +pub mod record_empty; pub mod record_lifetime; pub mod tuple_record; +// The individual record derives, each on its own: `#[derive(BuildField)]` for +// the builder slice alone, and `#[derive(CgpRecord)]` for the struct-only face +// of `#[derive(CgpData)]`. +pub mod build_field_derive; +pub mod cgp_record_derive; + // Behavioral record building: assembling a record from other records and from // handler pipelines. These reuse `#[derive(CgpData)]` as plain scaffolding — // the derive expansion is already pinned by `record_derive`. @@ -30,3 +37,4 @@ pub mod struct_single_named_field; pub mod struct_single_unnamed_field; pub mod struct_tuple_fields; pub mod struct_two_named_fields; +pub mod struct_unit_field; diff --git a/crates/tests/cgp-tests/tests/extensible_records/record_empty.rs b/crates/tests/cgp-tests/tests/extensible_records/record_empty.rs new file mode 100644 index 00000000..f1ff6dd4 --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_records/record_empty.rs @@ -0,0 +1,92 @@ +//! `#[derive(CgpData)]` on a **fieldless struct**, the degenerate record shape. +//! +//! A struct with no fields is the record-side counterpart of the variantless +//! enum, and like it the expansion degenerates rather than failing: the +//! `__Partial…` companion struct takes no `MapType` parameters at all, so there +//! is exactly one configuration of it and `HasBuilder`, `IntoBuilder` and +//! `FinalizeBuild` all name the same type. `Fields` is the empty product `Nil`, +//! and no per-field `UpdateField`, `HasField`, or `HasFieldMut` impls are +//! emitted because there is no field to key one on. +//! +//! The practical consequence is that `builder()` is already finalizable — the +//! present/absent tracking that makes a premature `finalize_build` a compile +//! error has nothing to track. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_cgp_data.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_derive_cgp_data; + +snapshot_derive_cgp_data! { + #[derive(CgpData)] + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct NoConfig {} + + expand_no_config(output) { + insta::assert_snapshot!(output, @" + impl HasFields for NoConfig { + type Fields = Nil; + } + impl HasFieldsRef for NoConfig { + type FieldsRef<'__a> = Nil where Self: '__a; + } + impl FromFields for NoConfig { + fn from_fields(Nil: Self::Fields) -> Self { + Self {} + } + } + impl ToFields for NoConfig { + fn to_fields(self) -> Self::Fields { + Nil + } + } + impl ToFieldsRef for NoConfig { + fn to_fields_ref<'__a>(&'__a self) -> Self::FieldsRef<'__a> + where + Self: '__a, + { + Nil + } + } + pub struct __PartialNoConfig {} + impl HasBuilder for NoConfig { + type Builder = __PartialNoConfig; + fn builder() -> Self::Builder { + __PartialNoConfig {} + } + } + impl IntoBuilder for NoConfig { + type Builder = __PartialNoConfig; + fn into_builder(self) -> Self::Builder { + __PartialNoConfig {} + } + } + impl PartialData for __PartialNoConfig { + type Target = NoConfig; + } + impl FinalizeBuild for __PartialNoConfig { + fn finalize_build(self) -> Self::Target { + NoConfig {} + } + } + ") + } +} + +#[test] +fn test_empty_builder_finalizes_immediately() { + let config: NoConfig = NoConfig::builder().finalize_build(); + + assert_eq!(config, NoConfig {}); +} + +#[test] +fn test_empty_record_round_trip() { + let config1 = NoConfig {}; + + let fields = config1.clone().to_fields(); + assert_eq!(fields, Nil); + + let config2 = NoConfig::from_fields(fields); + assert_eq!(config1, config2); +} diff --git a/crates/tests/cgp-tests/tests/extensible_records/struct_unit_field.rs b/crates/tests/cgp-tests/tests/extensible_records/struct_unit_field.rs new file mode 100644 index 00000000..512ebafc --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_records/struct_unit_field.rs @@ -0,0 +1,72 @@ +//! The two field derives on a **unit struct**, the degenerate record shape. +//! +//! A unit struct has no fields, and the two derives answer that differently +//! rather than both erroring. `#[derive(HasField)]` emits *nothing at all*, +//! since there is no field to key an accessor on. `#[derive(HasFields)]` still +//! emits all five representation impls, with `Fields` being the empty product +//! `Nil` — so a unit struct is a valid, if trivial, extensible record and +//! round-trips through `to_fields`/`from_fields`. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_has_field.md and +//! cgp-knowledge-base/cgp/reference/derives/derive_has_fields.md. + +use cgp::prelude::*; +use cgp_macro_test_util::{snapshot_derive_has_field, snapshot_derive_has_fields}; + +snapshot_derive_has_field! { + #[derive(HasField)] + pub struct NoFields; + + expand_has_field(output) { + insta::assert_snapshot!(output, @"") + } +} + +snapshot_derive_has_fields! { + #[derive(HasFields)] + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct Unit; + + expand_has_fields(output) { + insta::assert_snapshot!(output, @" + impl HasFields for Unit { + type Fields = Nil; + } + impl HasFieldsRef for Unit { + type FieldsRef<'__a> = Nil where Self: '__a; + } + impl FromFields for Unit { + fn from_fields(Nil: Self::Fields) -> Self { + Self + } + } + impl ToFields for Unit { + fn to_fields(self) -> Self::Fields { + Nil + } + } + impl ToFieldsRef for Unit { + fn to_fields_ref<'__a>(&'__a self) -> Self::FieldsRef<'__a> + where + Self: '__a, + { + Nil + } + } + ") + } +} + +#[test] +fn test_unit_struct_round_trip() { + let unit1 = Unit; + + let fields = unit1.clone().to_fields(); + assert_eq!(fields, Nil); + + let fields_ref = unit1.to_fields_ref(); + assert_eq!(fields_ref, Nil); + + let unit2 = Unit::from_fields(fields); + assert_eq!(unit1, unit2); +} diff --git a/crates/tests/cgp-tests/tests/extensible_variants/extract_field_derive.rs b/crates/tests/cgp-tests/tests/extensible_variants/extract_field_derive.rs new file mode 100644 index 00000000..b7e985f9 --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_variants/extract_field_derive.rs @@ -0,0 +1,361 @@ +//! `#[derive(ExtractField)]` on its own: the extractor slice of the variant +//! machinery, and nothing else. +//! +//! The snapshot is the point, and what it does *not* contain matters as much as +//! what it does: two `__Partial…` companion enums (one owned, one borrowed), +//! `PartialData` for each, the three `HasExtractor` accessors, an all-`IsVoid` +//! `FinalizeExtract` for each, and a per-variant `ExtractField` — but no +//! `HasFields` representation impls and no `FromVariant` constructors, which +//! `#[derive(HasFields)]` and `#[derive(FromVariant)]` supply. So a value is +//! built here with the enum's ordinary constructor, not generically. +//! +//! The runtime tests walk the chain in both outcomes. Each failed +//! `extract_field` hands back a *remainder* whose type has one more variant +//! marked `IsVoid`, and once every variant has been tried the remainder is +//! uninhabited — which is what lets `finalize_extract_result` close the chain +//! with no wildcard arm. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_extract_field.md and +//! cgp-knowledge-base/cgp/reference/traits/extract_field.md. + +use core::marker::PhantomData; + +use cgp::core::field::traits::FinalizeExtractResult; +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_derive_extract_field; + +#[derive(Debug, Eq, PartialEq)] +pub struct Circle { + pub radius: u32, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Rectangle { + pub width: u32, + pub height: u32, +} + +snapshot_derive_extract_field! { + #[derive(ExtractField)] + #[derive(Debug, Eq, PartialEq)] + pub enum Shape { + Circle(Circle), + Rectangle(Rectangle), + } + + expand_shape(output) { + insta::assert_snapshot!(output, @" + pub enum __PartialShape<__F0__: MapType, __F1__: MapType> { + Circle(<__F0__ as MapType>::Map), + Rectangle(<__F1__ as MapType>::Map), + } + pub enum __PartialRefShape<'__a__, __R__: MapTypeRef, __F0__: MapType, __F1__: MapType> { + Circle(<__F0__ as MapType>::Map<<__R__ as MapTypeRef>::Map<'__a__, Circle>>), + Rectangle(<__F1__ as MapType>::Map<<__R__ as MapTypeRef>::Map<'__a__, Rectangle>>), + } + impl<__F0__: MapType, __F1__: MapType> PartialData for __PartialShape<__F0__, __F1__> { + type Target = Shape; + } + impl<'__a__, __R__: MapTypeRef, __F0__: MapType, __F1__: MapType> PartialData + for __PartialRefShape<'__a__, __R__, __F0__, __F1__> { + type Target = Shape; + } + impl HasExtractor for Shape { + type Extractor = __PartialShape; + fn to_extractor(self) -> Self::Extractor { + match self { + Self::Circle(value) => __PartialShape::Circle(value), + Self::Rectangle(value) => __PartialShape::Rectangle(value), + } + } + fn from_extractor(extractor: Self::Extractor) -> Self { + match extractor { + __PartialShape::Circle(value) => Self::Circle(value), + __PartialShape::Rectangle(value) => Self::Rectangle(value), + } + } + } + impl HasExtractorRef for Shape { + type ExtractorRef<'__a__> = __PartialRefShape<'__a__, IsRef, IsPresent, IsPresent> + where + Self: '__a__; + fn extractor_ref<'__a__>(&'__a__ self) -> Self::ExtractorRef<'__a__> { + match self { + Self::Circle(value) => __PartialRefShape::Circle(value), + Self::Rectangle(value) => __PartialRefShape::Rectangle(value), + } + } + } + impl HasExtractorMut for Shape { + type ExtractorMut<'__a__> = __PartialRefShape<'__a__, IsMut, IsPresent, IsPresent> + where + Self: '__a__; + fn extractor_mut<'__a__>(&'__a__ mut self) -> Self::ExtractorMut<'__a__> { + match self { + Self::Circle(value) => __PartialRefShape::Circle(value), + Self::Rectangle(value) => __PartialRefShape::Rectangle(value), + } + } + } + impl FinalizeExtract for __PartialShape { + fn finalize_extract<__T__>(self) -> __T__ { + match self {} + } + } + impl<'__a__, __R__: MapTypeRef> FinalizeExtract + for __PartialRefShape<'__a__, __R__, IsVoid, IsVoid> { + fn finalize_extract<__T__>(self) -> __T__ { + match self {} + } + } + impl< + __F1__: MapType, + > ExtractField< + Symbol< + 6, + Chars<'C', Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>>, + >, + > for __PartialShape { + type Value = Circle; + type Remainder = __PartialShape; + fn extract_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 6, + Chars< + 'C', + Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + ) -> Result { + match self { + __PartialShape::Circle(value) => Ok(value), + __PartialShape::Rectangle(value) => Err(__PartialShape::Rectangle(value)), + } + } + } + impl< + __F0__: MapType, + > ExtractField< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars<'a', Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + >, + >, + > for __PartialShape<__F0__, IsPresent> { + type Value = Rectangle; + type Remainder = __PartialShape<__F0__, IsVoid>; + fn extract_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + ) -> Result { + match self { + __PartialShape::Circle(value) => Err(__PartialShape::Circle(value)), + __PartialShape::Rectangle(value) => Ok(value), + } + } + } + impl< + '__a__, + __R__: MapTypeRef, + __F1__: MapType, + > ExtractField< + Symbol< + 6, + Chars<'C', Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>>, + >, + > for __PartialRefShape<'__a__, __R__, IsPresent, __F1__> { + type Value = <__R__ as MapTypeRef>::Map<'__a__, Circle>; + type Remainder = __PartialRefShape<'__a__, __R__, IsVoid, __F1__>; + fn extract_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 6, + Chars< + 'C', + Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + ) -> Result { + match self { + __PartialRefShape::Circle(value) => Ok(value), + __PartialRefShape::Rectangle(value) => { + Err(__PartialRefShape::Rectangle(value)) + } + } + } + } + impl< + '__a__, + __R__: MapTypeRef, + __F0__: MapType, + > ExtractField< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars<'a', Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + >, + >, + > for __PartialRefShape<'__a__, __R__, __F0__, IsPresent> { + type Value = <__R__ as MapTypeRef>::Map<'__a__, Rectangle>; + type Remainder = __PartialRefShape<'__a__, __R__, __F0__, IsVoid>; + fn extract_field( + self, + _tag: ::core::marker::PhantomData< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + ) -> Result { + match self { + __PartialRefShape::Circle(value) => Err(__PartialRefShape::Circle(value)), + __PartialRefShape::Rectangle(value) => Ok(value), + } + } + } + ") + } +} + +/// Walk the chain to whichever variant the value holds, proving exhaustiveness +/// without a fallback arm. +fn describe(shape: Shape) -> String { + match shape + .to_extractor() + .extract_field(PhantomData::) + { + Ok(circle) => format!("circle of radius {}", circle.radius), + Err(remainder) => { + // `remainder` now has `Circle` ruled out. After the next extraction + // no variant is left, so the result cannot be an `Err`. + let rectangle = remainder + .extract_field(PhantomData::) + .finalize_extract_result(); + + format!("{}x{} rectangle", rectangle.width, rectangle.height) + } + } +} + +#[test] +fn test_extract_the_first_variant() { + assert_eq!( + describe(Shape::Circle(Circle { radius: 2 })), + "circle of radius 2" + ); +} + +#[test] +fn test_extract_through_a_remainder() { + assert_eq!( + describe(Shape::Rectangle(Rectangle { + width: 3, + height: 4, + })), + "3x4 rectangle" + ); +} + +#[test] +fn test_borrowed_and_mutable_extractors() { + let mut shape = Shape::Circle(Circle { radius: 2 }); + + // `HasExtractorRef` borrows each payload in place. + let radius = shape + .extractor_ref() + .extract_field(PhantomData::) + .map(|circle| circle.radius) + .ok(); + + assert_eq!(radius, Some(2)); + + // `HasExtractorMut` lends each payload mutably. + if let Ok(circle) = shape + .extractor_mut() + .extract_field(PhantomData::) + { + circle.radius = 5; + } + + assert_eq!(shape, Shape::Circle(Circle { radius: 5 })); +} + +#[test] +fn test_round_trip_through_the_owned_extractor() { + let shape1 = Shape::Rectangle(Rectangle { + width: 3, + height: 4, + }); + + let extractor = Shape::to_extractor(shape1); + let shape2 = Shape::from_extractor(extractor); + + assert_eq!( + shape2, + Shape::Rectangle(Rectangle { + width: 3, + height: 4, + }) + ); +} diff --git a/crates/tests/cgp-tests/tests/extensible_variants/from_variant_derive.rs b/crates/tests/cgp-tests/tests/extensible_variants/from_variant_derive.rs new file mode 100644 index 00000000..f6fab96d --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_variants/from_variant_derive.rs @@ -0,0 +1,142 @@ +//! `#[derive(FromVariant)]` on its own: the variant-construction slice, and the +//! simplest derive in the extensible-data family. +//! +//! The snapshot shows the whole of what it emits — one `FromVariant` impl per +//! variant and nothing more. There is no companion type and no presence +//! tracking, because construction needs neither: each impl just wraps a payload +//! in its variant, keyed by the variant name's `Symbol!`. +//! +//! What that buys is a constructor a generic caller can select by tag. The test +//! below stays generic over the tag, so one function can build either variant, +//! which a `Shape::Circle(..)` call site cannot. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_from_variant.md and +//! cgp-knowledge-base/cgp/reference/traits/from_variant.md. + +use core::marker::PhantomData; + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_derive_from_variant; + +#[derive(Debug, Eq, PartialEq)] +pub struct Circle { + pub radius: u32, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Rectangle { + pub width: u32, + pub height: u32, +} + +snapshot_derive_from_variant! { + #[derive(FromVariant)] + #[derive(Debug, Eq, PartialEq)] + pub enum Shape { + Circle(Circle), + Rectangle(Rectangle), + } + + expand_shape(output) { + insta::assert_snapshot!(output, @" + impl FromVariant< + Symbol< + 6, + Chars<'C', Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>>, + >, + > for Shape { + type Value = Circle; + fn from_variant( + _tag: ::core::marker::PhantomData< + Symbol< + 6, + Chars< + 'C', + Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + value: Self::Value, + ) -> Self { + Self::Circle(value) + } + } + impl FromVariant< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars<'a', Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + >, + >, + >, + > for Shape { + type Value = Rectangle; + fn from_variant( + _tag: ::core::marker::PhantomData< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + >, + value: Self::Value, + ) -> Self { + Self::Rectangle(value) + } + } + ") + } +} + +/// Build a `Shape` without naming which variant — the caller picks it with a tag. +fn wrap(tag: PhantomData, value: >::Value) -> Shape +where + Shape: FromVariant, +{ + Shape::from_variant(tag, value) +} + +#[test] +fn test_construct_each_variant_by_tag() { + assert_eq!( + wrap(PhantomData::, Circle { radius: 2 }), + Shape::Circle(Circle { radius: 2 }) + ); + + assert_eq!( + wrap( + PhantomData::, + Rectangle { + width: 3, + height: 4, + } + ), + Shape::Rectangle(Rectangle { + width: 3, + height: 4, + }) + ); +} diff --git a/crates/tests/cgp-tests/tests/extensible_variants/has_fields_enum_shapes.rs b/crates/tests/cgp-tests/tests/extensible_variants/has_fields_enum_shapes.rs new file mode 100644 index 00000000..33366ab3 --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_variants/has_fields_enum_shapes.rs @@ -0,0 +1,402 @@ +//! `#[derive(HasFields)]` on an enum whose variants use **every variant shape**. +//! +//! The variant derives that deconstruct an enum — `#[derive(ExtractField)]` and +//! `#[derive(FromVariant)]`, and therefore `#[derive(CgpVariant)]` and +//! `#[derive(CgpData)]` — require every variant to be a single-unnamed-field +//! tuple variant, because each has to name one payload type. `HasFields` has no +//! such requirement: it only *describes* each variant, so it accepts all four +//! shapes and nests each variant's own fields as a product inside that +//! variant's `Field` entry. +//! +//! The four shapes map as follows, and this file pins each: +//! +//! - a unit variant becomes the empty product `Nil`; +//! - a single-unnamed-field variant becomes the payload type directly, the same +//! newtype special case a one-field tuple struct gets; +//! - a multi-field tuple variant becomes a product keyed by `Index`; +//! - a named-field (struct-style) variant becomes a product keyed by `Symbol!`. +//! +//! See cgp-knowledge-base/cgp/reference/derives/derive_has_fields.md. + +use cgp::prelude::*; +use cgp_macro_test_util::snapshot_derive_has_fields; + +snapshot_derive_has_fields! { + #[derive(HasFields)] + #[derive(Clone, Debug, Eq, PartialEq)] + pub enum Shape { + Empty, + Circle(u32), + Rectangle(u32, u32), + Triangle { base: u32, height: u32 }, + } + + expand_shape(output) { + insta::assert_snapshot!(output, @" + impl HasFields for Shape { + type Fields = Either< + Field< + Symbol<5, Chars<'E', Chars<'m', Chars<'p', Chars<'t', Chars<'y', Nil>>>>>>, + Nil, + >, + Either< + Field< + Symbol< + 6, + Chars< + 'C', + Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + u32, + >, + Either< + Field< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + Cons, u32>, Cons, u32>, Nil>>, + >, + Either< + Field< + Symbol< + 8, + Chars< + 'T', + Chars< + 'r', + Chars< + 'i', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + Cons< + Field< + Symbol< + 4, + Chars<'b', Chars<'a', Chars<'s', Chars<'e', Nil>>>>, + >, + u32, + >, + Cons< + Field< + Symbol< + 6, + Chars< + 'h', + Chars< + 'e', + Chars<'i', Chars<'g', Chars<'h', Chars<'t', Nil>>>>, + >, + >, + >, + u32, + >, + Nil, + >, + >, + >, + Void, + >, + >, + >, + >; + } + impl HasFieldsRef for Shape { + type FieldsRef<'__a> = Either< + Field< + Symbol<5, Chars<'E', Chars<'m', Chars<'p', Chars<'t', Chars<'y', Nil>>>>>>, + Nil, + >, + Either< + Field< + Symbol< + 6, + Chars< + 'C', + Chars<'i', Chars<'r', Chars<'c', Chars<'l', Chars<'e', Nil>>>>>, + >, + >, + &'__a u32, + >, + Either< + Field< + Symbol< + 9, + Chars< + 'R', + Chars< + 'e', + Chars< + 'c', + Chars< + 't', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + >, + Cons< + Field, &'__a u32>, + Cons, &'__a u32>, Nil>, + >, + >, + Either< + Field< + Symbol< + 8, + Chars< + 'T', + Chars< + 'r', + Chars< + 'i', + Chars< + 'a', + Chars<'n', Chars<'g', Chars<'l', Chars<'e', Nil>>>>, + >, + >, + >, + >, + >, + Cons< + Field< + Symbol< + 4, + Chars<'b', Chars<'a', Chars<'s', Chars<'e', Nil>>>>, + >, + &'__a u32, + >, + Cons< + Field< + Symbol< + 6, + Chars< + 'h', + Chars< + 'e', + Chars<'i', Chars<'g', Chars<'h', Chars<'t', Nil>>>>, + >, + >, + >, + &'__a u32, + >, + Nil, + >, + >, + >, + Void, + >, + >, + >, + > + where + Self: '__a; + } + impl FromFields for Shape { + fn from_fields(rest: Self::Fields) -> Self { + match rest { + Either::Left(field) => { + let Nil = field.value; + Self::Empty + } + Either::Right(rest) => { + match rest { + Either::Left(field) => { + let field = field.value; + Self::Circle(field) + } + Either::Right(rest) => { + match rest { + Either::Left(field) => { + let Cons(field_1, Cons(field_0, Nil)) = field.value; + Self::Rectangle(field_1.value, field_0.value) + } + Either::Right(rest) => { + match rest { + Either::Left(field) => { + let Cons(base, Cons(height, Nil)) = field.value; + Self::Triangle { + base: base.value, + height: height.value, + } + } + Either::Right(rest) => match rest {} + } + } + } + } + } + } + } + } + } + impl ToFields for Shape { + fn to_fields(self) -> Self::Fields { + match self { + Self::Empty => Either::Left(Nil.into()), + Self::Circle(field) => Either::Right(Either::Left(field.into())), + Self::Rectangle(field_0, field_1) => { + Either::Right( + Either::Right( + Either::Left( + Cons(field_0.into(), Cons(field_1.into(), Nil)).into(), + ), + ), + ) + } + Self::Triangle { base, height } => { + Either::Right( + Either::Right( + Either::Right( + Either::Left( + Cons(base.into(), Cons(height.into(), Nil)).into(), + ), + ), + ), + ) + } + } + } + } + impl ToFieldsRef for Shape { + fn to_fields_ref<'__a>(&'__a self) -> Self::FieldsRef<'__a> + where + Self: '__a, + { + match self { + Self::Empty => Either::Left(Nil.into()), + Self::Circle(field) => Either::Right(Either::Left(field.into())), + Self::Rectangle(field_0, field_1) => { + Either::Right( + Either::Right( + Either::Left( + Cons(field_0.into(), Cons(field_1.into(), Nil)).into(), + ), + ), + ) + } + Self::Triangle { base, height } => { + Either::Right( + Either::Right( + Either::Right( + Either::Left( + Cons(base.into(), Cons(height.into(), Nil)).into(), + ), + ), + ), + ) + } + } + } + } + ") + } +} + +#[test] +fn test_unit_variant() { + let shape1 = Shape::Empty; + + let fields = shape1.clone().to_fields(); + assert_eq!(fields, Either::Left(Nil.into())); + + let shape2 = Shape::from_fields(fields); + assert_eq!(shape1, shape2); +} + +#[test] +fn test_newtype_variant() { + let shape1 = Shape::Circle(2); + + // The payload is the inner type directly, not a one-element product. + let fields = shape1.clone().to_fields(); + assert_eq!(fields, Either::Right(Either::Left(2.into()))); + + let shape2 = Shape::from_fields(fields); + assert_eq!(shape1, shape2); +} + +#[test] +fn test_multi_field_tuple_variant() { + let shape1 = Shape::Rectangle(3, 4); + + // Positional fields are keyed by `Index`, in declaration order. + let fields = shape1.clone().to_fields(); + assert_eq!( + fields, + Either::Right(Either::Right(Either::Left( + Cons(3.into(), Cons(4.into(), Nil)).into() + ))) + ); + + let shape2 = Shape::from_fields(fields); + assert_eq!(shape1, shape2); +} + +#[test] +fn test_named_field_variant() { + let shape1 = Shape::Triangle { base: 6, height: 5 }; + + // Named fields are keyed by `Symbol!`, in declaration order. + let fields = shape1.clone().to_fields(); + assert_eq!( + fields, + Either::Right(Either::Right(Either::Right(Either::Left( + Cons(6.into(), Cons(5.into(), Nil)).into() + )))) + ); + + let shape2 = Shape::from_fields(fields); + assert_eq!(shape1, shape2); +} + +#[test] +fn test_borrowed_fields_across_every_shape() { + // `to_fields_ref` borrows each payload in place, whatever the variant shape. + assert_eq!(Shape::Empty.to_fields_ref(), Either::Left(Nil.into())); + + assert_eq!( + Shape::Circle(2).to_fields_ref(), + Either::Right(Either::Left((&2).into())) + ); + + assert_eq!( + Shape::Rectangle(3, 4).to_fields_ref(), + Either::Right(Either::Right(Either::Left( + Cons((&3).into(), Cons((&4).into(), Nil)).into() + ))) + ); + + assert_eq!( + Shape::Triangle { base: 6, height: 5 }.to_fields_ref(), + Either::Right(Either::Right(Either::Right(Either::Left( + Cons((&6).into(), Cons((&5).into(), Nil)).into() + )))) + ); +} diff --git a/crates/tests/cgp-tests/tests/extensible_variants/mod.rs b/crates/tests/cgp-tests/tests/extensible_variants/mod.rs index b8b462e6..3487754f 100644 --- a/crates/tests/cgp-tests/tests/extensible_variants/mod.rs +++ b/crates/tests/cgp-tests/tests/extensible_variants/mod.rs @@ -6,10 +6,12 @@ pub mod sum_macro; // `#[derive(HasFields)]` snapshots for enums (this concept owns the enum -// expansion of the derive): the plain field list of an enum, and the generic -// variant. +// expansion of the derive): the plain field list of an enum, the generic +// variant, and every variant shape the derive accepts — which is all four, +// unlike the variant derives that deconstruct an enum. pub mod has_fields_enum; pub mod has_fields_enum_generic; +pub mod has_fields_enum_shapes; // `#[derive(CgpData)]` snapshots for enums (this concept owns the variant // expansion of the derive): the full extractor/extractor-ref machinery for a @@ -20,6 +22,12 @@ pub mod derive_cgp_data_empty; pub mod derive_cgp_data_generic; pub mod derive_cgp_data_shape; +// The individual variant derives, each on its own: `#[derive(ExtractField)]` +// for the extractor slice alone and `#[derive(FromVariant)]` for the +// constructor slice alone. +pub mod extract_field_derive; +pub mod from_variant_derive; + // Regression: `#[derive(CgpData)]` on an enum whose own lifetime is named `'a`, // which the borrowed extractor's reserved `'__a__` lifetime must not collide // with. A plain behavioral test — the expansion shape is pinned above. From c4c9095042953212df5267c1945cc952bc695a7c Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sun, 2 Aug 2026 22:25:07 +0200 Subject: [PATCH 09/10] Test the product operations, and DefaultImpls2's reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover two gaps found by auditing the trait surface while porting the reference's `traits/` group. `AppendProduct`, `ConcatProduct`, and `MapFields` had no test anywhere, despite all three being public and carrying worked examples in the documentation. They compute types rather than values, so the new tests are type equalities: each coerces the computed `Output`/`Mapped` to the type the operation is documented to produce, and compiles only if the two agree. The file covers both `Nil` identities of concat, the three markers over a product, and `MapFields` over the `Either`/`Void` sum spine as well as the product one — the latter being the reason `MapFields` is the only one of the three defined over both. `DefaultImpls2` had no user at all: nothing in the library emits or consumes it, and no test named it, which left it unclear whether the trait was reachable or merely declared. It is reachable, and needs no new construct to be — `#[default_impl]` accepts an arbitrary namespace path and appends only the table parameter, so a two-type key registers and an ordinary `for … in` loop consumes it. The test establishes both halves and drives the resulting wiring. Writing them also settled where these traits live, which is now stated on the public pages: none of the three product operations is in the prelude, nor is the `IsOptional` marker, nor `DefaultImpls1`/`DefaultImpls2`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018n2krDG9WF1HD9aA1aMASX --- .../cgp-tests/tests/extensible_records/mod.rs | 3 + .../tests/extensible_records/product_ops.rs | 169 ++++++++++++++++++ .../tests/namespaces/default_impls2.rs | 64 +++++++ .../tests/cgp-tests/tests/namespaces/mod.rs | 1 + 4 files changed, 237 insertions(+) create mode 100644 crates/tests/cgp-tests/tests/extensible_records/product_ops.rs create mode 100644 crates/tests/cgp-tests/tests/namespaces/default_impls2.rs diff --git a/crates/tests/cgp-tests/tests/extensible_records/mod.rs b/crates/tests/cgp-tests/tests/extensible_records/mod.rs index 871b6b98..37b1e3db 100644 --- a/crates/tests/cgp-tests/tests/extensible_records/mod.rs +++ b/crates/tests/cgp-tests/tests/extensible_records/mod.rs @@ -25,6 +25,9 @@ pub mod cgp_record_derive; pub mod record_build_from; pub mod record_build_with_handlers; +// The type-level product operations: growing, splicing, and re-marking a field list. +pub mod product_ops; + // The value-level `product!` macro: building a `Cons`/`Nil` value from expression // items, whose type is the matching `Product!`. pub mod product_value; diff --git a/crates/tests/cgp-tests/tests/extensible_records/product_ops.rs b/crates/tests/cgp-tests/tests/extensible_records/product_ops.rs new file mode 100644 index 00000000..b0f344ef --- /dev/null +++ b/crates/tests/cgp-tests/tests/extensible_records/product_ops.rs @@ -0,0 +1,169 @@ +//! The type-level product operations: `AppendProduct`, `ConcatProduct`, and `MapFields`. +//! +//! These three are the list algebra the structural machinery is built from — growing a +//! field product by one entry, splicing two products together, and rewriting every entry +//! through a `MapType` marker. They are pure type-level functions evaluated by the trait +//! solver, so the assertions below are type equalities rather than runtime comparisons: +//! each `fn` body coerces the computed `Output`/`Mapped` type to the type the operation is +//! documented to produce, and the test compiles only if the two are the same type. +//! +//! `MapFields` is the one defined over *both* spines, so it is exercised over a `Cons`/`Nil` +//! product and an `Either`/`Void` sum, with the same marker applied to each. +//! +//! None of the three is in the prelude; they are imported from `cgp::core::field::traits`, +//! as is the `IsOptional` marker's home in `cgp::core::field::impls`. +//! +//! See cgp-knowledge-base/cgp/reference/traits/product_ops.md. + +use cgp::core::field::impls::IsOptional; +use cgp::core::field::traits::{AppendProduct, ConcatProduct, MapFields}; +use cgp::prelude::*; + +/// `AppendProduct` adds one entry at the end of a product, keeping the existing entries in +/// order. +#[test] +fn test_append_product() { + type Base = Product![Field]; + + type WithPort = >>::Output; + + fn assert_appended( + fields: WithPort, + ) -> Product![Field, Field] { + fields + } + + let appended = assert_appended(Cons( + "localhost".to_owned().into(), + Cons(8080_u16.into(), Nil), + )); + + assert_eq!(appended.0.value, "localhost"); +} + +/// Appending to the empty product yields a one-entry product. +#[test] +fn test_append_to_the_empty_product() { + type One = >>::Output; + + fn assert_one(fields: One) -> Product![Field] { + fields + } + + let one = assert_one(Cons("localhost".to_owned().into(), Nil)); + + assert_eq!(one.0.value, "localhost"); +} + +/// `ConcatProduct` splices a whole product onto the end of another, which is what makes +/// append the single-entry special case of concat. +#[test] +fn test_concat_product() { + type Left = Product![Field, Field]; + type Right = Product![Field]; + + type Joined = >::Output; + + fn assert_joined( + fields: Joined, + ) -> Product![ + Field, + Field, + Field, + ] { + fields + } + + let joined = assert_joined(Cons( + "localhost".to_owned().into(), + Cons(8080_u16.into(), Cons(true.into(), Nil)), + )); + + assert!(joined.1.1.0.value); +} + +/// Concatenating onto the empty product returns the other product unchanged, and +/// concatenating the empty product onto one leaves it unchanged — the two identity cases. +#[test] +fn test_concat_identities() { + type Fields = Product![Field]; + + fn assert_left_identity(fields: >::Output) -> Fields { + fields + } + + fn assert_right_identity(fields: >::Output) -> Fields { + fields + } + + let left = assert_left_identity(Cons("a".to_owned().into(), Nil)); + let right = assert_right_identity(Cons("b".to_owned().into(), Nil)); + + assert_eq!(left.0.value, "a"); + assert_eq!(right.0.value, "b"); +} + +/// `MapFields` leaves a product's length and order alone and rewrites each entry type +/// through the marker's `Map`. `IsPresent` is therefore the identity. +#[test] +fn test_map_fields_over_a_product() { + type Fields = Product![String, u16, bool]; + + fn assert_optional( + fields: >::Mapped, + ) -> Product![Option, Option, Option,] { + fields + } + + fn assert_nothing(fields: >::Mapped) -> Product![(), (), ()] { + fields + } + + fn assert_present(fields: >::Mapped) -> Fields { + fields + } + + let optional = assert_optional(Cons( + Some("a".to_owned()), + Cons(Some(1), Cons(Some(true), Nil)), + )); + assert_eq!(optional.0, Some("a".to_owned())); + + let _nothing = assert_nothing(Cons((), Cons((), Cons((), Nil)))); + let present = assert_present(Cons("a".to_owned(), Cons(1, Cons(true, Nil)))); + assert_eq!(present.0, "a"); +} + +/// Mapping the empty product is the empty product, whatever the marker. +#[test] +fn test_map_fields_over_the_empty_product() { + fn assert_empty(fields: >::Mapped) -> Nil { + fields + } + + assert_eq!(assert_empty(Nil), Nil); +} + +/// The same marker applies over the `Either`/`Void` sum spine, which is what lets one +/// operation produce both a partial record and a partial enum. +#[test] +fn test_map_fields_over_a_sum() { + type Variants = Sum![String, u16]; + + fn assert_optional( + variants: >::Mapped, + ) -> Sum![Option, Option] { + variants + } + + let mapped = assert_optional(Either::Left(Some("a".to_owned()))); + + assert_eq!(mapped, Either::Left(Some("a".to_owned()))); +} + +/// Mapping the empty sum is the empty sum. `Void` is uninhabited, so this is a type-level +/// assertion with no value to build — the function existing is the whole check. +#[allow(dead_code)] +fn assert_map_fields_over_the_empty_sum(variants: >::Mapped) -> Void { + variants +} diff --git a/crates/tests/cgp-tests/tests/namespaces/default_impls2.rs b/crates/tests/cgp-tests/tests/namespaces/default_impls2.rs new file mode 100644 index 00000000..79b7e1db --- /dev/null +++ b/crates/tests/cgp-tests/tests/namespaces/default_impls2.rs @@ -0,0 +1,64 @@ +//! `DefaultImpls2`, the two-type-parameter member of the default-lookup family. +//! +//! `DefaultNamespace`, `DefaultImpls1`, and `DefaultImpls2` differ only in how many types +//! take part in the key. Nothing in the library or the rest of this suite exercised +//! `DefaultImpls2`, so this file establishes that it is genuinely reachable rather than a +//! declared-but-unusable trait — both halves of the round trip work, and neither needed a +//! new construct. +//! +//! Registration is the ordinary `#[default_impl]` attribute: the attribute takes an +//! arbitrary namespace trait path and appends the table parameter, so naming +//! `DefaultImpls2` emits +//! `impl<__Components__> DefaultImpls2 for Key`. The key +//! written before `in` becomes the impl's `Self`, and the types written inside the path +//! become the trait's leading parameters — which is the opposite way round from what the +//! parameter names `T`/`T1`/`T2` suggest. +//! +//! Consumption is the ordinary `for … in` loop, whose generated bound is +//! `T: DefaultImpls2`. The loop variable is the +//! *key*, so the entry must mention `T` for it to be constrained. +//! +//! See cgp-knowledge-base/cgp/reference/traits/default_namespace.md. + +use cgp::core::component::DefaultImpls2; +use cgp::prelude::*; + +#[cgp_component(ShowImpl)] +pub trait CanShowPair { + fn show_pair(&self, first: &T, second: &U) -> String; +} + +/// Registers `ShowPair` as the default for the key `String` under the two-type key +/// `(ShowImplComponent, u64)`. +#[cgp_impl(new ShowPair)] +#[default_impl(String in DefaultImpls2)] +impl ShowImpl { + fn show_pair(&self, first: &String, second: &u64) -> String { + format!("{first}={second}") + } +} + +pub struct App; + +delegate_components! { + App { + open ShowImplComponent; + + for in DefaultImpls2 { + @ShowImplComponent.T: Provider, + } + } +} + +check_components! { + App { + ShowImplComponent: (String, u64), + } +} + +#[test] +fn test_the_two_type_default_resolves() { + let shown = App.show_pair(&"answer".to_owned(), &42); + + assert_eq!(shown, "answer=42"); +} diff --git a/crates/tests/cgp-tests/tests/namespaces/mod.rs b/crates/tests/cgp-tests/tests/namespaces/mod.rs index c3e0b4fb..c7ede1c9 100644 --- a/crates/tests/cgp-tests/tests/namespaces/mod.rs +++ b/crates/tests/cgp-tests/tests/namespaces/mod.rs @@ -36,6 +36,7 @@ pub mod redirect_mapping; // and namespace inheritance in `delegate_components!`. pub mod default_impl_use_type; pub mod default_impls; +pub mod default_impls2; pub mod default_impls_wiring; pub mod extended; pub mod extended_namespace_wiring; From 1d43cc949b21d41542c815a2fd555c5e2c7c622d Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sun, 2 Aug 2026 22:57:14 +0200 Subject: [PATCH 10/10] Export cgp-base as `cgp::base` --- crates/main/cgp-base-extra/src/lib.rs | 3 +-- crates/main/cgp-base/src/lib.rs | 2 +- crates/main/cgp-core/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/main/cgp-base-extra/src/lib.rs b/crates/main/cgp-base-extra/src/lib.rs index 0683d3ad..ffdda3d0 100644 --- a/crates/main/cgp-base-extra/src/lib.rs +++ b/crates/main/cgp-base-extra/src/lib.rs @@ -2,5 +2,4 @@ pub mod macro_prelude; -pub use cgp_base::{base_types, component}; -pub use cgp_type as types; +pub use cgp_base::{component, types}; diff --git a/crates/main/cgp-base/src/lib.rs b/crates/main/cgp-base/src/lib.rs index b33a4759..c0e3c494 100644 --- a/crates/main/cgp-base/src/lib.rs +++ b/crates/main/cgp-base/src/lib.rs @@ -1,4 +1,4 @@ pub mod macro_prelude; -pub use cgp_base_types as base_types; +pub use cgp_base_types::*; pub use cgp_component as component; diff --git a/crates/main/cgp-core/src/lib.rs b/crates/main/cgp-core/src/lib.rs index 63d72983..f94af85e 100644 --- a/crates/main/cgp-core/src/lib.rs +++ b/crates/main/cgp-core/src/lib.rs @@ -6,6 +6,6 @@ pub mod prelude; pub use prelude as macro_prelude; #[doc(inline)] pub use { - cgp_async_macro::async_trait, cgp_component as component, cgp_error as error, + cgp_async_macro::async_trait, cgp_base as base, cgp_component as component, cgp_error as error, cgp_field as field, cgp_macro as macros, cgp_type as types, };