diff --git a/.gitignore b/.gitignore index ece769b..7763c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .idea/ .vscode/ +.worktrees/ + +.claude/ target/ fuzz/artifacts/ diff --git a/GUIDE.md b/GUIDE.md index f7c875c..1c7e06c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -151,7 +151,7 @@ Use this when you want an owned memento value within the same process: - test fixtures - state handoff between components -Enable the `impl_from` feature to derive `From` for the generated +Enable the `impl_from` feature to derive `From` for the generated memento type. ## Installation and Features @@ -160,7 +160,7 @@ Base dependency: ```toml [dependencies] -recallable = "0.1.0" +recallable = "0.2.0" ``` MSRV is Rust 1.88 with edition 2024. @@ -171,7 +171,7 @@ Feature flags: derive `serde::Deserialize`, and `#[recallable_model]` also injects source-side serde behavior. This feature remains compatible with `no_std` as long as your serde stack is configured for `no_std`. -- `impl_from`: generates `From` for the generated memento +- `impl_from`: generates `From` for the generated memento - `full`: convenience feature for `serde` + `impl_from` - `default-features = false`: disables recallable's default serde integration. It is useful for non-serde setups, but it is not what makes `no_std` @@ -182,7 +182,7 @@ Example dependency sets: ```toml [dependencies] # Readable std example -recallable = "0.1.0" +recallable = "0.2.0" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` @@ -190,7 +190,7 @@ serde_json = "1" ```toml [dependencies] # no_std + serde example -recallable = { version = "0.1.0", default-features = false, features = ["serde"] } +recallable = { version = "0.2.0", default-features = false, features = ["serde"] } serde = { version = "1", default-features = false, features = ["derive"] } postcard = { version = "1", default-features = false, features = ["heapless"] } heapless = { version = "0.9.2", default-features = false } @@ -255,21 +255,51 @@ With the default `serde` feature enabled, it also injects: - `#[derive(serde::Serialize)]` on the source struct - `#[serde(skip)]` on fields marked `#[recallable(skip)]` -Example: +Enum support is intentionally split: + +- assignment-only enums can use `#[recallable_model]` directly +- enums with `PhantomData<_>` marker fields can also use it directly; those + marker fields are the only skipped-field exception the derive handles + automatically +- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields + should derive `Recallable` and implement `Recall` or `TryRecall` manually + +Concrete example: ```rust -use recallable::recallable_model; +use recallable::{Recall, Recallable, recallable_model}; #[recallable_model] -#[derive(Clone, Debug, PartialEq, Eq)] -struct UserProfile { - id: u64, - display_name: String, - #[recallable(skip)] - cache_key: String, +#[derive(Clone, Debug, PartialEq)] +enum ConnectionState { + Disconnected, + Connected { session_id: u64, label: String }, +} + +fn main() { + let mut state = ConnectionState::Disconnected; + let memento: ::Memento = + serde_json::from_str(r#"{"Connected":{"session_id":7,"label":"live"}}"#).unwrap(); + + state.recall(memento); + + assert_eq!( + state, + ConnectionState::Connected { + session_id: 7, + label: "live".into(), + } + ); } ``` +That example is the normal supported enum flow: + +- assignment-only variants work directly with `#[recallable_model]` +- nested `#[recallable]` enum fields still need manual `Recall` or `TryRecall` +- `PhantomData<_>` markers are the only skipped-field exception handled + automatically + ### Attribute ordering requirement `#[recallable_model]` must appear before the attributes it needs to inspect. @@ -354,11 +384,21 @@ struct SessionState { Important distinction: - `#[recallable_model]` mutates source-side serde behavior for the common case -- direct `#[derive(Recallable, Recall)]` does not +- direct `#[derive(Recallable, Recall)]` does not change the source item for + you + +Direct derives are also the split point for complex enums: + +- `#[derive(Recallable)]` supports enum-shaped mementos under the normal field rules +- `#[derive(Recall)]` works only for assignment-only enums, plus + `PhantomData<_>` marker fields that are auto-skipped +- enums with nested `#[recallable]` or other skipped variant fields should derive + `Recallable` and implement `Recall` or `TryRecall` manually If you use direct derives and want the source struct to serialize in the same shape as the generated memento, you must add the serde derives and -`#[serde(skip)]` attributes yourself. +`#[serde(skip)]` attributes yourself. Use `#[recallable_model]` when you want +those shapes to align automatically. ## Skipped fields and memento visibility @@ -381,6 +421,67 @@ Generated mementos are intentionally somewhat opaque: This design pushes callers toward "construct or deserialize a memento, then apply it" instead of depending on widened field visibility. +### Skipped `PhantomData` and retained generics + +Most skipped fields simply disappear from the generated memento. `PhantomData<_>` +fields are auto-skipped by the derive, and the tricky case is when such a field +is the only field mentioning a generic that +still must remain part of the memento type. + +```rust +use core::any::TypeId; +use core::marker::PhantomData; +use recallable::Recallable; + +#[derive(Recallable)] +struct BoundDependent, U> { + value: T, + #[recallable(skip)] + marker: PhantomData, +} + +type Left = as Recallable>::Memento; +type Right = as Recallable>::Memento; + +assert_ne!(TypeId::of::(), TypeId::of::()); +``` + +Why this needs a hidden marker: + +- the skipped field means there is no visible memento field of type `U` +- `U` still matters, because the retained generic `T` depends on it through + `T: From` +- the generated memento type therefore needs to keep `U` alive internally + +The derive handles that by synthesizing an internal `PhantomData` marker on the +generated memento. + +If a skipped generic is otherwise unused, the derive prunes it instead of +preserving it: + +```rust +use core::any::TypeId; +use core::marker::PhantomData; +use recallable::Recallable; + +#[derive(Recallable)] +enum SkippedGenericEnum { + Value(T), + Marker(#[recallable(skip)] PhantomData), +} + +type Left = as Recallable>::Memento; +type Right = as Recallable>::Memento; + +assert_eq!(TypeId::of::(), TypeId::of::()); +``` + +So the rule is: + +- if a skipped generic is no longer needed, the memento drops it +- if it is still needed by retained generics or bounds, the derive keeps it via + an internal hidden marker + ## Recursive fields and container-defined semantics Mark a field with `#[recallable]` when that field should use its own @@ -471,12 +572,12 @@ Every `Recall` type automatically implements `TryRecall` with ## In-memory snapshots with `impl_from` -Enable `impl_from` when you want a derived `From` implementation for +Enable `impl_from` when you want a derived `From` implementation for the generated memento. ```toml [dependencies] -recallable = { version = "0.1.0", features = ["impl_from"] } +recallable = { version = "0.2.0", features = ["impl_from"] } ``` ```rust @@ -529,6 +630,9 @@ For `#[recallable]` fields, this also requires: That extra export-side bound is why `impl_from` is not enabled implicitly for all workflows. +With `impl_from`, both struct and enum `Recallable` derives can generate +`From` for the companion memento, as long as the generated bounds hold. + ## Manual trait implementations You do not need the macros to use the traits. @@ -541,7 +645,7 @@ serde support by default: ```toml [dependencies] -recallable = { version = "0.1.0", default-features = false } +recallable = { version = "0.2.0", default-features = false } ``` Then define the memento and recall behavior manually: @@ -581,11 +685,16 @@ This manual style pairs naturally with: The derive macros support more than just simple named structs. -### Supported struct shapes +### Supported item shapes - named structs - tuple structs - unit structs +- enums for `Recallable` +- enums for `Recall` and `recallable_model` only when every variant field is + assignment-only, plus `PhantomData<_>` markers that the derive auto-skips +- complex enums should derive `Recallable` only and supply manual `Recall` or + `TryRecall` ### Supported generic forms @@ -605,27 +714,35 @@ owned type. That means: - skipped borrowed fields are allowed -- lifetime-only markers such as `PhantomData<&'a T>` are allowed +- `PhantomData<_>` fields are allowed because the derive auto-skips them; this + includes lifetime-bearing markers such as `PhantomData<&'a T>` - non-skipped borrowed state fields like `&'a str` are rejected ## Serialization guidance Recallable is codec-agnostic. -It only cares that your chosen format can: +For persisted state, the usual flow is: + +1. serialize the source value with your chosen Serde-compatible format +2. deserialize into the memento type you apply +3. apply the memento -- serialize the source-side state you emit -- deserialize into the memento type you apply +This applies to any Serde-compatible format, not just the `serde_json` and +`postcard` examples in this repository. Compatibility is a property of the +chosen format and its deserializer behavior, not of Recallable itself. Practical guidance: -- use `serde_json` for readable examples, tests, and debugging -- use `postcard` or another binary format when you care about size or `no_std` +- use a human-readable format when you want easier inspection in docs or tests +- use a binary format when you care about size or `no_std`, but treat + fixed-layout codecs as schema-sensitive unless you version them - use `#[recallable_model]` when you want the source struct's serialized shape to align automatically with the generated memento - use direct derives when you want explicit source-side serde control Important asymmetry: +- generated mementos are the deserialization targets you apply - generated mementos derive `Deserialize` - generated mementos do not derive `Serialize` @@ -637,7 +754,7 @@ write-side output format by default. ### `#[recallable_model]` -Convenience attribute for the common struct model path. +Convenience attribute for the common struct or assignment-only enum model path. It is the recommended default whether or not `serde` is enabled; with `serde` enabled it also removes extra derive boilerplate. @@ -646,6 +763,8 @@ Behavior: - injects `Recallable` and `Recall` - injects `serde::Serialize` when the `serde` feature is enabled - injects `#[serde(skip)]` onto fields marked `#[recallable(skip)]` +- rejects complex enums where generated `Recall` would be ambiguous; those + should derive `Recallable` and implement `Recall` or `TryRecall` manually ### `#[derive(Recallable)]` @@ -653,7 +772,8 @@ Generates: - the companion memento type - the `Recallable` implementation -- `From` for the memento when `impl_from` is enabled +- `From` for the memento when `impl_from` is enabled +- enum-shaped mementos for enums, even when `Recall` must stay manual ### `#[derive(Recall)]` @@ -661,9 +781,11 @@ Generates the `Recall` implementation. Behavior: -- plain fields are assigned directly -- `#[recallable]` fields call nested `recall` -- `#[recallable(skip)]` fields are left untouched +- struct fields are handled as before +- enum derives are supported only for assignment-only variants, plus + `PhantomData<_>` marker fields that are auto-skipped by the derive +- enums with nested `#[recallable]` or other skipped fields should derive + `Recallable` and implement `Recall` or `TryRecall` manually ### `#[recallable]` @@ -718,7 +840,11 @@ pub trait TryRecall: Recallable { ## Current limitations -- Derive macros currently support structs only; enum support is not implemented +- `#[derive(Recallable)]` supports enums under the normal field rules +- `#[derive(Recall)]` and `#[recallable_model]` support enums only for + assignment-only variants +- complex enums should derive `Recallable` and implement `Recall` or + `TryRecall` manually - Borrowed non-skipped state fields are rejected - `#[recallable]` is path-only and does not accept tuple/reference/slice/function syntax directly diff --git a/Makefile b/Makefile index 1c0fdd1..bcd73d6 100644 --- a/Makefile +++ b/Makefile @@ -2,39 +2,75 @@ CARGO ?= cargo TEST_ARGS ?= --workspace +EXAMPLE_ARGS ?= --package recallable CARGO_LLVM_COV ?= cargo llvm-cov COVERAGE_ARGS ?= --workspace COVERAGE_DIR ?= target/llvm-cov COVERAGE_HTML_INDEX := $(COVERAGE_DIR)/html/index.html BROWSER_OPEN ?= open -.PHONY: help test test-default test-no-default test-impl-from test-all-features coverage coverage-open +.PHONY: help regression test validate-examples \ + test-default test-no-default test-impl-from test-all-features \ + examples-default examples-no-default examples-impl-from examples-all-features \ + coverage coverage-open help: @printf '%s\n' \ 'Available targets:' \ - ' make test Run the full workspace test feature matrix' \ + ' make regression Run the full workspace test and example matrix' \ + ' make test Run the original workspace test feature matrix' \ + ' make validate-examples Run the example validation matrix' \ ' make test-default Run tests with default features' \ + ' make examples-default Run default-feature examples' \ ' make test-no-default Run tests with no default features' \ + ' make examples-no-default Run examples with no default features' \ ' make test-impl-from Run tests with impl_from enabled' \ + ' make examples-impl-from Run impl_from examples with no default features' \ ' make test-all-features Run tests with all features enabled' \ + ' make examples-all-features Check examples with all features enabled' \ ' make coverage Generate merged llvm-cov HTML and JSON reports' \ ' make coverage-open Generate coverage reports and open the HTML report' -test: test-default test-no-default test-impl-from test-all-features +regression: test validate-examples + +test: \ + test-default \ + test-no-default \ + test-impl-from \ + test-all-features + +validate-examples: \ + examples-default \ + examples-no-default \ + examples-impl-from \ + examples-all-features test-default: $(CARGO) test $(TEST_ARGS) +examples-default: + $(CARGO) run $(EXAMPLE_ARGS) --example basic_model + $(CARGO) run $(EXAMPLE_ARGS) --example nested_generic + $(CARGO) run $(EXAMPLE_ARGS) --example postcard_roundtrip + test-no-default: $(CARGO) test $(TEST_ARGS) --no-default-features +examples-no-default: + $(CARGO) run $(EXAMPLE_ARGS) --no-default-features --example manual_no_serde + test-impl-from: $(CARGO) test $(TEST_ARGS) --features impl_from +examples-impl-from: + $(CARGO) run $(EXAMPLE_ARGS) --no-default-features --features impl_from --example impl_from_roundtrip + test-all-features: $(CARGO) test $(TEST_ARGS) --all-features +examples-all-features: + $(CARGO) check $(EXAMPLE_ARGS) --examples --all-features + coverage: $(CARGO_LLVM_COV) clean $(COVERAGE_ARGS) $(CARGO_LLVM_COV) $(COVERAGE_ARGS) --no-default-features --tests --no-report diff --git a/README.md b/README.md index 2bfbdba..1247437 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ input. - `Recall`: applies that memento infallibly - `TryRecall`: applies it fallibly with validation - `#[derive(Recallable)]`: generates the companion memento type -- `#[derive(Recall)]`: generates recall logic -- `#[recallable_model]`: convenience attribute for the common model path; with `serde` enabled it also removes extra - derive boilerplate +- `#[derive(Recall)]`: generates recall logic for structs and assignment-only enums +- `#[recallable_model]`: convenience attribute for the common struct or assignment-only enum path; with + `serde` enabled it also removes extra derive boilerplate The crate intentionally does not force one universal memento shape for container-like field types. A field type can choose whole-value replacement, @@ -97,13 +97,28 @@ For `no_std + serde` deployments, prefer a `no_std`-compatible format such as - `#[derive(serde::Serialize)]` when the default `serde` feature is enabled - `#[serde(skip)]` for fields marked `#[recallable(skip)]` +For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recallable)]`: + +- assignment-only enums are supported directly +- enums with `PhantomData<_>` marker fields are still supported directly; those + marker fields are auto-skipped, and explicit `#[recallable(skip)]` remains + accepted +- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should + derive `Recallable` and implement `Recall` or `TryRecall` manually + +Auto-skipped `PhantomData<_>` can also affect generic retention on generated +mementos. When such a marker is the only field mentioning a generic that still +must remain part of the memento type, the derive keeps that generic alive with +an internal hidden marker. The user-facing examples for that corner case live +in the API docs and [GUIDE.md](GUIDE.md). + ## Features - `serde` (default): enables macro-generated serde support; generated mementos derive `serde::Deserialize`, and `#[recallable_model]` also adds the source-side serde behavior described above. This feature remains compatible with `no_std` as long as your serde stack is configured for `no_std`. -- `impl_from`: generates `From` for `::Memento` +- `impl_from`: generates `From` for `::Memento` - `full`: convenience feature for `serde` + `impl_from` - `default-features = false`: disables recallable's default serde integration. It is useful for non-serde setups, but it is not what makes `no_std` possible. @@ -113,7 +128,7 @@ Example dependency sets: ```toml [dependencies] # Readable std example -recallable = "0.1" +recallable = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` @@ -121,7 +136,7 @@ serde_json = "1" ```toml [dependencies] # no_std + serde example -recallable = { version = "0.1", default-features = false, features = ["serde"] } +recallable = { version = "0.2", default-features = false, features = ["serde"] } serde = { version = "1", default-features = false, features = ["derive"] } postcard = { version = "1", default-features = false, features = ["heapless"] } heapless = { version = "0.9.2", default-features = false } @@ -130,29 +145,39 @@ heapless = { version = "0.9.2", default-features = false } ```toml [dependencies] # In-memory snapshots -recallable = { version = "0.1", features = ["impl_from"] } +recallable = { version = "0.2", features = ["impl_from"] } ``` ## Two Common Workflows ### Persistence and restore -This is the default path when state crosses process boundaries or is written to -disk. It works in both `std` and `no_std` environments; only the serialization -format and serde configuration differ. +This is the normal persistence path when state crosses process boundaries or +is written to disk. It works in both `std` and `no_std` environments; only the +serialization format and serde configuration differ. -1. Serialize the source struct. +1. Serialize the source value with your chosen Serde-compatible format. 2. Deserialize into `::Memento`. 3. Apply the memento with `recall` or `try_recall`. +Generated mementos are deserialization targets, not the default write-side +export format. If you want a stable custom wire format or different +source-side serde behavior, define the memento manually. + This flow is especially convenient with `#[recallable_model]`, because the -source struct's serialized shape already matches the generated memento shape. +source struct's serialized shape already matches the generated memento shape +automatically. Any Serde-compatible format can be used here; `serde_json` and +`postcard` show up in this repository's tests and examples only as concrete +reference points. ### In-memory snapshots Enable `impl_from` when you want an owned memento inside the same process for checkpoint/rollback, undo stacks, or test fixtures. +With `impl_from`, both struct and enum `Recallable` derives can generate +`From` for the companion memento, as long as the generated bounds hold. + ```rust use recallable::{Recall, Recallable, recallable_model}; @@ -183,7 +208,7 @@ default: ```toml [dependencies] -recallable = { version = "0.1", default-features = false } +recallable = { version = "0.2", default-features = false } ``` Define the memento type and recall behavior manually: @@ -223,8 +248,17 @@ impl Recall for EngineState { ## Current Limitations -- Derive macros currently support structs only: named, tuple, and unit structs +- Derive macros support structs and enums +- `#[derive(Recallable)]` supports enums under the normal field rules +- `#[derive(Recall)]` and `#[recallable_model]` support enums only when every + non-marker variant field is assignment-only +- Enums with `PhantomData<_>` marker fields are still supported; those marker + fields are auto-skipped, and explicit `#[recallable(skip)]` remains accepted +- Enums with nested `#[recallable]` or other `#[recallable(skip)]` fields + should derive `Recallable` and implement `Recall` or `TryRecall` manually - Borrowed state fields are rejected unless they are skipped +- `PhantomData<_>` fields are auto-skipped by the derive, including + lifetime-bearing markers such as `PhantomData<&'a T>` - `#[recallable]` is path-only: it supports type parameters, path types, and associated types, but not tuple/reference/slice/function syntax directly - Serde attributes are not forwarded to the generated memento diff --git a/recallable-macro/MACRO_INTERNALS.md b/recallable-macro/MACRO_INTERNALS.md new file mode 100644 index 0000000..ddabdb9 --- /dev/null +++ b/recallable-macro/MACRO_INTERNALS.md @@ -0,0 +1,570 @@ +# `recallable-macro` Internals + +Developer guide to the procedural macro crate that powers `recallable`. +This document explains the architecture, data flow, and design decisions +so you can navigate and extend the codebase confidently. + +--- + +## Table of Contents + +- [High-Level Architecture](#high-level-architecture) +- [Entry Points (`lib.rs`)](#entry-points) +- [Attribute Macro (`model_macro.rs`)](#attribute-macro) +- [The `context` Facade (`context.rs`)](#the-context-facade) +- [Semantic Analysis — the IR Layer](#semantic-analysis--the-ir-layer) + - [Shared Infrastructure (`internal/shared/`)](#shared-infrastructure) + - [Struct IR (`internal/structs/`)](#struct-ir) + - [Enum IR (`internal/enums/`)](#enum-ir) +- [Code Generation](#code-generation) + - [Memento Type (`memento/`)](#memento-type) + - [Recallable Impl (`recallable_impl/`)](#recallable-impl) + - [Recall Impl (`recall_impl/`)](#recall-impl) + - [From Impl (`from_impl/`)](#from-impl) +- [Generic Retention System](#generic-retention-system) +- [Trait Bound Inference](#trait-bound-inference) +- [Feature Flags](#feature-flags) +- [Design Principles](#design-principles) + +--- + +## High-Level Architecture + +The crate follows a three-phase pipeline: + +```text +DeriveInput ──► Semantic Analysis (IR) ──► Code Generation (TokenStream) + │ │ + StructIr / EnumIr memento struct/enum + Recallable impl + Recall impl + From impl (optional) +``` + +1. **Parse** — `syn` parses the token stream into a `DeriveInput`. +2. **Analyze** — The IR layer (`StructIr` or `EnumIr`) validates the input and + builds a rich intermediate representation: field strategies, generic retention + plans, where-clause filtering, and marker param detection. +3. **Generate** — Codegen functions consume `&StructIr`/`&EnumIr` + `&CodegenEnv` + and emit `TokenStream2` fragments that `lib.rs` assembles into the final output. + +The IR is built once and shared across all codegen passes. This avoids redundant +parsing and keeps each codegen module focused on token assembly. + +--- + +## Entry Points + +**File:** `src/lib.rs` + +Three proc-macro entry points, each a thin shell: + +| Macro | Function | What it does | +|-------------------------|-----------------------|--------------------------------------------------------------------------------------| +| `#[recallable_model]` | `recallable_model()` | Attribute macro — delegates to `model_macro::expand` | +| `#[derive(Recallable)]` | `derive_recallable()` | Analyzes input → generates memento type + `Recallable` impl (+ optional `From` impl) | +| `#[derive(Recall)]` | `derive_recall()` | Analyzes input → generates `Recall` impl | + +All generated code is wrapped in `const _: () = { ... };` blocks with +`#[automatically_derived]` to avoid polluting the item namespace and to signal +to lints that the code is machine-generated. + +--- + +## Attribute Macro + +**File:** `src/model_macro.rs` + +`#[recallable_model]` is a convenience attribute that: + +1. Validates it receives no arguments. +2. Parses the item as a struct or enum via `ModelItem`. +3. Runs `context::analyze_model_input()` to reject unsupported enum shapes. +4. Checks for duplicate `Serialize` derives (serde feature only). +5. Injects `#[derive(Recallable, Recall)]` (and `serde::Serialize` when serde is enabled). +6. Adds `#[serde(skip)]` to fields marked `#[recallable(skip)]`. + +`ModelItem` is a local enum over `ItemStruct` / `ItemEnum` that provides a +uniform interface for attribute manipulation (`add_derives`, `add_serde_skip_attrs`, +`parse` -> `DeriveInput`). + +The duplicate-Serialize check scans `#[derive(...)]` attributes for paths ending +in `Serialize`, matching `serde::Serialize`, `serde_derive::Serialize`, and bare +`Serialize`. This catches the most common user mistake since `#[recallable_model]` +auto-adds `Serialize` when serde is enabled. + +> **Attribute ordering:** `#[recallable_model]` must appear *before* any attributes +> it needs to inspect. An attribute macro's `item` token stream only contains +> attributes that follow it in source order. + +--- + +## The `context` Facade + +**File:** `src/context.rs` + +A thin coordination layer that: + +- Re-exports IR types and codegen functions for `lib.rs` to consume. +- Defines feature-flag constants: `SERDE_ENABLED` and `IMPL_FROM_ENABLED`. +- Provides three analysis entry points: + - `analyze_item()` — builds `ItemIr` (struct or enum). + - `analyze_recall_input()` — same, but also validates enum recall-derive eligibility. + - `analyze_model_input()` — validates enum model-attribute eligibility. +- Delegates codegen to submodules: `memento`, `recallable_impl`, `recall_impl`, `from_impl`. + +Each codegen submodule has a top-level dispatcher that matches on `ItemIr::Struct` / +`ItemIr::Enum` and calls the appropriate struct- or enum-specific generator. + +--- + +## Semantic Analysis — the IR Layer + +### Shared Infrastructure + +**Directory:** `src/context/internal/shared/` + +This is the foundation that both `StructIr` and `EnumIr` build on. + +#### `item.rs` — `ItemIr` enum + +```rust +enum ItemIr<'a> { + Struct(StructIr<'a>), + Enum(EnumIr<'a>), +} +``` + +The top-level dispatch type. `ItemIr::analyze()` routes to the appropriate IR +constructor based on `syn::Data`. Also handles item-level attribute parsing +(`skip_memento_default_derives`). + +#### `fields.rs` — Field analysis + +Core types: + +- **`FieldStrategy`** — `Skip | StoreAsSelf | StoreAsMemento`. Determines how each + field participates in the memento. +- **`FieldIr`** — Per-field IR: source reference, memento index (position in the + generated memento after skipped fields are removed), member accessor, type, strategy. +- **`FieldMember`** — `Named(&Ident) | Unnamed(Index)`. Implements `ToTokens` for + use in both pattern and expression positions. + +Field classification logic: + +1. Parse `#[recallable]` and `#[recallable(skip)]` attributes → `FieldBehavior`. +2. Conflicting `#[recallable]` + `#[recallable(skip)]` on the same field → compile error. +3. `PhantomData` fields → auto-skipped (regardless of attributes). +4. `#[recallable]` on a bare type param `T` → `StoreAsMemento` with the param + flagged as `RetainedAsRecallable`. +5. `#[recallable]` on a complex path type like `Option` → `StoreAsMemento` + with whole-type bounds (no bare-param shorthand). +6. Everything else → `StoreAsSelf`. + +`collect_field_irs()` processes all fields and returns both the field IR vec and +a `GenericUsage` summary (which generic params are retained, which are recallable). + +#### `generics.rs` — Generic retention + +The most complex piece of the analysis. See [Generic Retention System](#generic-retention-system) +for the full algorithm. + +Key types: + +- **`GenericParamRetention`** — `Dropped | Retained | RetainedAsRecallable`. +- **`GenericParamPlan`** — Pairs a `&GenericParam` with its retention decision. + Provides `decl_param()`, `type_arg()`, `recallable_ident()`. +- **`GenericParamLookup`** — Index from ident → param position, split by kind + (type, const, lifetime). Used during field analysis and dependency collection. +- **`GenericUsage`** — Accumulated from field analysis: which param indices are + retained, which are recallable type params. +- **`GenericDependencyCollector`** — A `syn::Visit` walker that collects which + generic params a given type or where-predicate depends on. + +#### `lifetime.rs` — Borrowed-field rejection + +- `collect_item_lifetimes()` — extracts lifetime param idents from generics. +- `validate_no_borrowed_fields()` — rejects non-`PhantomData`, non-skipped fields + that reference struct lifetimes. Uses `LifetimeUsageChecker` (a `syn::Visit` walker). +- `is_phantom_data()` — heuristic path match: any path ending in `PhantomData`. + +#### `bounds.rs` — Trait bound assembly + +- **`MementoTraitSpec`** — Encapsulates the derive/bound configuration for the + memento type. Controlled by two flags: `serde_enabled` and `derive_off` + (from `#[recallable(skip_memento_default_derives)]`). + - `derive_attr()` → the `#[derive(...)]` attribute for the memento. + - `common_bound_tokens()` → `Clone + Debug + PartialEq` (or empty if derive_off). + - `serde_nested_bound()` → `DeserializeOwned` (or None if serde disabled). +- `collect_shared_memento_bounds()` — Builds where-predicates for memento type + definitions: `T::Memento: Clone + Debug + PartialEq` and whole-type equivalents. +- `collect_recall_like_bounds()` — Builds where-predicates for trait impls + (`Recallable`, `Recall`): direct bounds on recallable params + shared memento bounds. + +#### `codegen.rs` — Shared codegen helpers + +- **`CodegenItemIr`** trait — The polymorphism layer. Both `StructIr` and `EnumIr` + implement this trait, providing uniform access to generics, fields, memento name, + marker params, etc. This lets bound-collection and field-token-building code work + generically across item kinds. +- `build_memento_field_ty()` — Produces the memento field type: `T::Memento` for + bare type params, `::Memento` for complex paths, + or the original type for `StoreAsSelf`. +- `build_memento_field_tokens()` — Wraps the type with the field name for named + fields, or emits just the type for tuple fields. +- `build_from_value_expr()` — Wraps an expression in `From::from()` for + `StoreAsMemento` fields, passes through for `StoreAsSelf`. + +#### `env.rs` — `CodegenEnv` + +```rust +struct CodegenEnv { + recallable_trait: TokenStream2, // e.g. ::recallable::Recallable + recall_trait: TokenStream2, // e.g. ::recallable::Recall +} +``` + +Resolved once per macro invocation via `crate_path()`. Holds the fully-qualified +trait paths used throughout codegen. + +#### `util.rs` — Crate path resolution + +`crate_path()` uses `proc-macro-crate` to resolve the `recallable` crate name, +handling re-exports and the `Itself` case (when the macro is invoked from within +the `recallable` crate itself). + +### Struct IR + +**Directory:** `src/context/internal/structs/` + +#### `ir.rs` — `StructIr` + +The struct-specific IR. Built by `StructIr::analyze()`: + +1. Collect item lifetimes, validate no borrowed fields. +2. Determine `StructShape` (`Named | Unnamed | Unit`). +3. Build `GenericParamLookup`, collect field IRs + generic usage. +4. Run `plan_memento_generics()` → generic param plans + filtered where-clause. +5. Detect marker param indices (retained params not referenced by any kept field). +6. Parse `skip_memento_default_derives`. + +Key accessors: `struct_type()`, `memento_name()`, `visibility()`, `shape()`, +`impl_generics()`, `memento_fields()` (iterator over non-skipped fields), +`has_synthetic_marker()`. + +Implements `CodegenItemIr` for polymorphic codegen. + +#### `bounds.rs` — Struct-specific bound wrappers + +Thin wrappers around the shared `collect_shared_memento_bounds` and +`collect_recall_like_bounds`, automatically passing the struct's `MementoTraitSpec`. + +### Enum IR + +**Directory:** `src/context/internal/enums/` + +#### `ir.rs` — `EnumIr` and `VariantIr` + +The enum-specific IR. `EnumIr::analyze()` follows the same pattern as `StructIr` +but processes variants: + +1. Each variant's fields are analyzed independently via `collect_field_irs()`. +2. Variants are classified by `VariantShape` (`Named | Unnamed | Unit`). +3. Generic usage is merged across all variants. + +**`VariantIr`** holds per-variant data: name, shape, field IRs. Provides +`kept_fields()` (non-skipped) and `kept_bindings()` (binding idents for pattern matching). + +**Enum restrictions:** + +- `ensure_recall_derive_allowed()` — Rejects enums with `#[recallable]` fields or + non-`PhantomData` skipped fields. These "complex" enums must implement `Recall` + or `TryRecall` manually. +- `ensure_model_derive_allowed()` — Same restriction for `#[recallable_model]`. +- `supports_derived_recall()` — Returns whether the restore helper should be generated. + +`build_binding_ident()` creates binding names for pattern matching: named fields +keep their ident, unnamed fields get `__recallable_field_{index}`. + +Implements `CodegenItemIr` with `all_fields()` flat-mapping across all variants. + +#### `bounds.rs` — Enum-specific bound wrappers + +Same pattern as struct bounds — thin wrappers passing the enum's `MementoTraitSpec`. + +--- + +## Code Generation + +Each codegen module has a top-level dispatcher in its `mod.rs` that matches on +`ItemIr` and delegates to struct- or enum-specific generators. + +### Memento Type + +**Directory:** `src/context/memento/` + +Generates the companion memento type that mirrors the source item. + +**Structs** (`structs.rs`): + +- Emits `#[derive(Clone, Debug, PartialEq)]` (+ `Deserialize` with serde). +- Mirrors the struct shape (named/unnamed/unit). +- Field types: original type for `StoreAsSelf`, `T::Memento` or + `::Memento` for `StoreAsMemento`. +- All fields are private (no visibility modifiers). +- Appends a synthetic `_recallable_marker: PhantomData<(...)>` field when + retained generic params aren't referenced by any kept field. +- Where-clause includes `Recallable` bounds + memento trait bounds. + +**Enums** (`enums.rs`): + +- Same derive/visibility/bound strategy as structs. +- Each variant mirrors the source variant shape, with skipped fields removed. +- Variants that lose all fields become unit variants. +- Synthetic marker uses a hidden `__RecallableMarker(PhantomData<(...)>)` variant + with `#[serde(skip)]` when serde is enabled. + +### Recallable Impl + +**Directory:** `src/context/recallable_impl/` + +Generates `impl Recallable for Type { type Memento = TypeMemento; }`. + +**Structs** (`structs.rs`): Straightforward — emits the impl with appropriate +generics and where-clause. + +**Enums** (`enums.rs`): Additionally generates a private +`__recallable_restore_from_memento()` helper method on the enum type (when the +enum supports derived recall). This helper contains the `match` expression that +reconstructs enum values from memento variants. It's emitted alongside the +`Recallable` impl so that `Recall` can call it. + +The restore helper: + +- Matches each memento variant and reconstructs the corresponding source variant. +- `StoreAsSelf` fields pass through directly. +- Skipped `PhantomData` fields are reconstructed as `PhantomData`. +- The `__RecallableMarker` variant arm is `unreachable!()`. + +### Recall Impl + +**Directory:** `src/context/recall_impl/` + +Generates `impl Recall for Type { fn recall(&mut self, memento) { ... } }`. + +**Structs** (`structs.rs`): + +- For each non-skipped field: + - `StoreAsSelf` → `self.field = memento.field;` + - `StoreAsMemento` → `Recall::recall(&mut self.field, memento.field);` +- Memento parameter is named `_memento` when there are no fields to recall. +- The `recall` method is `#[inline]`. + +**Enums** (`enums.rs`): + +- Delegates to `*self = Self::__recallable_restore_from_memento(memento);` +- The actual reconstruction logic lives in the restore helper generated by + `recallable_impl`. + +### From Impl + +**Directory:** `src/context/from_impl/` + +Generates `impl From for TypeMemento` (behind the `impl_from` feature). + +**Structs** (`structs.rs`): + +- Named: `Self { field: value.field, ... }` +- Unnamed: `Self(value.0, ...)` +- Unit: `Self` +- `StoreAsMemento` fields wrapped in `From::from()`. +- Synthetic marker initialized as `PhantomData`. + +**Enums** (`enums.rs`): + +- Match on source enum, construct corresponding memento variant. +- Binding idents used for destructuring and reconstruction. +- Skipped fields matched as `_` in patterns. + +--- + +## Generic Retention System + +The generic retention algorithm determines which generic parameters from the +source type appear on the generated memento. This is the most intricate part +of the analysis. + +### Algorithm + +1. **Field analysis** (`collect_field_irs`) records which generic params each + non-skipped field depends on → `GenericUsage.retained`. + +2. **Fixed-point dependency closure** (`plan_memento_generics`): + - Start with the set of params directly used by kept fields. + - Scan where-clause predicates: if a predicate depends on any retained param, + mark all params it references as retained too. + - Repeat until no new params are added (fixed-point). + - This ensures transitive dependencies are captured. For example, if `T: Into` + and `T` is retained, then `U` must also be retained. + +3. **Where-clause filtering**: Only predicates that depend on at least one retained + param are kept on the memento's where-clause. Predicates involving only dropped + params are removed. + +4. **Retention classification**: + - `Dropped` — param not used by any kept field (directly or transitively). + - `Retained` — param used by a `StoreAsSelf` field. + - `RetainedAsRecallable` — type param used by a `StoreAsMemento` field + (gets `Recallable` bounds). + +5. **Marker params** (`collect_marker_param_indices`): Retained params that aren't + referenced by any kept field's type need a `PhantomData` marker to satisfy + Rust's "must use all generic params" rule. This happens when a param is only + referenced through where-clause dependencies. + +### Marker field generation + +- **Structs**: A `_recallable_marker: PhantomData<(T, U, ...)>` field is appended. + For named structs it's a named field; for tuple structs it's a positional field. +- **Enums**: A hidden `__RecallableMarker(PhantomData<(T, U, ...)>)` variant is added, + with `#[doc(hidden)]` and `#[serde(skip)]`. +- Const generic params in the marker use a helper type alias + `type __RecallableConstMarker = [(); N];` to embed them in `PhantomData`. + +--- + +## Trait Bound Inference + +The macro automatically infers trait bounds for generic parameters. Bounds are +assembled in layers: + +### For the memento type definition + +```rust +T: Recallable // bare recallable params +T::Memento: Clone + Debug + PartialEq // common trait bounds (unless derive_off) +T::Memento: DeserializeOwned // serde feature only +ComplexType: Recallable // whole-type recallable bounds +::Memento: ... // whole-type memento bounds +``` + +### For `Recallable` / `Recall` impls + +Same structure, but the direct bound varies: + +- `Recallable` impl uses `T: Recallable` as the direct bound. +- `Recall` impl uses `T: Recall` as the direct bound. + +### For `From` impl + +```rust +T: Recallable +T::Memento: From // conversion bound +// + shared memento bounds +// + whole-type From bounds +``` + +The `collect_recall_like_bounds()` function in `shared/bounds.rs` is the +workhorse — it's parameterized by the "direct bound" trait and assembles +the full predicate list. + +--- + +## Feature Flags + +Feature flags are evaluated at compile time of the macro crate itself (not the +downstream user's crate): + +```rust +// context.rs +pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); +pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); +``` + +These are module-level constants, not part of `CodegenEnv`. They gate: + +| Flag | Effect | +|-------------|-------------------------------------------------------------------------------| +| `serde` | 1. Memento derives `Deserialize`; | +| | 2. `#[recallable_model]` adds `Serialize`; `#[serde(skip)]` on marker fields; | +| | 3. `DeserializeOwned` bounds on nested mementos | +|-------------|-------------------------------------------------------------------------------| +| `impl_from` | `From` impl generated for memento types | + +--- + +## Design Principles + +1. **Analyze once, generate many.** The IR is the single source of truth. Each + codegen module is a pure function from `(&IR, &CodegenEnv) -> TokenStream`. + +2. **Polymorphism via `CodegenItemIr`.** Shared codegen logic (generics, bounds, + field tokens) works across structs and enums through a trait, avoiding + duplication while keeping item-specific code in dedicated modules. + +3. **Dependency-closed generics.** The fixed-point loop ensures the memento's + generic parameter set is self-consistent — no dangling bounds or missing params. + +4. **Opaque mementos.** Generated memento fields are always private. Mementos are + state tokens, not an inspection surface. + +5. **Heuristic type matching.** The macro can't resolve types, so it uses path-based + heuristics (e.g., `is_phantom_data` matches any path ending in `PhantomData`, + `is_generic_type_param` checks single-segment paths against known type params). + +6. **Graceful enum restrictions.** Complex enums (with `#[recallable]` fields or + non-phantom skipped fields) are rejected with a clear error message directing + users to manual `Recall`/`TryRecall` implementations. Simple "assignment-only" + enums get full derive support. + +7. **Hygienic output.** All generated code lives in `const _: () = { ... }` blocks, + uses fully-qualified paths (`::core::...`, `::serde::...`), and is marked + `#[automatically_derived]`. + +--- + +## Module Map + +```text +recallable-macro/src/ +├── lib.rs # proc-macro entry points +├── model_macro.rs # #[recallable_model] expansion +├── context.rs # facade: re-exports, feature flags, analysis entry points +└── context/ + ├── memento.rs # ItemIr dispatcher + ├── memento/ + │ ├── structs.rs # memento struct generation + │ └── enums.rs # memento enum generation + ├── recallable_impl.rs # ItemIr dispatcher + ├── recallable_impl/ + │ ├── structs.rs # Recallable impl for structs + │ └── enums.rs # Recallable impl + restore helper for enums + ├── recall_impl.rs # ItemIr dispatcher + ├── recall_impl/ + │ ├── structs.rs # Recall impl for structs + │ └── enums.rs # Recall impl for enums (delegates to restore helper) + ├── from_impl.rs # ItemIr dispatcher + ├── from_impl/ + │ ├── structs.rs # From impl for structs + │ └── enums.rs # From impl for enums + ├── internal.rs # re-exports shared/structs/enums + └── internal/ + ├── shared.rs # re-exports all shared types + ├── shared/ + │ ├── item.rs # ItemIr enum, item-level attr parsing + │ ├── fields.rs # FieldIr, FieldStrategy, field classification + │ ├── generics.rs # generic retention algorithm, dependency collector + │ ├── lifetime.rs # borrowed-field rejection, PhantomData detection + │ ├── bounds.rs # MementoTraitSpec, bound collection + │ ├── codegen.rs # CodegenItemIr trait, shared field/type helpers + │ ├── env.rs # CodegenEnv (resolved crate paths) + │ └── util.rs # crate_path(), is_recallable_attr() + ├── structs.rs # re-exports + ├── structs/ + │ ├── ir.rs # StructIr, StructShape + │ └── bounds.rs # struct-specific bound wrappers + ├── enums.rs # re-exports + └── enums/ + ├── ir.rs # EnumIr, VariantIr, VariantShape + └── bounds.rs # enum-specific bound wrappers +``` diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 26de6f8..f5564c5 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -1,38 +1,116 @@ -//! # Struct IR and Code Generation +//! # Derive Backend Facade //! -//! Semantic analysis and support logic live in the nested `internal` module. +//! Semantic analysis and item-kind-specific support logic live in the nested +//! `internal` module. //! //! Code generation remains split into free functions in submodules: -//! - [`gen_memento_struct`] — companion memento struct definition +//! - `gen_memento_type` — companion memento struct or enum definition //! - [`gen_recallable_impl`] — `Recallable` trait implementation //! - [`gen_recall_impl`] — `Recall` trait implementation -//! - [`gen_from_impl`] — `From` for memento (behind `impl_from` feature) +//! - [`gen_from_impl`] — `From` for memento (behind `impl_from` feature) mod from_impl; mod internal; -mod memento_struct; +mod memento; mod recall_impl; mod recallable_impl; +use syn::DeriveInput; + +use self::internal::shared::ItemIr; + pub(super) use from_impl::gen_from_impl; -pub(super) use internal::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, StructShape, - collect_recall_like_bounds, collect_shared_memento_bounds, crate_path, - has_recallable_skip_attr, is_generic_type_param, -}; -pub(super) use memento_struct::gen_memento_struct; +pub(super) use internal::shared::{CodegenEnv, crate_path, has_recallable_skip_attr}; pub(super) use recall_impl::gen_recall_impl; pub(super) use recallable_impl::gen_recallable_impl; pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); +pub(super) fn analyze_item(input: &DeriveInput) -> syn::Result> { + ItemIr::analyze(input) +} + +pub(super) fn analyze_recall_input(input: &DeriveInput) -> syn::Result> { + let ir = analyze_item(input)?; + + if let ItemIr::Enum(enum_ir) = &ir { + enum_ir.ensure_recall_derive_allowed()?; + } + + Ok(ir) +} + +pub(super) fn analyze_model_input(input: &DeriveInput) -> syn::Result<()> { + let ir = analyze_item(input)?; + + if let ItemIr::Enum(enum_ir) = &ir { + enum_ir.ensure_model_derive_allowed()?; + } + + Ok(()) +} + +pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> proc_macro2::TokenStream { + memento::gen_memento_type(ir, env) +} + #[cfg(test)] mod tests { use quote::ToTokens; use syn::parse_quote; - use super::{CodegenEnv, StructIr, gen_memento_struct}; + use super::{CodegenEnv, analyze_item, analyze_recall_input, gen_memento_type}; + + #[test] + fn split_internal_reexports_cover_both_item_kinds() { + use crate::context::internal::{ + enums::EnumIr, + shared::{CodegenEnv, ItemIr}, + structs::StructIr, + }; + use syn::parse_quote; + + let struct_input: syn::DeriveInput = parse_quote! { + struct Example { + value: T, + } + }; + let enum_input: syn::DeriveInput = parse_quote! { + enum Choice { + One(T), + } + }; + + let struct_ir = StructIr::analyze(&struct_input).unwrap(); + let enum_ir = EnumIr::analyze(&enum_input).unwrap(); + let env = CodegenEnv::resolve(); + + assert_eq!(struct_ir.memento_name().to_string(), "ExampleMemento"); + assert_eq!(enum_ir.memento_name().to_string(), "ChoiceMemento"); + assert!( + crate::context::memento::gen_memento_type(&ItemIr::Struct(struct_ir), &env) + .to_string() + .contains("ExampleMemento") + ); + } + + #[test] + fn helper_name_and_manual_only_guidance_manual_only_error() { + let input: syn::DeriveInput = parse_quote! { + enum Example { + Value(#[recallable] Inner), + } + }; + + let error = analyze_recall_input(&input).unwrap_err(); + + assert!( + error + .to_string() + .contains("derive `Recallable` and implement `Recall` or `TryRecall` manually") + ); + } #[test] fn facade_reexports_support_analysis_and_codegen() { @@ -43,11 +121,27 @@ mod tests { } }; - let ir = StructIr::analyze(&input).unwrap(); + let ir = analyze_item(&input).unwrap(); let env = CodegenEnv::resolve(); - let memento: syn::ItemStruct = syn::parse2(gen_memento_struct(&ir, &env)).unwrap(); + let memento: syn::ItemStruct = syn::parse2(gen_memento_type(&ir, &env)).unwrap(); assert_eq!(memento.ident.to_string(), "ExampleMemento"); assert_eq!(memento.vis.to_token_stream().to_string(), "pub (crate)"); } + + #[test] + fn analyze_item_rejects_unions_at_outer_boundary() { + let input: syn::DeriveInput = parse_quote! { + union Example { + value: u32, + } + }; + + let error = analyze_item(&input).unwrap_err(); + + assert_eq!( + error.to_string(), + "This derive macro can only be applied to structs or enums" + ); + } } diff --git a/recallable-macro/src/context/from_impl.rs b/recallable-macro/src/context/from_impl.rs index 6df6f3d..ad1bb3b 100644 --- a/recallable-macro/src/context/from_impl.rs +++ b/recallable-macro/src/context/from_impl.rs @@ -1,116 +1,14 @@ +mod enums; +mod structs; + use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::WherePredicate; -use crate::context::{ - CodegenEnv, FieldIr, FieldStrategy, StructIr, StructShape, collect_shared_memento_bounds, -}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] -pub(crate) fn gen_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let struct_type = ir.struct_type(); - let memento_type = ir.memento_type(); - let where_clause = build_from_where_clause(ir, env); - let from_method = build_from_method(ir); - - quote! { - impl #impl_generics ::core::convert::From<#struct_type> - for #memento_type - #where_clause { - #from_method - } - } -} - -fn build_from_method(ir: &StructIr) -> TokenStream2 { - let struct_type = ir.struct_type(); - let fn_body = build_from_body(ir); - - quote! { - #[inline] - fn from(value: #struct_type) -> Self { - #fn_body - } - } -} - -fn build_from_body(ir: &StructIr) -> TokenStream2 { - match ir.shape() { - StructShape::Named => build_named_from_body(ir), - StructShape::Unnamed => build_unnamed_from_body(ir), - StructShape::Unit => build_unit_from_body(ir), +pub(crate) fn gen_from_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_from_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_from_impl(ir, env), } } - -fn build_named_from_body(ir: &StructIr) -> TokenStream2 { - let inits = ir - .memento_fields() - .map(build_named_from_field) - .chain(ir.has_synthetic_marker().then(build_named_marker_init)); - - quote! { Self { #(#inits),* } } -} - -fn build_named_from_field(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - let value = build_from_expr(field); - quote! { #member: #value } -} - -fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { - let values = ir - .memento_fields() - .map(build_from_expr) - .chain(ir.has_synthetic_marker().then(build_marker_init)); - - quote! { Self(#(#values),*) } -} - -fn build_unit_from_body(ir: &StructIr) -> TokenStream2 { - if ir.has_synthetic_marker() { - quote! { Self { _recallable_marker: ::core::marker::PhantomData } } - } else { - quote! { Self } - } -} - -fn build_marker_init() -> TokenStream2 { - quote! { ::core::marker::PhantomData } -} - -fn build_named_marker_init() -> TokenStream2 { - quote! { _recallable_marker: ::core::marker::PhantomData } -} - -fn build_from_expr(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - match &field.strategy { - FieldStrategy::StoreAsSelf => quote! { value.#member }, - FieldStrategy::StoreAsMemento => { - quote! { ::core::convert::From::from(value.#member) } - } - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - } -} - -fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let bounds = collect_from_bounds(ir, env); - ir.extend_where_clause(bounds) -} - -fn collect_from_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - let recallable_trait = &env.recallable_trait; - let mut bounds: Vec<_> = ir - .recallable_params() - .flat_map(|ty| -> [WherePredicate; 2] { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, - ] - }) - .collect(); - bounds.extend(collect_shared_memento_bounds(ir, env)); - bounds.extend(ir.whole_type_from_bounds(recallable_trait)); - bounds -} diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs new file mode 100644 index 0000000..5a825f3 --- /dev/null +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -0,0 +1,129 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::{ToTokens, quote}; +use syn::WherePredicate; + +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, build_binding_ident, collect_shared_memento_bounds_for_enum, +}; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, build_from_value_expr}; + +#[must_use] +pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let memento_type = ir.memento_type(); + let where_clause = build_enum_from_where_clause(ir, env); + let from_method = build_enum_from_method(ir); + + quote! { + #[automatically_derived] + impl #impl_generics ::core::convert::From<#enum_type> + for #memento_type + #where_clause { + #from_method + } + } +} + +fn build_enum_from_method(ir: &EnumIr) -> TokenStream2 { + let enum_type = ir.enum_type(); + let fn_body = build_enum_from_body(ir); + + quote! { + #[inline] + fn from(value: #enum_type) -> Self { + #fn_body + } + } +} + +fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { + let enum_name = ir.name(); + let memento_name = ir.memento_name(); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_source_pattern(variant); + let expr = build_variant_from_expr(variant); + quote! { #enum_name::#variant_name #pattern => #memento_name::#variant_name #expr } + }); + + quote! { + match value { + #(#arms),* + } + } +} + +fn build_variant_source_pattern(variant: &VariantIr<'_>) -> Option { + match variant.shape { + VariantShape::Named => { + let patterns = variant.indexed_fields().map(|(index, field)| { + if field.strategy.is_skip() { + let member = &field.member; + quote! { #member: _ } + } else { + build_binding_ident(field, index).to_token_stream() + } + }); + Some(quote! { { #(#patterns),* } }) + } + VariantShape::Unnamed => { + let patterns = variant + .indexed_fields() + .map(|(index, field)| build_binding_pattern(field, index)); + Some(quote! { ( #(#patterns),* ) }) + } + VariantShape::Unit => None, + } +} + +fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { + if field.strategy.is_skip() { + quote! { _ } + } else { + build_binding_ident(field, index).to_token_stream() + } +} + +fn build_variant_from_expr(variant: &VariantIr<'_>) -> Option { + let mut kept_fields = variant.kept_fields().peekable(); + let non_empty = kept_fields.peek().is_some(); + match variant.shape { + VariantShape::Named if non_empty => { + let inits = kept_fields.map(|(index, field)| { + let member = &field.member; + let binding = build_binding_ident(field, index); + let value = build_from_binding_expr(field, &binding); + quote! { #member: #value } + }); + Some(quote! { { #(#inits),* } }) + } + VariantShape::Unnamed if non_empty => { + let values = kept_fields.map(|(index, field)| { + let binding = build_binding_ident(field, index); + build_from_binding_expr(field, &binding) + }); + Some(quote! { ( #(#values),* ) }) + } + _ => None, + } +} + +fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { + build_from_value_expr(quote! { #binding }, field.strategy) +} + +fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let recallable_trait = &env.recallable_trait; + ir.extend_where_clause( + ir.recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .chain(collect_shared_memento_bounds_for_enum(ir, env)) + .chain(ir.whole_type_from_bounds(recallable_trait)), + ) +} diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs new file mode 100644 index 0000000..cca6b54 --- /dev/null +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -0,0 +1,96 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, build_from_value_expr}; +use crate::context::internal::structs::{StructIr, StructShape, collect_shared_memento_bounds}; + +#[must_use] +pub(crate) fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let struct_type = ir.struct_type(); + let memento_type = ir.memento_type(); + let where_clause = build_from_where_clause(ir, env); + let from_method = build_from_method(ir); + + quote! { + #[automatically_derived] + impl #impl_generics ::core::convert::From<#struct_type> + for #memento_type + #where_clause { + #from_method + } + } +} + +fn build_from_method(ir: &StructIr) -> TokenStream2 { + let struct_type = ir.struct_type(); + let fn_body = build_from_body(ir); + + quote! { + #[inline] + fn from(value: #struct_type) -> Self { + #fn_body + } + } +} + +fn build_from_body(ir: &StructIr) -> TokenStream2 { + match ir.shape() { + StructShape::Named => build_named_from_body(ir), + StructShape::Unnamed => build_unnamed_from_body(ir), + StructShape::Unit => quote! { Self }, + } +} + +fn build_named_from_body(ir: &StructIr) -> TokenStream2 { + let inits = ir + .memento_fields() + .map(build_named_from_field) + .chain(ir.has_synthetic_marker().then(build_named_marker_init)); + + quote! { Self { #(#inits),* } } +} + +fn build_named_from_field(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + let value = build_from_expr(field); + quote! { #member: #value } +} + +fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { + let values = ir + .memento_fields() + .map(build_from_expr) + .chain(ir.has_synthetic_marker().then(build_marker_init)); + + quote! { Self(#(#values),*) } +} + +fn build_marker_init() -> TokenStream2 { + quote! { ::core::marker::PhantomData } +} + +fn build_named_marker_init() -> TokenStream2 { + quote! { _recallable_marker: ::core::marker::PhantomData } +} + +fn build_from_expr(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + build_from_value_expr(quote! { value.#member }, field.strategy) +} + +fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let recallable_trait = &env.recallable_trait; + ir.extend_where_clause( + ir.recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .chain(collect_shared_memento_bounds(ir, env)) + .chain(ir.whole_type_from_bounds(recallable_trait)), + ) +} diff --git a/recallable-macro/src/context/internal.rs b/recallable-macro/src/context/internal.rs index d5b7d15..a3ede98 100644 --- a/recallable-macro/src/context/internal.rs +++ b/recallable-macro/src/context/internal.rs @@ -1,14 +1,5 @@ //! Semantic analysis and shared helper backend for the `context` codegen facade. -mod bounds; -mod fields; -mod generics; -mod ir; -mod lifetime; -mod util; - -pub(crate) use bounds::{collect_recall_like_bounds, collect_shared_memento_bounds}; -pub(crate) use fields::has_recallable_skip_attr; -pub(crate) use generics::is_generic_type_param; -pub(crate) use ir::{CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, StructShape}; -pub(crate) use util::crate_path; +pub(crate) mod enums; +pub(crate) mod shared; +pub(crate) mod structs; diff --git a/recallable-macro/src/context/internal/enums.rs b/recallable-macro/src/context/internal/enums.rs new file mode 100644 index 0000000..72b8c2e --- /dev/null +++ b/recallable-macro/src/context/internal/enums.rs @@ -0,0 +1,7 @@ +mod bounds; +mod ir; + +pub(crate) use bounds::{ + collect_recall_like_bounds_for_enum, collect_shared_memento_bounds_for_enum, +}; +pub(crate) use ir::{EnumIr, VariantIr, VariantShape, build_binding_ident}; diff --git a/recallable-macro/src/context/internal/enums/bounds.rs b/recallable-macro/src/context/internal/enums/bounds.rs new file mode 100644 index 0000000..b38b7a9 --- /dev/null +++ b/recallable-macro/src/context/internal/enums/bounds.rs @@ -0,0 +1,42 @@ +use proc_macro2::TokenStream as TokenStream2; +use syn::WherePredicate; + +use crate::context::internal::enums::EnumIr; +use crate::context::internal::shared::{ + CodegenEnv, MementoTraitSpec, collect_recall_like_bounds as collect_shared_recall_like_bounds, + collect_shared_memento_bounds as collect_common_memento_bounds, +}; + +#[must_use] +pub(crate) fn collect_shared_memento_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, +) -> Vec { + collect_shared_memento_bounds_with_spec_for_enum(ir, env, &ir.memento_trait_spec()) +} + +#[must_use] +pub(crate) fn collect_recall_like_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, +) -> Vec { + collect_recall_like_bounds_with_spec_for_enum(ir, env, direct_bound, &ir.memento_trait_spec()) +} + +fn collect_shared_memento_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + collect_common_memento_bounds(ir, env, memento_trait_spec) +} + +fn collect_recall_like_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + collect_shared_recall_like_bounds(ir, env, direct_bound, memento_trait_spec) +} diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs new file mode 100644 index 0000000..8c46e17 --- /dev/null +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -0,0 +1,263 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{DataEnum, DeriveInput, Generics, Ident, ImplGenerics, Visibility, WhereClause}; + +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::FieldMember; +use crate::context::internal::shared::bounds::MementoTraitSpec; +use crate::context::internal::shared::codegen::CodegenItemIr; +use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; +use crate::context::internal::shared::generics::{ + GenericParamLookup, GenericParamPlan, collect_marker_param_indices, plan_memento_generics, +}; +use crate::context::internal::shared::item::has_skip_memento_default_derives; +use crate::context::internal::shared::lifetime::{ + collect_item_lifetimes, is_phantom_data, validate_no_borrowed_fields, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VariantShape { + Named, + Unnamed, + Unit, +} + +#[derive(Debug)] +pub(crate) struct VariantIr<'a> { + pub(crate) name: &'a Ident, + pub(crate) shape: VariantShape, + pub(crate) fields: Vec>, +} + +impl<'a> VariantIr<'a> { + pub(crate) fn indexed_fields(&self) -> impl Iterator)> { + self.fields.iter().enumerate() + } + + pub(crate) fn kept_fields(&self) -> impl Iterator)> { + self.indexed_fields() + .filter(|(_, field)| !field.strategy.is_skip()) + } + + pub(crate) fn kept_bindings(&self) -> impl Iterator + '_ { + self.kept_fields() + .map(|(index, field)| build_binding_ident(field, index)) + } +} + +#[derive(Debug)] +pub(crate) struct EnumIr<'a> { + name: &'a Ident, + visibility: &'a Visibility, + generics: &'a Generics, + variants: Vec>, + memento_name: Ident, + generic_type_param_idents: HashSet<&'a Ident>, + generic_params: Vec>, + memento_where_clause: Option, + marker_param_indices: Vec, + skip_memento_default_derives: bool, +} + +pub(crate) fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + FieldMember::Named(name) => (*name).clone(), + FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} + +const ENUM_RECALL_MANUAL_ONLY_ERROR: &str = "enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and \ + implement `Recall` or `TryRecall` manually"; +const ENUM_MODEL_MANUAL_ONLY_ERROR: &str = "`#[recallable_model]` on enums requires assignment-only variants; complex enums should \ + derive `Recallable` and implement `Recall` or `TryRecall` manually"; + +fn collect_variant_irs<'a>( + variants: &'a syn::punctuated::Punctuated, + generic_lookup: &GenericParamLookup<'a>, +) -> syn::Result<( + crate::context::internal::shared::generics::GenericUsage, + Vec>, +)> { + let mut usage = crate::context::internal::shared::generics::GenericUsage::default(); + let mut variant_irs = Vec::with_capacity(variants.len()); + + for variant in variants { + let (variant_usage, fields) = collect_field_irs(&variant.fields, generic_lookup)?; + usage.retained.extend(variant_usage.retained); + usage + .recallable_type_params + .extend(variant_usage.recallable_type_params); + + let shape = match &variant.fields { + syn::Fields::Named(_) => VariantShape::Named, + syn::Fields::Unnamed(_) => VariantShape::Unnamed, + syn::Fields::Unit => VariantShape::Unit, + }; + + variant_irs.push(VariantIr { + name: &variant.ident, + shape, + fields, + }); + } + + Ok((usage, variant_irs)) +} + +impl<'a> EnumIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + let syn::Data::Enum(DataEnum { variants, .. }) = &input.data else { + unreachable!("EnumIr::analyze only receives enum inputs"); + }; + let item_lifetimes = collect_item_lifetimes(&input.generics); + for variant in variants { + validate_no_borrowed_fields(&variant.fields, &item_lifetimes)?; + } + + let generic_lookup = GenericParamLookup::new(&input.generics); + let generic_type_param_idents = input + .generics + .type_params() + .map(|param| ¶m.ident) + .collect(); + let (usage, variant_irs) = collect_variant_irs(variants, &generic_lookup)?; + let (generic_params, memento_where_clause) = + plan_memento_generics(&input.generics, usage, &generic_lookup); + let marker_param_indices = collect_marker_param_indices( + variant_irs.iter().flat_map(|variant| variant.fields.iter()), + &generic_params, + &generic_lookup, + ); + + Ok(Self { + name: &input.ident, + visibility: &input.vis, + generics: &input.generics, + variants: variant_irs, + memento_name: quote::format_ident!("{}Memento", input.ident), + generic_type_param_idents, + generic_params, + memento_where_clause, + marker_param_indices, + skip_memento_default_derives: has_skip_memento_default_derives(input)?, + }) + } + + pub(crate) const fn name(&self) -> &'a Ident { + self.name + } + + pub(crate) fn enum_type(&self) -> TokenStream2 { + let name = &self.name; + let (_, type_generics, _) = self.generics.split_for_impl(); + quote! { #name #type_generics } + } + + pub(crate) const fn memento_name(&self) -> &Ident { + &self.memento_name + } + + pub(crate) const fn visibility(&self) -> &'a Visibility { + self.visibility + } + + pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { + let (impl_generics, _, _) = self.generics.split_for_impl(); + impl_generics + } + + pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + #[must_use] + pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { + MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) + } + + pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { + self.memento_where_clause.as_ref() + } + + pub(crate) fn variants(&self) -> impl Iterator> { + self.variants.iter() + } + + fn manual_only_field(&self) -> Option<&FieldIr<'a>> { + self.variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .find(|field| { + !(matches!(field.strategy, FieldStrategy::StoreAsSelf) + || (field.strategy.is_skip() && is_phantom_data(field.ty))) + }) + } + + pub(crate) fn supports_derived_recall(&self) -> bool { + self.manual_only_field().is_none() + } + + pub(crate) fn ensure_recall_derive_allowed(&self) -> syn::Result<()> { + if let Some(field) = self.manual_only_field() { + return Err(syn::Error::new_spanned( + field.source, + ENUM_RECALL_MANUAL_ONLY_ERROR, + )); + } + + Ok(()) + } + + pub(crate) fn ensure_model_derive_allowed(&self) -> syn::Result<()> { + if self.manual_only_field().is_some() { + return Err(syn::Error::new_spanned( + self.name, + ENUM_MODEL_MANUAL_ONLY_ERROR, + )); + } + + Ok(()) + } +} + +impl<'a> CodegenItemIr<'a> for EnumIr<'a> { + type Fields<'b> + = std::iter::FlatMap< + std::slice::Iter<'b, VariantIr<'a>>, + std::slice::Iter<'b, FieldIr<'a>>, + fn(&'b VariantIr<'a>) -> std::slice::Iter<'b, FieldIr<'a>>, + > + where + Self: 'b, + 'a: 'b; + + fn generics(&self) -> &'a Generics { + self.generics + } + + fn memento_name(&self) -> &Ident { + &self.memento_name + } + + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + fn generic_params(&self) -> &[GenericParamPlan<'a>] { + &self.generic_params + } + + fn marker_param_indices(&self) -> &[usize] { + &self.marker_param_indices + } + + fn all_fields(&self) -> Self::Fields<'_> { + self.variants.iter().flat_map(variant_fields) + } +} + +fn variant_fields<'a, 'b>(variant: &'b VariantIr<'a>) -> std::slice::Iter<'b, FieldIr<'a>> { + variant.fields.iter() +} diff --git a/recallable-macro/src/context/internal/ir.rs b/recallable-macro/src/context/internal/ir.rs deleted file mode 100644 index bb1052e..0000000 --- a/recallable-macro/src/context/internal/ir.rs +++ /dev/null @@ -1,354 +0,0 @@ -use std::collections::HashSet; - -use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, quote}; -use syn::{ - DeriveInput, Fields, Generics, Ident, ImplGenerics, Index, Type, Visibility, WhereClause, - WherePredicate, -}; - -use crate::context::SERDE_ENABLED; - -use super::bounds::MementoTraitSpec; - -use super::fields::{collect_field_irs, extract_struct_fields}; -use super::generics::{ - GenericParamPlan, collect_marker_param_indices, is_generic_type_param, marker_component, - plan_memento_generics, -}; -use super::lifetime::{collect_struct_lifetimes, validate_no_borrowed_fields}; -use super::util::{crate_path, is_recallable_attr}; - -/// The structural shape of the source struct and generated memento. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StructShape { - Named, - Unnamed, - Unit, -} - -impl StructShape { - const fn from_fields(fields: &Fields) -> Self { - match fields { - Fields::Named(_) => Self::Named, - Fields::Unnamed(_) => Self::Unnamed, - Fields::Unit => Self::Unit, - } - } -} - -/// How a field is represented in the generated memento. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FieldStrategy { - /// Field excluded from memento entirely. - Skip, - /// Field copied as-is into memento (type unchanged). - StoreAsSelf, - /// Field stored as its memento type, recalled recursively. - StoreAsMemento, -} - -impl FieldStrategy { - pub(super) const fn is_skip(self) -> bool { - matches!(self, Self::Skip) - } -} - -#[derive(Debug)] -pub(crate) struct CodegenEnv { - /// Fully qualified path to the `Recallable` trait. - pub(crate) recallable_trait: TokenStream2, - /// Fully qualified path to the `Recall` trait. - pub(crate) recall_trait: TokenStream2, -} - -impl CodegenEnv { - #[must_use] - pub(crate) fn resolve() -> Self { - let crate_path = crate_path(); - Self { - recallable_trait: quote! { #crate_path::Recallable }, - recall_trait: quote! { #crate_path::Recall }, - } - } -} - -#[derive(Debug)] -pub(crate) struct FieldIr<'a> { - pub(crate) memento_index: Option, - pub(crate) member: FieldMember<'a>, - pub(crate) ty: &'a Type, - pub(crate) strategy: FieldStrategy, -} - -/// How a field member is referenced in generated tokens. -#[derive(Debug, Clone)] -pub(crate) enum FieldMember<'a> { - /// Access by named field, such as `value`. - Named(&'a Ident), - /// Access by tuple-field index, such as `.0`. - Unnamed(Index), -} - -impl<'a> ToTokens for FieldMember<'a> { - fn to_tokens(&self, tokens: &mut TokenStream2) { - match self { - FieldMember::Named(ident) => ident.to_tokens(tokens), - FieldMember::Unnamed(index) => index.to_tokens(tokens), - } - } -} - -#[derive(Debug)] -pub(crate) struct StructIr<'a> { - name: &'a Ident, - visibility: &'a Visibility, - generics: &'a Generics, - shape: StructShape, - fields: Vec>, - memento_name: Ident, - generic_type_param_idents: HashSet<&'a Ident>, - generic_params: Vec>, - memento_where_clause: Option, - marker_param_indices: Vec, - skip_memento_default_derives: bool, -} - -fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { - let mut skip_memento_default_derives = false; - for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip_memento_default_derives") { - skip_memento_default_derives = true; - Ok(()) - } else if meta.path.is_ident("skip") { - Err(meta.error("`skip` is a field-level attribute, not a struct-level attribute")) - } else { - Err(meta.error("unrecognized `recallable` parameter")) - } - })?; - } - Ok(skip_memento_default_derives) -} - -impl<'a> StructIr<'a> { - pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { - let fields = extract_struct_fields(input)?; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - validate_no_borrowed_fields(fields, &struct_lifetimes)?; - - let shape = StructShape::from_fields(fields); - let memento_name = quote::format_ident!("{}Memento", input.ident); - let generic_lookup = super::generics::GenericParamLookup::new(&input.generics); - let generic_type_param_idents = input - .generics - .type_params() - .map(|param| ¶m.ident) - .collect(); - let (usage, field_irs) = collect_field_irs(fields, &struct_lifetimes, &generic_lookup)?; - let (generic_params, memento_where_clause) = - plan_memento_generics(&input.generics, usage, &generic_lookup); - let marker_param_indices = - collect_marker_param_indices(&field_irs, &generic_params, &generic_lookup); - let skip_memento_default_derives = has_skip_memento_default_derives(input)?; - - Ok(Self { - name: &input.ident, - visibility: &input.vis, - generics: &input.generics, - shape, - fields: field_irs, - memento_name, - generic_type_param_idents, - generic_params, - memento_where_clause, - marker_param_indices, - skip_memento_default_derives, - }) - } - - pub(crate) fn struct_type(&self) -> TokenStream2 { - let name = &self.name; - let (_, type_generics, _) = self.generics.split_for_impl(); - quote! { #name #type_generics } - } - - pub(crate) const fn memento_name(&self) -> &Ident { - &self.memento_name - } - - pub(crate) const fn visibility(&self) -> &'a Visibility { - self.visibility - } - - #[must_use] - pub(crate) const fn shape(&self) -> StructShape { - self.shape - } - - pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { - let (impl_generics, _, _) = self.generics.split_for_impl(); - impl_generics - } - - pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { - &self.generic_type_param_idents - } - - #[must_use] - pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { - MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) - } - - #[must_use] - pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { - let mut params = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::decl_param) - .peekable(); - - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } - } - - pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { - self.memento_where_clause.as_ref() - } - - #[must_use] - pub(crate) fn generated_memento_shape(&self) -> StructShape { - if self.shape == StructShape::Unit && self.has_synthetic_marker() { - StructShape::Named - } else { - self.shape - } - } - - #[must_use] - pub(crate) const fn has_synthetic_marker(&self) -> bool { - !self.marker_param_indices.is_empty() - } - - #[must_use] - pub(crate) fn synthetic_marker_type(&self) -> Option { - if self.marker_param_indices.is_empty() { - return None; - } - - let components = self - .marker_param_indices - .iter() - .map(|&index| marker_component(self.generic_params[index].param)); - - Some(quote! { - ::core::marker::PhantomData<(#(#components,)*)> - }) - } - - pub(crate) fn recallable_params(&self) -> impl Iterator { - self.generic_params - .iter() - .filter_map(GenericParamPlan::recallable_ident) - } - - #[must_use] - pub(crate) fn memento_type(&self) -> TokenStream2 { - let name = &self.memento_name; - let mut args = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::type_arg) - .peekable(); - - if args.peek().is_none() { - quote! { #name } - } else { - quote! { #name<#(#args),*> } - } - } - - pub(crate) fn memento_fields(&self) -> impl Iterator> { - self.fields.iter().filter(|field| !field.strategy.is_skip()) - } - - pub(super) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) - .collect() - } - - fn whole_type_bound_targets(&self) -> impl Iterator { - let mut seen = HashSet::new(); - - self.fields - .iter() - .filter_map(move |field| match field.strategy { - FieldStrategy::StoreAsMemento - if !is_generic_type_param(field.ty, &self.generic_type_param_idents) - && seen.insert(field.ty) => - { - Some(field.ty) - } - _ => None, - }) - } - - #[must_use] - pub(super) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { - self.whole_type_bound_targets() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn whole_type_memento_bounds( - &self, - recallable_trait: &TokenStream2, - bound: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) - } - - pub(crate) fn whole_type_from_bounds( - &self, - recallable_trait: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .flat_map(move |ty| { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, - ] - }) - } - - pub(crate) fn extend_where_clause( - &self, - extra: impl IntoIterator, - ) -> Option { - let mut where_clause = self.generics.where_clause.clone(); - let mut extra_iter = extra.into_iter().peekable(); - if extra_iter.peek().is_none() { - where_clause - } else { - where_clause - .get_or_insert(syn::parse_quote! { where }) - .predicates - .extend(extra_iter); - - where_clause - } - } -} diff --git a/recallable-macro/src/context/internal/shared.rs b/recallable-macro/src/context/internal/shared.rs new file mode 100644 index 0000000..f5a6832 --- /dev/null +++ b/recallable-macro/src/context/internal/shared.rs @@ -0,0 +1,17 @@ +pub(crate) mod bounds; +pub(crate) mod codegen; +pub(crate) mod env; +pub(crate) mod fields; +pub(crate) mod generics; +pub(crate) mod item; +pub(crate) mod lifetime; +pub(crate) mod util; + +pub(crate) use bounds::{ + MementoTraitSpec, collect_recall_like_bounds, collect_shared_memento_bounds, +}; +pub(crate) use codegen::{CodegenItemIr, build_from_value_expr, build_memento_field_tokens}; +pub(crate) use env::CodegenEnv; +pub(crate) use fields::{FieldIr, FieldMember, FieldStrategy, has_recallable_skip_attr}; +pub(crate) use item::ItemIr; +pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/shared/bounds.rs b/recallable-macro/src/context/internal/shared/bounds.rs new file mode 100644 index 0000000..c5a80ce --- /dev/null +++ b/recallable-macro/src/context/internal/shared/bounds.rs @@ -0,0 +1,157 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use super::{CodegenEnv, CodegenItemIr}; + +#[derive(Debug)] +pub(crate) struct MementoTraitSpec { + serde_enabled: bool, + derive_off: bool, +} + +impl MementoTraitSpec { + pub(crate) const fn new(serde_enabled: bool, derive_off: bool) -> Self { + Self { + serde_enabled, + derive_off, + } + } + + #[must_use] + pub(crate) fn derive_attr(&self) -> TokenStream2 { + match (self.has_common_traits(), self.serde_enabled) { + (true, true) => { + quote! { + #[derive( + ::core::clone::Clone, + ::core::fmt::Debug, + ::core::cmp::PartialEq, + ::serde::Deserialize + )] + } + } + (true, false) => { + quote! { + #[derive( + ::core::clone::Clone, + ::core::fmt::Debug, + ::core::cmp::PartialEq + )] + } + } + (false, true) => quote! { #[derive(::serde::Deserialize)] }, + (false, false) => quote! {}, + } + } + + pub(crate) fn common_bound_tokens(&self) -> TokenStream2 { + if self.has_common_traits() { + quote! { ::core::clone::Clone + ::core::fmt::Debug + ::core::cmp::PartialEq } + } else { + quote! {} + } + } + + pub(crate) fn serde_nested_bound(&self) -> Option { + self.serde_enabled + .then_some(quote!(::serde::de::DeserializeOwned)) + } + + const fn has_common_traits(&self) -> bool { + !self.derive_off + } +} + +#[must_use] +pub(crate) fn collect_shared_memento_bounds<'a, T>( + ir: &T, + env: &CodegenEnv, + memento_trait_spec: &MementoTraitSpec, +) -> Vec +where + T: CodegenItemIr<'a>, +{ + let recallable_trait = &env.recallable_trait; + let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); + + let mut bounds = ir + .recallable_memento_bounds(&memento_trait_bounds) + .collect::>(); + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); + if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); + } + + bounds +} + +#[must_use] +pub(crate) fn collect_recall_like_bounds<'a, T>( + ir: &T, + env: &CodegenEnv, + direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, +) -> Vec +where + T: CodegenItemIr<'a>, +{ + let shared_memento_bounds = collect_shared_memento_bounds(ir, env, memento_trait_spec); + let shared_param_bound_count = ir.recallable_params().count(); + + let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); + bounds.extend( + shared_memento_bounds + .iter() + .take(shared_param_bound_count) + .cloned(), + ); + bounds.extend(ir.whole_type_bounds(direct_bound)); + bounds.extend( + shared_memento_bounds + .into_iter() + .skip(shared_param_bound_count), + ); + bounds +} + +#[cfg(test)] +mod tests { + use super::MementoTraitSpec; + + #[test] + fn memento_trait_spec_formats_derives_for_serde_modes() { + let serde_derives = MementoTraitSpec::new(true, false).derive_attr().to_string(); + assert!(serde_derives.contains(":: core :: clone :: Clone")); + assert!(serde_derives.contains(":: core :: fmt :: Debug")); + assert!(serde_derives.contains(":: core :: cmp :: PartialEq")); + assert!(serde_derives.contains(":: serde :: Deserialize")); + + let no_serde_derives = MementoTraitSpec::new(false, false) + .derive_attr() + .to_string(); + assert!(no_serde_derives.contains(":: core :: clone :: Clone")); + assert!(no_serde_derives.contains(":: core :: fmt :: Debug")); + assert!(no_serde_derives.contains(":: core :: cmp :: PartialEq")); + assert!(!no_serde_derives.contains(":: serde :: Deserialize")); + } + + #[test] + fn memento_trait_spec_derive_off_suppresses_common_traits() { + let derive_off_serde = MementoTraitSpec::new(true, true).derive_attr().to_string(); + assert!(!derive_off_serde.contains("Clone")); + assert!(!derive_off_serde.contains("Debug")); + assert!(!derive_off_serde.contains("PartialEq")); + assert!(derive_off_serde.contains(":: serde :: Deserialize")); + + let derive_off_no_serde = MementoTraitSpec::new(false, true).derive_attr().to_string(); + assert!(derive_off_no_serde.is_empty()); + } + + #[test] + fn memento_trait_spec_derive_off_empties_common_bounds() { + let spec = MementoTraitSpec::new(true, true); + assert!(spec.common_bound_tokens().is_empty()); + assert!(spec.serde_nested_bound().is_some()); + } +} diff --git a/recallable-macro/src/context/internal/shared/codegen.rs b/recallable-macro/src/context/internal/shared/codegen.rs new file mode 100644 index 0000000..904191c --- /dev/null +++ b/recallable-macro/src/context/internal/shared/codegen.rs @@ -0,0 +1,241 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{GenericParam, Generics, Ident, Type, WhereClause, WherePredicate}; + +use super::fields::{FieldIr, FieldMember, FieldStrategy}; +use super::generics::{GenericParamPlan, is_generic_type_param, marker_component}; + +pub(crate) trait CodegenItemIr<'a> { + type Fields<'b>: Iterator> + where + Self: 'b, + 'a: 'b; + + fn generics(&self) -> &'a Generics; + fn memento_name(&self) -> &Ident; + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident>; + fn generic_params(&self) -> &[GenericParamPlan<'a>]; + fn marker_param_indices(&self) -> &[usize]; + fn all_fields(&self) -> Self::Fields<'_>; + + #[must_use] + fn memento_decl_generics(&self) -> Option { + let mut params = self + .generic_params() + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::decl_param) + .peekable(); + + params.peek().is_some().then_some(quote! { <#(#params),*> }) + } + + #[must_use] + fn memento_type(&self) -> TokenStream2 { + let name = self.memento_name(); + let mut args = self + .generic_params() + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::type_arg) + .peekable(); + + if args.peek().is_none() { + quote! { #name } + } else { + quote! { #name<#(#args),*> } + } + } + + #[must_use] + fn synthetic_marker_type(&self) -> Option { + let marker_param_indices = self.marker_param_indices(); + if marker_param_indices.is_empty() { + return None; + } + + let generic_params = self.generic_params(); + let components = marker_param_indices + .iter() + .map(|&index| marker_component(generic_params[index].param)); + + Some(quote! { + ::core::marker::PhantomData<(#(#components,)*)> + }) + } + + fn synthetic_marker_helper_defs(&self) -> impl Iterator + '_ { + self.marker_param_indices() + .iter() + .filter_map(|&index| const_marker_helper_def(self.generic_params()[index].param)) + } + + fn recallable_params<'b>(&'b self) -> impl Iterator + 'b + where + 'a: 'b, + { + self.generic_params() + .iter() + .filter_map(GenericParamPlan::recallable_ident) + } + + fn recallable_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.recallable_params() + .map(move |ty| syn::parse_quote! { #ty: #bound }) + } + + fn recallable_memento_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.recallable_params() + .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) + } + + fn whole_type_bound_targets<'b>(&'b self) -> impl Iterator + 'b + where + 'a: 'b, + { + let generic_type_param_idents = self.generic_type_param_idents(); + let mut seen = HashSet::new(); + + self.all_fields() + .filter_map(move |field| match field.strategy { + FieldStrategy::StoreAsMemento + if !is_generic_type_param(field.ty, generic_type_param_idents) + && seen.insert(field.ty) => + { + Some(field.ty) + } + _ => None, + }) + } + + fn whole_type_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { #ty: #bound }) + } + + fn whole_type_memento_bounds<'b>( + &'b self, + recallable_trait: &'b TokenStream2, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + } + + fn whole_type_from_bounds<'b>( + &'b self, + recallable_trait: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets().flat_map(move |ty| { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, + ] + }) + } + + fn extend_where_clause( + &self, + extra: impl IntoIterator, + ) -> Option { + let mut where_clause = self.generics().where_clause.clone(); + let mut extra_iter = extra.into_iter().peekable(); + if extra_iter.peek().is_none() { + where_clause + } else { + where_clause + .get_or_insert(syn::parse_quote! { where }) + .predicates + .extend(extra_iter); + + where_clause + } + } +} + +fn const_marker_helper_ident(ident: &Ident) -> Ident { + format_ident!("__RecallableConstMarker_{ident}") +} + +fn const_marker_helper_def(param: &GenericParam) -> Option { + match param { + GenericParam::Const(param) => { + let helper_ident = const_marker_helper_ident(¶m.ident); + let ty = ¶m.ty; + + Some(quote! { + #[doc(hidden)] + struct #helper_ident; + }) + } + _ => None, + } +} + +#[must_use] +pub(crate) fn build_memento_field_ty( + field: &FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let ty = field.ty; + match field.strategy { + FieldStrategy::StoreAsMemento => { + if is_generic_type_param(ty, generic_type_params) { + quote! { #ty::Memento } + } else { + quote! { <#ty as #recallable_trait>::Memento } + } + } + FieldStrategy::StoreAsSelf => quote! { #ty }, + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} + +#[must_use] +pub(crate) fn build_memento_field_tokens( + field: &FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let field_ty = build_memento_field_ty(field, recallable_trait, generic_type_params); + match &field.member { + FieldMember::Named(name) => quote! { #name: #field_ty }, + FieldMember::Unnamed(_) => quote! { #field_ty }, + } +} + +#[must_use] +pub(crate) fn build_from_value_expr(expr: TokenStream2, strategy: FieldStrategy) -> TokenStream2 { + match strategy { + FieldStrategy::StoreAsSelf => expr, + FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#expr) }, + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} diff --git a/recallable-macro/src/context/internal/shared/env.rs b/recallable-macro/src/context/internal/shared/env.rs new file mode 100644 index 0000000..f1d62c6 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/env.rs @@ -0,0 +1,21 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; + +use super::util::crate_path; + +#[derive(Debug)] +pub(crate) struct CodegenEnv { + pub(crate) recallable_trait: TokenStream2, + pub(crate) recall_trait: TokenStream2, +} + +impl CodegenEnv { + #[must_use] + pub(crate) fn resolve() -> Self { + let crate_path = crate_path(); + Self { + recallable_trait: quote! { #crate_path::Recallable }, + recall_trait: quote! { #crate_path::Recall }, + } + } +} diff --git a/recallable-macro/src/context/internal/fields.rs b/recallable-macro/src/context/internal/shared/fields.rs similarity index 71% rename from recallable-macro/src/context/internal/fields.rs rename to recallable-macro/src/context/internal/shared/fields.rs index df1b161..ca212f6 100644 --- a/recallable-macro/src/context/internal/fields.rs +++ b/recallable-macro/src/context/internal/shared/fields.rs @@ -1,34 +1,56 @@ -use std::collections::HashSet; - -use syn::{Data, DataStruct, DeriveInput, Field, Fields, Index, Meta, PathArguments, Type}; +use proc_macro2::TokenStream as TokenStream2; +use quote::ToTokens; +use syn::{Field, Ident, Index, Meta, PathArguments, Type}; use super::generics::{ BareTypeParam, GenericParamLookup, GenericUsage, collect_generic_dependencies_in_type, }; -use super::ir::{FieldIr, FieldMember, FieldStrategy}; -use super::lifetime::{field_uses_struct_lifetime, is_phantom_data}; +use super::lifetime::is_phantom_data; use super::util::is_recallable_attr; -/// Field-level behavior inferred from `#[recallable]` attributes during analysis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FieldBehavior { - /// Keep the field in the memento with its original type. - Keep, - /// Store the field as an inner memento and recall this field recursively. - Recall, +pub(crate) enum FieldStrategy { + Skip, + StoreAsSelf, + StoreAsMemento, } -pub(super) fn extract_struct_fields(input: &DeriveInput) -> syn::Result<&Fields> { - if let Data::Struct(DataStruct { fields, .. }) = &input.data { - Ok(fields) - } else { - Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs", - )) +impl FieldStrategy { + pub(crate) const fn is_skip(self) -> bool { + matches!(self, Self::Skip) } } +#[derive(Debug)] +pub(crate) struct FieldIr<'a> { + pub(crate) source: &'a Field, + pub(crate) memento_index: Option, + pub(crate) member: FieldMember<'a>, + pub(crate) ty: &'a Type, + pub(crate) strategy: FieldStrategy, +} + +#[derive(Debug, Clone)] +pub(crate) enum FieldMember<'a> { + Named(&'a Ident), + Unnamed(Index), +} + +impl<'a> ToTokens for FieldMember<'a> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Named(ident) => ident.to_tokens(tokens), + Self::Unnamed(index) => index.to_tokens(tokens), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldBehavior { + Keep, + Recall, +} + fn determine_field_behavior(field: &Field) -> syn::Result> { let mut saw_recall = false; let mut saw_skip = false; @@ -63,8 +85,8 @@ fn determine_field_behavior(field: &Field) -> syn::Result> } Ok(match (saw_recall, saw_skip) { - (true, false) => Some(FieldBehavior::Recall), // #[recallable] - (false, true) => None, // #[recallable(skip)] + (true, false) => Some(FieldBehavior::Recall), + (false, true) => None, (false, false) => Some(FieldBehavior::Keep), (true, true) => unreachable!("conflicting attributes handled above"), }) @@ -99,9 +121,8 @@ fn classify_recallable_field_type( } } -pub(super) fn collect_field_irs<'a>( - fields: &'a Fields, - struct_lifetimes: &HashSet<&'a syn::Ident>, +pub(crate) fn collect_field_irs<'a>( + fields: &'a syn::Fields, generic_lookup: &GenericParamLookup<'a>, ) -> syn::Result<(GenericUsage, Vec>)> { let mut usage = GenericUsage::default(); @@ -112,8 +133,9 @@ pub(super) fn collect_field_irs<'a>( let member = field_member(field, index); let ty = &field.ty; - if is_phantom_data(ty) && field_uses_struct_lifetime(ty, struct_lifetimes) { + if is_phantom_data(ty) { field_irs.push(FieldIr { + source: field, memento_index: None, member, ty, @@ -145,6 +167,7 @@ pub(super) fn collect_field_irs<'a>( let memento_index = (!strategy.is_skip()).then_some(memento_counter); field_irs.push(FieldIr { + source: field, memento_index, member, ty, @@ -160,9 +183,6 @@ pub(super) fn collect_field_irs<'a>( #[must_use] pub(crate) fn has_recallable_skip_attr(field: &Field) -> bool { - // Use determine_field_behavior for consistent validation. - // In the attribute macro context, we intentionally ignore errors here - // because the derive macros will report them with proper spans. matches!(determine_field_behavior(field), Ok(None)) } @@ -170,7 +190,9 @@ pub(crate) fn has_recallable_skip_attr(field: &Field) -> bool { mod tests { use syn::parse_quote; - use super::{GenericParamLookup, classify_recallable_field_type}; + use super::{ + FieldStrategy, GenericParamLookup, classify_recallable_field_type, collect_field_irs, + }; #[test] fn recallable_type_classifier_accepts_any_path_type() { @@ -192,4 +214,24 @@ mod tests { Ok(None) )); } + + #[test] + fn phantom_data_fields_are_always_skipped() { + let input: syn::DeriveInput = parse_quote! { + struct Example { + marker: ::core::marker::PhantomData, + value: u8, + } + }; + let fields = match &input.data { + syn::Data::Struct(data) => &data.fields, + _ => unreachable!(), + }; + let lookup = GenericParamLookup::new(&input.generics); + + let (_, field_irs) = collect_field_irs(fields, &lookup).unwrap(); + + assert_eq!(field_irs[0].strategy, FieldStrategy::Skip); + assert_eq!(field_irs[1].strategy, FieldStrategy::StoreAsSelf); + } } diff --git a/recallable-macro/src/context/internal/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs similarity index 77% rename from recallable-macro/src/context/internal/generics.rs rename to recallable-macro/src/context/internal/shared/generics.rs index 845bf8c..532570f 100644 --- a/recallable-macro/src/context/internal/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -1,41 +1,37 @@ use std::collections::{HashMap, HashSet}; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{format_ident, quote}; use syn::visit::Visit; use syn::{GenericParam, Generics, Ident, PathArguments, Type, WhereClause, WherePredicate}; -use super::ir::FieldIr; +use crate::context::internal::shared::FieldIr; -/// Whether a generic parameter is kept on the generated memento type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum GenericParamRetention { - /// Omit the parameter from the generated memento declaration. +pub(crate) enum GenericParamRetention { Dropped, - /// Retain the parameter because the memento shape depends on it. Retained, - /// Retain the type parameter and later add `Recallable`-related bounds for it. RetainedAsRecallable, } #[derive(Debug)] -pub(super) struct GenericParamPlan<'a> { - pub(super) param: &'a GenericParam, +pub(crate) struct GenericParamPlan<'a> { + pub(crate) param: &'a GenericParam, retention: GenericParamRetention, } impl<'a> GenericParamPlan<'a> { #[must_use] - pub(super) const fn is_retained(&self) -> bool { + pub(crate) const fn is_retained(&self) -> bool { !matches!(self.retention, GenericParamRetention::Dropped) } - pub(super) const fn decl_param(&self) -> &GenericParam { + pub(crate) const fn decl_param(&self) -> &GenericParam { self.param } #[must_use] - pub(super) fn type_arg(&self) -> TokenStream2 { + pub(crate) fn type_arg(&self) -> TokenStream2 { match self.param { GenericParam::Lifetime(param) => { let lifetime = ¶m.lifetime; @@ -53,7 +49,7 @@ impl<'a> GenericParamPlan<'a> { } #[must_use] - pub(super) const fn recallable_ident(&self) -> Option<&'a Ident> { + pub(crate) const fn recallable_ident(&self) -> Option<&'a Ident> { match (self.param, self.retention) { (GenericParam::Type(param), GenericParamRetention::RetainedAsRecallable) => { Some(¶m.ident) @@ -64,13 +60,13 @@ impl<'a> GenericParamPlan<'a> { } #[derive(Debug, Default)] -pub(super) struct GenericUsage { - pub(super) retained: HashSet, - pub(super) recallable_type_params: HashSet, +pub(crate) struct GenericUsage { + pub(crate) retained: HashSet, + pub(crate) recallable_type_params: HashSet, } #[derive(Debug)] -pub(super) struct GenericParamLookup<'a> { +pub(crate) struct GenericParamLookup<'a> { type_params: HashMap<&'a Ident, usize>, const_params: HashMap<&'a Ident, usize>, lifetime_params: HashMap<&'a Ident, usize>, @@ -78,7 +74,7 @@ pub(super) struct GenericParamLookup<'a> { impl<'a> GenericParamLookup<'a> { #[must_use] - pub(super) fn new(generics: &'a Generics) -> Self { + pub(crate) fn new(generics: &'a Generics) -> Self { let mut type_params = HashMap::new(); let mut const_params = HashMap::new(); let mut lifetime_params = HashMap::new(); @@ -105,7 +101,7 @@ impl<'a> GenericParamLookup<'a> { } #[must_use] - pub(super) fn type_param_index(&self, ident: &Ident) -> Option { + pub(crate) fn type_param_index(&self, ident: &Ident) -> Option { self.type_params.get(ident).copied() } @@ -114,17 +110,19 @@ impl<'a> GenericParamLookup<'a> { } } -/// Analysis-only classification for recalled field types. -pub(super) struct BareTypeParam(pub(super) usize); +pub(crate) struct BareTypeParam(pub(crate) usize); #[must_use] -pub(super) fn collect_marker_param_indices( - fields: &[FieldIr<'_>], - generic_params: &[GenericParamPlan<'_>], - generic_lookup: &GenericParamLookup<'_>, -) -> Vec { +pub(crate) fn collect_marker_param_indices<'field, 'input>( + fields: impl IntoIterator>, + generic_params: &[GenericParamPlan<'input>], + generic_lookup: &GenericParamLookup<'input>, +) -> Vec +where + 'input: 'field, +{ let referenced_by_fields: HashSet<_> = fields - .iter() + .into_iter() .filter(|field| !field.strategy.is_skip()) .flat_map(|field| collect_generic_dependencies_in_type(field.ty, generic_lookup)) .collect(); @@ -139,7 +137,7 @@ pub(super) fn collect_marker_param_indices( } #[must_use] -pub(super) fn plan_memento_generics<'a>( +pub(crate) fn plan_memento_generics<'a>( generics: &'a Generics, mut usage: GenericUsage, generic_lookup: &GenericParamLookup<'a>, @@ -285,7 +283,6 @@ impl<'ast, 'a> Visit<'ast> for GenericDependencyCollector<'a> { && matches!(first_segment.arguments, PathArguments::None) && let Some(index) = self.lookup.const_param_index(&first_segment.ident) { - // `syn` represents identity const arguments like `N` as `Type`. self.dependencies.insert(index); } } @@ -306,7 +303,7 @@ impl<'ast, 'a> Visit<'ast> for GenericDependencyCollector<'a> { } #[must_use] -pub(super) fn collect_generic_dependencies_in_type( +pub(crate) fn collect_generic_dependencies_in_type( ty: &Type, generic_lookup: &GenericParamLookup<'_>, ) -> HashSet { @@ -334,7 +331,7 @@ fn collect_generic_dependencies_in_where_predicate( } #[must_use] -pub(super) fn marker_component(param: &GenericParam) -> TokenStream2 { +pub(crate) fn marker_component(param: &GenericParam) -> TokenStream2 { match param { GenericParam::Lifetime(param) => { let lifetime = ¶m.lifetime; @@ -346,7 +343,8 @@ pub(super) fn marker_component(param: &GenericParam) -> TokenStream2 { } GenericParam::Const(param) => { let ident = ¶m.ident; - quote! { [(); { let _ = #ident; 0usize }] } + let helper_ident = format_ident!("__RecallableConstMarker_{ident}"); + quote! { #helper_ident<#ident> } } } } @@ -356,8 +354,7 @@ pub(crate) fn is_generic_type_param(ty: &Type, generic_type_params: &HashSet<&Id match ty { Type::Path(tp) if tp.qself.is_none() && tp.path.segments.len() == 1 => { let segment = &tp.path.segments[0]; - matches!(segment.arguments, PathArguments::None) - && generic_type_params.contains(&segment.ident) + segment.arguments.is_empty() && generic_type_params.contains(&segment.ident) } _ => false, } @@ -368,7 +365,23 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; - use crate::context::StructIr; + use super::{ + GenericParamLookup, GenericParamPlan, GenericParamRetention, + collect_generic_dependencies_in_where_predicate, + }; + use crate::context::internal::shared::CodegenItemIr; + use crate::context::internal::structs::StructIr; + + #[test] + fn generic_param_plan_type_arg_emits_lifetime_arguments() { + let param = parse_quote!('a); + let plan = GenericParamPlan { + param: ¶m, + retention: GenericParamRetention::Retained, + }; + + assert_eq!(plan.type_arg().to_string(), quote!('a).to_string()); + } #[test] fn memento_generics_preserve_retained_bounds_defaults_and_where_clauses() { @@ -389,7 +402,7 @@ mod tests { let ir = StructIr::analyze(&input).unwrap(); assert_eq!( - ir.memento_decl_generics().to_string(), + ir.memento_decl_generics().unwrap().to_string(), quote!().to_string() ); assert_eq!( @@ -428,7 +441,7 @@ mod tests { let ir = StructIr::analyze(&input).unwrap(); assert_eq!( - ir.memento_decl_generics().to_string(), + ir.memento_decl_generics().unwrap().to_string(), quote!().to_string() ); assert_eq!( @@ -443,4 +456,45 @@ mod tests { quote!(ExampleMemento).to_string() ); } + + #[test] + fn memento_where_clause_ignores_predicates_without_generic_dependencies() { + let input = parse_quote! { + struct Example + where + u8: Copy, + T: Clone, + { + value: T, + } + }; + + let ir = StructIr::analyze(&input).unwrap(); + + assert_eq!( + ir.memento_where_clause() + .unwrap() + .to_token_stream() + .to_string(), + quote!(where T: Clone).to_string() + ); + } + + #[test] + fn where_predicate_dependency_collection_ignores_non_generic_const_paths() { + let generics = parse_quote!(); + let lookup = GenericParamLookup::new(&generics); + let predicate = parse_quote!([u8; SOME_CONST]: Copy); + + assert!(collect_generic_dependencies_in_where_predicate(&predicate, &lookup).is_empty()); + } + + #[test] + fn where_predicate_dependency_collection_visits_non_single_segment_expr_paths() { + let generics = parse_quote!(); + let lookup = GenericParamLookup::new(&generics); + let predicate = parse_quote!([u8; module::SOME_CONST]: Copy); + + assert!(collect_generic_dependencies_in_where_predicate(&predicate, &lookup).is_empty()); + } } diff --git a/recallable-macro/src/context/internal/shared/item.rs b/recallable-macro/src/context/internal/shared/item.rs new file mode 100644 index 0000000..da39f97 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/item.rs @@ -0,0 +1,79 @@ +use syn::DeriveInput; + +use crate::context::internal::{enums::EnumIr, structs::StructIr}; + +use super::util::is_recallable_attr; + +#[derive(Debug)] +pub(crate) enum ItemIr<'a> { + Struct(StructIr<'a>), + Enum(EnumIr<'a>), +} + +pub(crate) fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { + let mut skip_memento_default_derives = false; + for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip_memento_default_derives") { + skip_memento_default_derives = true; + Ok(()) + } else if meta.path.is_ident("skip") { + Err(meta.error("`skip` is a field-level attribute, not an item-level attribute")) + } else { + Err(meta.error("unrecognized `recallable` parameter")) + } + })?; + } + Ok(skip_memento_default_derives) +} + +impl<'a> ItemIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + match &input.data { + syn::Data::Struct(_) => Ok(Self::Struct(StructIr::analyze(input)?)), + syn::Data::Enum(_) => Ok(Self::Enum(EnumIr::analyze(input)?)), + _ => Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs or enums", + )), + } + } +} + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::has_skip_memento_default_derives; + + #[test] + fn item_level_unknown_recallable_parameter_is_rejected() { + let input: syn::DeriveInput = parse_quote! { + #[recallable(unknown)] + struct Example { + value: u32, + } + }; + + let error = has_skip_memento_default_derives(&input).unwrap_err(); + + assert_eq!(error.to_string(), "unrecognized `recallable` parameter"); + } + + #[test] + fn item_level_skip_parameter_is_rejected() { + let input: syn::DeriveInput = parse_quote! { + #[recallable(skip)] + enum Example { + Value(u32), + } + }; + + let error = has_skip_memento_default_derives(&input).unwrap_err(); + + assert_eq!( + error.to_string(), + "`skip` is a field-level attribute, not an item-level attribute" + ); + } +} diff --git a/recallable-macro/src/context/internal/lifetime.rs b/recallable-macro/src/context/internal/shared/lifetime.rs similarity index 69% rename from recallable-macro/src/context/internal/lifetime.rs rename to recallable-macro/src/context/internal/shared/lifetime.rs index af08586..f395f7e 100644 --- a/recallable-macro/src/context/internal/lifetime.rs +++ b/recallable-macro/src/context/internal/shared/lifetime.rs @@ -5,11 +5,11 @@ use syn::{Fields, GenericParam, Generics, Ident, Type}; use super::fields::has_recallable_skip_attr; -pub(super) fn validate_no_borrowed_fields( +pub(crate) fn validate_no_borrowed_fields( fields: &Fields, - struct_lifetimes: &HashSet<&Ident>, + item_lifetimes: &HashSet<&Ident>, ) -> syn::Result<()> { - if struct_lifetimes.is_empty() { + if item_lifetimes.is_empty() { return Ok(()); } @@ -22,7 +22,7 @@ pub(super) fn validate_no_borrowed_fields( if is_phantom_data(&field.ty) { continue; } - if field_uses_struct_lifetime(&field.ty, struct_lifetimes) { + if field_uses_item_lifetime(&field.ty, item_lifetimes) { let err = syn::Error::new_spanned(&field.ty, "Recall derives do not support borrowed fields"); match &mut errors { @@ -39,7 +39,7 @@ pub(super) fn validate_no_borrowed_fields( } #[must_use] -pub(super) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { +pub(crate) fn collect_item_lifetimes(generics: &Generics) -> HashSet<&Ident> { generics .params .iter() @@ -51,29 +51,20 @@ pub(super) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { } struct LifetimeUsageChecker<'a> { - struct_lifetimes: &'a HashSet<&'a Ident>, + item_lifetimes: &'a HashSet<&'a Ident>, found: bool, } impl<'ast> Visit<'ast> for LifetimeUsageChecker<'_> { fn visit_lifetime(&mut self, lt: &'ast syn::Lifetime) { - if self.struct_lifetimes.contains(<.ident) { + if self.item_lifetimes.contains(<.ident) { self.found = true; } } } -/// Heuristically detects PhantomData-shaped field types during analysis. -/// -/// This matches any path type whose final segment is `PhantomData`, so it accepts -/// `PhantomData`, `marker::PhantomData`, `core::marker::PhantomData`, -/// `::core::marker::PhantomData`, `std::marker::PhantomData`, and -/// `::std::marker::PhantomData`. -/// -/// Because proc macros cannot resolve types, this also intentionally matches any -/// user-defined type whose final path segment is `PhantomData`. #[must_use] -pub(super) fn is_phantom_data(ty: &Type) -> bool { +pub(crate) fn is_phantom_data(ty: &Type) -> bool { matches!( ty, Type::Path(p) @@ -82,9 +73,9 @@ pub(super) fn is_phantom_data(ty: &Type) -> bool { } #[must_use] -pub(super) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<&Ident>) -> bool { +pub(crate) fn field_uses_item_lifetime(ty: &Type, item_lifetimes: &HashSet<&Ident>) -> bool { let mut checker = LifetimeUsageChecker { - struct_lifetimes, + item_lifetimes, found: false, }; checker.visit_type(ty); @@ -95,7 +86,7 @@ pub(super) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<& mod tests { use syn::parse_quote; - use super::{collect_struct_lifetimes, is_phantom_data, validate_no_borrowed_fields}; + use super::{collect_item_lifetimes, is_phantom_data, validate_no_borrowed_fields}; #[test] fn phantom_data_detection_accepts_common_path_variants() { @@ -127,8 +118,8 @@ mod tests { syn::Data::Struct(data) => &data.fields, _ => unreachable!(), }; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - let error = validate_no_borrowed_fields(fields, &struct_lifetimes).unwrap_err(); + let item_lifetimes = collect_item_lifetimes(&input.generics); + let error = validate_no_borrowed_fields(fields, &item_lifetimes).unwrap_err(); assert!( error diff --git a/recallable-macro/src/context/internal/shared/util.rs b/recallable-macro/src/context/internal/shared/util.rs new file mode 100644 index 0000000..f893078 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/util.rs @@ -0,0 +1,25 @@ +use proc_macro_crate::{FoundCrate, crate_name}; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::{Attribute, Ident}; + +const RECALLABLE: &str = "recallable"; + +#[inline] +#[must_use] +pub(crate) fn crate_path() -> TokenStream2 { + match crate_name(RECALLABLE) { + Ok(FoundCrate::Itself) => quote! { ::recallable }, + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, Span::call_site()); + quote! { ::#ident } + } + Err(_) => quote! { ::recallable }, + } +} + +#[inline] +#[must_use] +pub(crate) fn is_recallable_attr(attr: &Attribute) -> bool { + attr.path().is_ident(RECALLABLE) +} diff --git a/recallable-macro/src/context/internal/structs.rs b/recallable-macro/src/context/internal/structs.rs new file mode 100644 index 0000000..725de13 --- /dev/null +++ b/recallable-macro/src/context/internal/structs.rs @@ -0,0 +1,5 @@ +mod bounds; +mod ir; + +pub(crate) use bounds::{collect_recall_like_bounds, collect_shared_memento_bounds}; +pub(crate) use ir::{StructIr, StructShape}; diff --git a/recallable-macro/src/context/internal/bounds.rs b/recallable-macro/src/context/internal/structs/bounds.rs similarity index 60% rename from recallable-macro/src/context/internal/bounds.rs rename to recallable-macro/src/context/internal/structs/bounds.rs index 6d5cc15..e78bb8a 100644 --- a/recallable-macro/src/context/internal/bounds.rs +++ b/recallable-macro/src/context/internal/structs/bounds.rs @@ -1,68 +1,11 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::quote; use syn::WherePredicate; -use super::ir::{CodegenEnv, StructIr}; - -/// Policy for which derives and nested bounds the generated memento should receive. -#[derive(Debug)] -pub(crate) struct MementoTraitSpec { - serde_enabled: bool, - derive_off: bool, -} - -impl MementoTraitSpec { - pub(super) const fn new(serde_enabled: bool, derive_off: bool) -> Self { - Self { - serde_enabled, - derive_off, - } - } - - #[must_use] - pub(crate) fn derive_attr(&self) -> TokenStream2 { - match (self.has_common_traits(), self.serde_enabled) { - (true, true) => { - quote! { - #[derive( - ::core::clone::Clone, - ::core::fmt::Debug, - ::core::cmp::PartialEq, - ::serde::Deserialize - )] - } - } - (true, false) => { - quote! { - #[derive( - ::core::clone::Clone, - ::core::fmt::Debug, - ::core::cmp::PartialEq - )] - } - } - (false, true) => quote! { #[derive(::serde::Deserialize)] }, - (false, false) => quote! {}, - } - } - - fn common_bound_tokens(&self) -> TokenStream2 { - if self.has_common_traits() { - quote! { ::core::clone::Clone + ::core::fmt::Debug + ::core::cmp::PartialEq } - } else { - quote! {} - } - } - - fn serde_nested_bound(&self) -> Option { - self.serde_enabled - .then_some(quote!(::serde::de::DeserializeOwned)) - } - - const fn has_common_traits(&self) -> bool { - !self.derive_off - } -} +use crate::context::internal::shared::{ + CodegenEnv, MementoTraitSpec, collect_recall_like_bounds as collect_shared_recall_like_bounds, + collect_shared_memento_bounds as collect_common_memento_bounds, +}; +use crate::context::internal::structs::StructIr; #[must_use] pub(crate) fn collect_shared_memento_bounds( @@ -72,30 +15,21 @@ pub(crate) fn collect_shared_memento_bounds( collect_shared_memento_bounds_with_spec(ir, env, &ir.memento_trait_spec()) } -fn collect_shared_memento_bounds_with_spec( +#[must_use] +pub(crate) fn collect_recall_like_bounds( ir: &StructIr, env: &CodegenEnv, - memento_trait_spec: &MementoTraitSpec, + direct_bound: &TokenStream2, ) -> Vec { - let recallable_trait = &env.recallable_trait; - let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - - let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); - if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); - } - - bounds + collect_recall_like_bounds_with_spec(ir, env, direct_bound, &ir.memento_trait_spec()) } -#[must_use] -pub(crate) fn collect_recall_like_bounds( +fn collect_shared_memento_bounds_with_spec( ir: &StructIr, env: &CodegenEnv, - direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, ) -> Vec { - collect_recall_like_bounds_with_spec(ir, env, direct_bound, &ir.memento_trait_spec()) + collect_common_memento_bounds(ir, env, memento_trait_spec) } fn collect_recall_like_bounds_with_spec( @@ -104,24 +38,7 @@ fn collect_recall_like_bounds_with_spec( direct_bound: &TokenStream2, memento_trait_spec: &MementoTraitSpec, ) -> Vec { - let shared_memento_bounds = - collect_shared_memento_bounds_with_spec(ir, env, memento_trait_spec); - let shared_param_bound_count = ir.recallable_params().count(); - - let mut bounds = ir.recallable_bounds(direct_bound); - bounds.extend( - shared_memento_bounds - .iter() - .take(shared_param_bound_count) - .cloned(), - ); - bounds.extend(ir.whole_type_bounds(direct_bound)); - bounds.extend( - shared_memento_bounds - .into_iter() - .skip(shared_param_bound_count), - ); - bounds + collect_shared_recall_like_bounds(ir, env, direct_bound, memento_trait_spec) } #[cfg(test)] @@ -129,6 +46,8 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; + use crate::context::internal::shared::CodegenItemIr; + use super::{ CodegenEnv, MementoTraitSpec, StructIr, collect_recall_like_bounds_with_spec, collect_shared_memento_bounds_with_spec, @@ -220,7 +139,6 @@ mod tests { let whole_type_bounds: Vec<_> = ir .whole_type_bounds(&env.recallable_trait) - .into_iter() .map(|predicate| predicate.to_token_stream().to_string()) .collect(); assert_eq!( @@ -285,40 +203,4 @@ mod tests { ] ); } - - #[test] - fn memento_trait_spec_formats_derives_for_serde_modes() { - let serde_derives = MementoTraitSpec::new(true, false).derive_attr().to_string(); - assert!(serde_derives.contains(":: core :: clone :: Clone")); - assert!(serde_derives.contains(":: core :: fmt :: Debug")); - assert!(serde_derives.contains(":: core :: cmp :: PartialEq")); - assert!(serde_derives.contains(":: serde :: Deserialize")); - - let no_serde_derives = MementoTraitSpec::new(false, false) - .derive_attr() - .to_string(); - assert!(no_serde_derives.contains(":: core :: clone :: Clone")); - assert!(no_serde_derives.contains(":: core :: fmt :: Debug")); - assert!(no_serde_derives.contains(":: core :: cmp :: PartialEq")); - assert!(!no_serde_derives.contains(":: serde :: Deserialize")); - } - - #[test] - fn memento_trait_spec_derive_off_suppresses_common_traits() { - let derive_off_serde = MementoTraitSpec::new(true, true).derive_attr().to_string(); - assert!(!derive_off_serde.contains("Clone")); - assert!(!derive_off_serde.contains("Debug")); - assert!(!derive_off_serde.contains("PartialEq")); - assert!(derive_off_serde.contains(":: serde :: Deserialize")); - - let derive_off_no_serde = MementoTraitSpec::new(false, true).derive_attr().to_string(); - assert!(derive_off_no_serde.is_empty()); - } - - #[test] - fn memento_trait_spec_derive_off_empties_common_bounds() { - let spec = MementoTraitSpec::new(true, true); - assert!(spec.common_bound_tokens().is_empty()); - assert!(spec.serde_nested_bound().is_some()); - } } diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs new file mode 100644 index 0000000..1d8ed8b --- /dev/null +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -0,0 +1,193 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + DataStruct, DeriveInput, Fields, Generics, Ident, ImplGenerics, Visibility, WhereClause, +}; + +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::bounds::MementoTraitSpec; +use crate::context::internal::shared::codegen::CodegenItemIr; +use crate::context::internal::shared::fields::{FieldIr, collect_field_irs}; +use crate::context::internal::shared::generics::{ + GenericParamLookup, GenericParamPlan, collect_marker_param_indices, plan_memento_generics, +}; +use crate::context::internal::shared::item::has_skip_memento_default_derives; +use crate::context::internal::shared::lifetime::{ + collect_item_lifetimes, validate_no_borrowed_fields, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StructShape { + Named, + Unnamed, + Unit, +} + +impl StructShape { + const fn from_fields(fields: &Fields) -> Self { + match fields { + Fields::Named(_) => Self::Named, + Fields::Unnamed(_) => Self::Unnamed, + Fields::Unit => Self::Unit, + } + } +} + +#[derive(Debug)] +pub(crate) struct StructIr<'a> { + name: &'a Ident, + visibility: &'a Visibility, + generics: &'a Generics, + shape: StructShape, + fields: Vec>, + memento_name: Ident, + generic_type_param_idents: HashSet<&'a Ident>, + generic_params: Vec>, + memento_where_clause: Option, + marker_param_indices: Vec, + skip_memento_default_derives: bool, +} + +impl<'a> StructIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + let syn::Data::Struct(DataStruct { fields, .. }) = &input.data else { + unreachable!("StructIr::analyze only receives struct inputs"); + }; + let item_lifetimes = collect_item_lifetimes(&input.generics); + validate_no_borrowed_fields(fields, &item_lifetimes)?; + + let shape = StructShape::from_fields(fields); + let memento_name = quote::format_ident!("{}Memento", input.ident); + let generic_lookup = GenericParamLookup::new(&input.generics); + let generic_type_param_idents = input + .generics + .type_params() + .map(|param| ¶m.ident) + .collect(); + let (usage, field_irs) = collect_field_irs(fields, &generic_lookup)?; + let (generic_params, memento_where_clause) = + plan_memento_generics(&input.generics, usage, &generic_lookup); + let marker_param_indices = + collect_marker_param_indices(&field_irs, &generic_params, &generic_lookup); + let skip_memento_default_derives = has_skip_memento_default_derives(input)?; + + Ok(Self { + name: &input.ident, + visibility: &input.vis, + generics: &input.generics, + shape, + fields: field_irs, + memento_name, + generic_type_param_idents, + generic_params, + memento_where_clause, + marker_param_indices, + skip_memento_default_derives, + }) + } + + pub(crate) fn struct_type(&self) -> TokenStream2 { + let name = &self.name; + let (_, type_generics, _) = self.generics.split_for_impl(); + quote! { #name #type_generics } + } + + pub(crate) const fn memento_name(&self) -> &Ident { + &self.memento_name + } + + pub(crate) const fn visibility(&self) -> &'a Visibility { + self.visibility + } + + #[must_use] + pub(crate) const fn shape(&self) -> StructShape { + self.shape + } + + pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { + let (impl_generics, _, _) = self.generics.split_for_impl(); + impl_generics + } + + pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + #[must_use] + pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { + MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) + } + + pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { + self.memento_where_clause.as_ref() + } + + #[must_use] + pub(crate) fn generated_memento_shape(&self) -> StructShape { + self.shape + } + + #[must_use] + pub(crate) const fn has_synthetic_marker(&self) -> bool { + !self.marker_param_indices.is_empty() + } + + pub(crate) fn memento_fields(&self) -> impl Iterator> { + self.fields.iter().filter(|field| !field.strategy.is_skip()) + } +} + +impl<'a> CodegenItemIr<'a> for StructIr<'a> { + type Fields<'b> + = std::slice::Iter<'b, FieldIr<'a>> + where + Self: 'b, + 'a: 'b; + + fn generics(&self) -> &'a Generics { + self.generics + } + + fn memento_name(&self) -> &Ident { + &self.memento_name + } + + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + fn generic_params(&self) -> &[GenericParamPlan<'a>] { + &self.generic_params + } + + fn marker_param_indices(&self) -> &[usize] { + &self.marker_param_indices + } + + fn all_fields(&self) -> Self::Fields<'_> { + self.fields.iter() + } +} + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::{StructIr, StructShape}; + + #[test] + fn unit_structs_never_need_synthetic_markers() { + let input: syn::DeriveInput = parse_quote! { + struct Example<'a, T: From, U, const N: usize>; + }; + + let ir = StructIr::analyze(&input).unwrap(); + + assert_eq!(ir.shape(), StructShape::Unit); + assert_eq!(ir.generated_memento_shape(), StructShape::Unit); + assert!(!ir.has_synthetic_marker()); + } +} diff --git a/recallable-macro/src/context/internal/util.rs b/recallable-macro/src/context/internal/util.rs deleted file mode 100644 index 81267aa..0000000 --- a/recallable-macro/src/context/internal/util.rs +++ /dev/null @@ -1,34 +0,0 @@ -use proc_macro_crate::{FoundCrate, crate_name}; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::quote; -use syn::{Attribute, Ident}; - -const RECALLABLE: &str = "recallable"; - -/// Returns the path used to reference the `recallable` crate in generated code. -/// -/// Uses `proc-macro-crate` to resolve the actual dependency name from `Cargo.toml`, -/// which handles crate renames (e.g., `my_recallable = { package = "recallable", ... }`). -/// -/// Even when the macro expands inside the `recallable` crate itself, prefer the -/// absolute `::recallable` path instead of `crate`. That keeps doctests working: -/// rustdoc compiles them as external crates, so `crate` would point at the -/// temporary doctest crate rather than the real `recallable` library. -#[inline] -#[must_use] -pub(crate) fn crate_path() -> TokenStream2 { - match crate_name(RECALLABLE) { - Ok(FoundCrate::Itself) => quote! { ::recallable }, - Ok(FoundCrate::Name(name)) => { - let ident = Ident::new(&name, Span::call_site()); - quote! { ::#ident } - } - Err(_) => quote! { ::recallable }, - } -} - -#[inline] -#[must_use] -pub(super) fn is_recallable_attr(attr: &Attribute) -> bool { - attr.path().is_ident(RECALLABLE) -} diff --git a/recallable-macro/src/context/memento.rs b/recallable-macro/src/context/memento.rs new file mode 100644 index 0000000..36173e8 --- /dev/null +++ b/recallable-macro/src/context/memento.rs @@ -0,0 +1,14 @@ +mod enums; +mod structs; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::context::internal::shared::{CodegenEnv, ItemIr}; + +#[must_use] +pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_memento_struct(ir, env), + ItemIr::Enum(ir) => enums::gen_memento_enum(ir, env), + } +} diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs new file mode 100644 index 0000000..8d0e69e --- /dev/null +++ b/recallable-macro/src/context/memento/enums.rs @@ -0,0 +1,98 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{Ident, WhereClause, WherePredicate}; + +use crate::context::SERDE_ENABLED; +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, +}; +use crate::context::internal::shared::{ + CodegenEnv, CodegenItemIr, FieldIr, build_memento_field_tokens, +}; + +#[must_use] +pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let derives = ir.memento_trait_spec().derive_attr(); + let marker_helpers = ir.synthetic_marker_helper_defs(); + let visibility = ir.visibility(); + let memento_name = ir.memento_name(); + let memento_generics = ir.memento_decl_generics(); + let where_clause = build_memento_where_clause(ir, env); + let variants = ir + .variants() + .map(|variant| { + build_memento_variant( + variant, + &env.recallable_trait, + ir.generic_type_param_idents(), + ) + }) + .chain( + ir.synthetic_marker_type() + .into_iter() + .map(|marker_ty| build_marker_variant(&marker_ty)), + ); + + quote! { + #(#marker_helpers)* + + #[automatically_derived] + #[allow(dead_code)] + #derives + #visibility enum #memento_name #memento_generics #where_clause { + #(#variants),* + } + } +} + +fn build_memento_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let mut where_clause = ir + .memento_where_clause() + .cloned() + .unwrap_or(syn::parse_quote! { where }); + let bounded_types = collect_memento_bounds(ir, env); + where_clause.predicates.extend(bounded_types); + (!where_clause.predicates.is_empty()).then_some(where_clause) +} + +fn collect_memento_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} + +fn build_memento_variant( + variant: &VariantIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let name = variant.name; + let mut fields = variant + .kept_fields() + .map(|(_, field)| build_memento_field(field, recallable_trait, generic_type_params)) + .peekable(); + let non_empty = fields.peek().is_some(); + match variant.shape { + VariantShape::Named if non_empty => quote! { #name { #(#fields),* } }, + VariantShape::Unnamed if non_empty => quote! { #name(#(#fields),*) }, + _ => quote! { #name }, + } +} + +fn build_memento_field( + field: &FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + build_memento_field_tokens(field, recallable_trait, generic_type_params) +} + +fn build_marker_variant(marker_ty: &TokenStream2) -> TokenStream2 { + let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip)] }); + + quote! { + #[doc(hidden)] + #serde_attr + __RecallableMarker(#marker_ty) + } +} diff --git a/recallable-macro/src/context/memento_struct.rs b/recallable-macro/src/context/memento/structs.rs similarity index 83% rename from recallable-macro/src/context/memento_struct.rs rename to recallable-macro/src/context/memento/structs.rs index c69c571..7345350 100644 --- a/recallable-macro/src/context/memento_struct.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -3,20 +3,26 @@ use quote::quote; use std::collections::HashSet; use syn::{Ident, WhereClause, WherePredicate}; -use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, SERDE_ENABLED, StructIr, StructShape, - collect_recall_like_bounds, is_generic_type_param, +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::{ + CodegenEnv, CodegenItemIr, FieldIr, build_memento_field_tokens, }; +use crate::context::internal::structs::{StructIr, StructShape, collect_recall_like_bounds}; #[must_use] pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { let derives = ir.memento_trait_spec().derive_attr(); + let marker_helpers = ir.synthetic_marker_helper_defs(); let visibility = ir.visibility(); let memento_name = ir.memento_name(); let memento_generics = ir.memento_decl_generics(); let body = build_memento_body(ir, env); quote! { + #(#marker_helpers)* + + #[automatically_derived] + #[allow(dead_code)] #derives #visibility struct #memento_name #memento_generics #body } @@ -90,22 +96,7 @@ fn build_memento_field( recallable_trait: &TokenStream2, generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { - let ty = field.ty; - let field_ty = match &field.strategy { - FieldStrategy::StoreAsMemento => { - if is_generic_type_param(ty, generic_type_params) { - quote! { #ty::Memento } - } else { - quote! { <#ty as #recallable_trait>::Memento } - } - } - FieldStrategy::StoreAsSelf => quote! { #ty }, - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - }; - match &field.member { - FieldMember::Named(name) => quote! { #name: #field_ty }, - FieldMember::Unnamed(_) => quote! { #field_ty }, - } + build_memento_field_tokens(field, recallable_trait, generic_type_params) } #[cfg(test)] diff --git a/recallable-macro/src/context/recall_impl.rs b/recallable-macro/src/context/recall_impl.rs index 3409b24..99cbbbc 100644 --- a/recallable-macro/src/context/recall_impl.rs +++ b/recallable-macro/src/context/recall_impl.rs @@ -1,83 +1,14 @@ +mod enums; +mod structs; + use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::WherePredicate; -use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, collect_recall_like_bounds, -}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] -pub(crate) fn gen_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let recall_trait = &env.recall_trait; - let impl_generics = ir.impl_generics(); - let where_clause = build_recall_where_clause(ir, env); - let struct_type = ir.struct_type(); - let recall_method = build_recall_method(ir, recall_trait); - - quote! { - impl #impl_generics #recall_trait - for #struct_type - #where_clause { - #recall_method - } - } -} - -fn build_recall_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let extra_bounds = collect_recall_bounds(ir, env); - ir.extend_where_clause(extra_bounds) -} - -fn collect_recall_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds(ir, env, &env.recall_trait) -} - -fn build_recall_method(ir: &StructIr, recall_trait: &TokenStream2) -> TokenStream2 { - let mut memento_fields = ir.memento_fields().peekable(); - let recall_param_name = build_recall_param_name(memento_fields.peek().is_some()); - let statements = memento_fields.map(|field| build_recall_statement(field, recall_trait)); - - quote! { - #[inline] - fn recall(&mut self, #recall_param_name: Self::Memento) { - #(#statements)* - } - } -} - -fn build_recall_param_name(has_memento_fields: bool) -> TokenStream2 { - if has_memento_fields { - quote! { memento } - } else { - quote! { _memento } - } -} - -fn build_recall_statement(field: &FieldIr, recall_trait: &TokenStream2) -> TokenStream2 { - let member = &field.member; - let memento_member = build_memento_member(field); - - match &field.strategy { - FieldStrategy::StoreAsSelf => { - quote! { self.#member = memento.#memento_member; } - } - FieldStrategy::StoreAsMemento => { - quote! { #recall_trait::recall(&mut self.#member, memento.#memento_member); } - } - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - } -} - -fn build_memento_member(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - match member { - FieldMember::Named(name) => quote! { #name }, - FieldMember::Unnamed(_) => { - let memento_index = field - .memento_index - .expect("memento_fields() guarantees memento_index is Some"); - let idx = syn::Index::from(memento_index); - quote! { #idx } - } +pub(crate) fn gen_recall_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_recall_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_recall_impl(ir, env), } } diff --git a/recallable-macro/src/context/recall_impl/enums.rs b/recallable-macro/src/context/recall_impl/enums.rs new file mode 100644 index 0000000..f23e74e --- /dev/null +++ b/recallable-macro/src/context/recall_impl/enums.rs @@ -0,0 +1,25 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; + +use crate::context::internal::enums::{EnumIr, collect_recall_like_bounds_for_enum}; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; + +#[must_use] +pub(crate) fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let recall_trait = &env.recall_trait; + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let where_clause = + ir.extend_where_clause(collect_recall_like_bounds_for_enum(ir, env, recall_trait)); + + quote! { + impl #impl_generics #recall_trait + for #enum_type + #where_clause { + #[inline] + fn recall(&mut self, memento: Self::Memento) { + *self = Self::__recallable_restore_from_memento(memento); + } + } + } +} diff --git a/recallable-macro/src/context/recall_impl/structs.rs b/recallable-macro/src/context/recall_impl/structs.rs new file mode 100644 index 0000000..1aedca2 --- /dev/null +++ b/recallable-macro/src/context/recall_impl/structs.rs @@ -0,0 +1,84 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::internal::shared::{ + CodegenEnv, CodegenItemIr, FieldIr, FieldMember, FieldStrategy, +}; +use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; + +#[must_use] +pub(crate) fn gen_struct_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let recall_trait = &env.recall_trait; + let impl_generics = ir.impl_generics(); + let where_clause = build_recall_where_clause(ir, env); + let struct_type = ir.struct_type(); + let recall_method = build_recall_method(ir, recall_trait); + + quote! { + impl #impl_generics #recall_trait + for #struct_type + #where_clause { + #recall_method + } + } +} + +fn build_recall_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let extra_bounds = collect_recall_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_recall_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds(ir, env, &env.recall_trait) +} + +fn build_recall_method(ir: &StructIr, recall_trait: &TokenStream2) -> TokenStream2 { + let mut memento_fields = ir.memento_fields().peekable(); + let recall_param_name = build_recall_param_name(memento_fields.peek().is_some()); + let statements = memento_fields.map(|field| build_recall_statement(field, recall_trait)); + + quote! { + #[inline] + fn recall(&mut self, #recall_param_name: Self::Memento) { + #(#statements)* + } + } +} + +fn build_recall_param_name(has_memento_fields: bool) -> TokenStream2 { + if has_memento_fields { + quote! { memento } + } else { + quote! { _memento } + } +} + +fn build_recall_statement(field: &FieldIr, recall_trait: &TokenStream2) -> TokenStream2 { + let member = &field.member; + let memento_member = build_memento_member(field); + + match field.strategy { + FieldStrategy::StoreAsSelf => { + quote! { self.#member = memento.#memento_member; } + } + FieldStrategy::StoreAsMemento => { + quote! { #recall_trait::recall(&mut self.#member, memento.#memento_member); } + } + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} + +fn build_memento_member(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + match member { + FieldMember::Named(name) => quote! { #name }, + FieldMember::Unnamed(_) => { + let memento_index = field + .memento_index + .expect("memento_fields() guarantees memento_index is Some"); + let idx = syn::Index::from(memento_index); + quote! { #idx } + } + } +} diff --git a/recallable-macro/src/context/recallable_impl.rs b/recallable-macro/src/context/recallable_impl.rs index da36b09..c67b191 100644 --- a/recallable-macro/src/context/recallable_impl.rs +++ b/recallable-macro/src/context/recallable_impl.rs @@ -1,31 +1,14 @@ +mod enums; +mod structs; + use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::WherePredicate; -use crate::context::{CodegenEnv, StructIr, collect_recall_like_bounds}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] -pub(crate) fn gen_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let recallable_trait = &env.recallable_trait; - let struct_type = ir.struct_type(); - let where_clause = build_recallable_where_clause(ir, env); - let memento_type = ir.memento_type(); - - quote! { - impl #impl_generics #recallable_trait - for #struct_type - #where_clause { - type Memento = #memento_type; - } +pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_recallable_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_recallable_impl(ir, env), } } - -fn build_recallable_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let extra_bounds = collect_recallable_bounds(ir, env); - ir.extend_where_clause(extra_bounds) -} - -fn collect_recallable_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds(ir, env, &env.recallable_trait) -} diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs new file mode 100644 index 0000000..75261ed --- /dev/null +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -0,0 +1,148 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, build_binding_ident, collect_recall_like_bounds_for_enum, +}; +use crate::context::internal::shared::lifetime::is_phantom_data; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, FieldStrategy}; + +#[must_use] +pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let recallable_trait = &env.recallable_trait; + let enum_type = ir.enum_type(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let memento_type = ir.memento_type(); + let restore_helper = gen_enum_restore_helper(ir, env); + + quote! { + #[automatically_derived] + impl #impl_generics #recallable_trait + for #enum_type + #where_clause { + type Memento = #memento_type; + } + + #restore_helper + } +} + +fn build_enum_recallable_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let extra_bounds = collect_enum_recallable_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} + +fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + if !ir.supports_derived_recall() { + return quote! {}; + } + + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let recallable_trait = &env.recallable_trait; + let memento_name = ir.memento_name(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_memento_pattern(variant); + let expr = build_variant_restore_expr(variant, ir.name()); + quote! { #memento_name::#variant_name #pattern => #expr } + }); + let marker_arm = ir.synthetic_marker_type().map(|_| { + quote! { #memento_name::__RecallableMarker(_) => unreachable!("marker variant is never constructed"), } + }); + + quote! { + impl #impl_generics #enum_type #where_clause { + #[inline] + fn __recallable_restore_from_memento( + memento: <#enum_type as #recallable_trait>::Memento, + ) -> Self { + match memento { + #(#arms,)* + #marker_arm + } + } + } + } +} + +fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> Option { + let mut bindings = variant.kept_bindings().peekable(); + let non_empty = bindings.peek().is_some(); + match variant.shape { + VariantShape::Named if non_empty => Some(quote! { { #(#bindings),* } }), + VariantShape::Unnamed if non_empty => Some(quote! { ( #(#bindings),* ) }), + _ => None, + } +} + +fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { + let variant_name = variant.name; + + match variant.shape { + VariantShape::Named => { + let inits = variant.indexed_fields().map(|(index, field)| { + let member = &field.member; + let value = build_variant_restore_value(field, index); + quote! { #member: #value } + }); + quote! { #enum_name::#variant_name { #(#inits),* } } + } + VariantShape::Unnamed => { + let values = variant + .indexed_fields() + .map(|(index, field)| build_variant_restore_value(field, index)); + quote! { #enum_name::#variant_name(#(#values),*) } + } + VariantShape::Unit => quote! { #enum_name::#variant_name }, + } +} + +fn build_variant_restore_value(field: &FieldIr<'_>, index: usize) -> TokenStream2 { + match field.strategy { + FieldStrategy::StoreAsSelf => { + let binding = build_binding_ident(field, index); + quote! { #binding } + } + FieldStrategy::Skip if is_phantom_data(field.ty) => quote! { ::core::marker::PhantomData }, + FieldStrategy::StoreAsMemento | FieldStrategy::Skip => { + unreachable!("manual-only gating rejects non-phantom skipped and recallable fields") + } + } +} + +#[cfg(test)] +mod tests { + use quote::quote; + use syn::parse_quote; + + use super::gen_enum_recallable_impl; + use crate::context::internal::enums::EnumIr; + use crate::context::internal::shared::CodegenEnv; + + #[test] + fn helper_name_and_manual_only_guidance_helper_name() { + let input: syn::DeriveInput = parse_quote! { + enum Example { + Value(u32), + } + }; + let ir = EnumIr::analyze(&input).unwrap(); + let env = CodegenEnv { + recallable_trait: quote!(::recallable::Recallable), + recall_trait: quote!(::recallable::Recall), + }; + + let tokens = gen_enum_recallable_impl(&ir, &env).to_string(); + + assert!(tokens.contains("__recallable_restore_from_memento")); + assert!(!tokens.contains("__recallable_rebuild_from_memento")); + } +} diff --git a/recallable-macro/src/context/recallable_impl/structs.rs b/recallable-macro/src/context/recallable_impl/structs.rs new file mode 100644 index 0000000..fa7ab05 --- /dev/null +++ b/recallable-macro/src/context/recallable_impl/structs.rs @@ -0,0 +1,33 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; +use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; + +#[must_use] +pub(crate) fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let recallable_trait = &env.recallable_trait; + let struct_type = ir.struct_type(); + let where_clause = build_recallable_where_clause(ir, env); + let memento_type = ir.memento_type(); + + quote! { + #[automatically_derived] + impl #impl_generics #recallable_trait + for #struct_type + #where_clause { + type Memento = #memento_type; + } + } +} + +fn build_recallable_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let extra_bounds = collect_recallable_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_recallable_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds(ir, env, &env.recallable_trait) +} diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 1a61367..068538a 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -4,19 +4,27 @@ //! //! Provided macros: //! -//! - `#[recallable_model]`: injects `Recallable`/`Recall` derives; with the `serde` -//! Cargo feature enabled for this macro crate it also adds `serde::Serialize` -//! and applies `#[serde(skip)]` to fields marked `#[recallable(skip)]`. +//! - `#[recallable_model]`: injects `Recallable`/`Recall` derives for structs and +//! assignment-only enums; with the `serde` Cargo feature enabled for this macro +//! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields +//! marked `#[recallable(skip)]`, keeping the source-side serde shape aligned +//! with the generated memento in the common path. Complex enums with nested +//! `#[recallable]` fields or non-marker skipped fields should derive +//! `Recallable` and implement `Recall` or `TryRecall` manually. `PhantomData<_>` +//! fields are auto-skipped by the derive, and explicit `#[recallable(skip)]` on +//! them remains accepted. //! -//! - `#[derive(Recallable)]`: generates an internal companion memento struct, exposes -//! it as `::Memento`, and emits the `Recallable` impl; with the -//! `impl_from` Cargo feature it also generates `From` for the memento type. +//! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes +//! it as `::Memento`, and emits the `Recallable` impl; with the +//! `impl_from` Cargo feature it also generates `From` for the memento type +//! for in-memory snapshot workflows. //! -//! - `#[derive(Recall)]`: generates the `Recall` implementation and recursively -//! recalls fields annotated with `#[recallable]`. +//! - `#[derive(Recall)]`: generates the `Recall` implementation, recursively +//! recalls struct fields annotated with `#[recallable]`, and supports enums only +//! when every non-marker variant field is assignment-only. //! //! Feature flags are evaluated in the `recallable-macro` crate itself. See `context` -//! for details about the generated memento struct and trait implementations. +//! for details about the generated memento type and trait implementations. use proc_macro::TokenStream; @@ -26,16 +34,20 @@ use syn::{DeriveInput, parse_macro_input}; mod context; mod model_macro; -/// Attribute macro that augments a struct with `Recallable`/`Recall` derives. +/// Attribute macro that augments a struct or assignment-only enum with +/// `Recallable`/`Recall` derives. /// /// - Always adds `#[derive(Recallable, Recall)]`. /// - When the `serde` feature is enabled for the macro crate, it also adds /// `#[derive(serde::Serialize)]`. /// - For fields annotated with `#[recallable(skip)]`, it injects `#[serde(skip)]` -/// to keep serde output aligned with recall behavior. +/// to keep source-side serde aligned with the generated memento shape. /// - This attribute itself takes no arguments. +/// - Complex enums with nested `#[recallable]` fields or non-marker skipped +/// fields are rejected so the caller can keep `Recall` or `TryRecall` +/// explicit. /// -/// This macro preserves the original struct shape and only mutates attributes. +/// This macro preserves the original item shape and only mutates attributes. /// /// **Attribute ordering:** This macro must appear *before* any attributes it needs /// to inspect. An attribute macro only receives attributes that follow it in source @@ -48,10 +60,17 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// Derive macro that generates the companion memento type and `Recallable` impl. /// +/// Supports structs directly. +/// Supports enums by generating an enum-shaped memento with matching variants. +/// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only +/// when every variant field is assignment-only. +/// Complex enums can still derive `Recallable` alone and provide manual +/// `Recall` or `TryRecall` implementations. +/// /// The generated memento type: -/// - mirrors the original struct shape (named/tuple/unit), +/// - mirrors the original item shape (struct or enum), /// - includes fields unless marked with `#[recallable(skip)]`, -/// - uses the same visibility as the input struct, +/// - uses the same visibility as the input item, /// - keeps all generated fields private by omitting field-level visibility modifiers, /// - also derives `serde::Deserialize` when the `serde` feature is enabled for the /// macro crate. @@ -60,15 +79,15 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// `::Memento`. The macro does not prescribe one canonical container /// semantics; it uses whatever memento shape the field type defines. /// -/// The companion struct itself is generated as an internal implementation detail. The supported -/// way to name it is `::Memento`. It is intended to be produced and consumed -/// alongside the source struct, primarily through `Recall::recall`/`TryRecall::try_recall`, not as +/// The companion type itself is generated as an internal implementation detail. The supported +/// way to name it is `::Memento`. It is intended to be deserialized and applied +/// alongside the source item, primarily through `Recall::recall`/`TryRecall::try_recall`, not as /// a field-inspection surface with widened visibility. /// /// The `Recallable` impl sets `type Memento` to that generated type and adds any required generic /// bounds. /// -/// The generated memento struct always derives `Clone`, `Debug`, and `PartialEq`. +/// The generated memento type always derives `Clone`, `Debug`, and `PartialEq`. /// When the `serde` feature is enabled, it also derives `serde::Deserialize`. /// All non-skipped field types must implement these derived traits. /// @@ -78,35 +97,26 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// still derived on the memento even with this attribute. /// /// When the `impl_from` feature is enabled for the macro crate, a -/// `From` implementation is also generated for the memento type. For `#[recallable]` -/// fields, that additionally requires `::Memento: From`. +/// `From` implementation is also generated for the memento type. This is useful for +/// in-memory snapshot flows. For `#[recallable]` fields, that additionally requires +/// `::Memento: From`. #[proc_macro_derive(Recallable, attributes(recallable))] pub fn derive_recallable(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::StructIr::analyze(&input) { + let ir = match context::analyze_item(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; let env = context::CodegenEnv::resolve(); - let memento_struct = context::gen_memento_struct(&ir, &env); + let memento_struct = context::gen_memento_type(&ir, &env); let recallable_impl = context::gen_recallable_impl(&ir, &env); - let from_impl = if context::IMPL_FROM_ENABLED { - let from_impl = context::gen_from_impl(&ir, &env); - quote! { - #[automatically_derived] - #from_impl - } - } else { - quote! {} - }; + let from_impl = context::IMPL_FROM_ENABLED.then_some(context::gen_from_impl(&ir, &env)); let output = quote! { const _: () = { - #[automatically_derived] #memento_struct - #[automatically_derived] #recallable_impl #from_impl @@ -125,9 +135,14 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// `Recall` implementation. +/// Enums are supported only when every non-marker variant field is +/// assignment-only. `PhantomData<_>` marker fields are auto-skipped by the +/// derive, and explicit `#[recallable(skip)]` on them remains accepted. +/// For supported enums, the generated implementation restores the target variant +/// from the memento directly. pub fn derive_recall(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::StructIr::analyze(&input) { + let ir = match context::analyze_recall_input(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index b33efc5..59f0834 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -1,34 +1,39 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Fields, ItemStruct, parse_macro_input, parse_quote}; +use quote::ToTokens; +use syn::{Fields, Item, ItemEnum, ItemStruct, parse_quote}; -use crate::context::{SERDE_ENABLED, crate_path, has_recallable_skip_attr}; +use crate::context::{self, SERDE_ENABLED, crate_path, has_recallable_skip_attr}; + +const DERIVE: &str = "derive"; +const SERIALIZE: &str = "Serialize"; +const SERDE: &str = "serde"; +const SERDE_DERIVE: &str = "serde_derive"; #[must_use] pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { - let attr_tokens: TokenStream2 = attr.into(); - if let Err(err) = validate_model_attr(&attr_tokens) { - return err.to_compile_error().into(); + match expand_tokens(attr.into(), item.into()) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), } +} - let crate_path = crate_path(); - let mut input = parse_macro_input!(item as ItemStruct); - - if SERDE_ENABLED && let Err(e) = check_no_serialize_derive(&input.attrs) { - return e.to_compile_error().into(); +fn expand_tokens(attr: TokenStream2, item: TokenStream2) -> syn::Result { + validate_model_attr(&attr)?; + let mut model_item = parse_model_item_tokens(item)?; + let derive_input = model_item.parse(); + context::analyze_model_input(&derive_input)?; + if SERDE_ENABLED { + check_no_serialize_derive(model_item.attrs())?; } - let derives = build_model_derive_attr(&crate_path); - - input.attrs.push(derives); - + model_item.add_derives(); if SERDE_ENABLED { - add_serde_skip_attrs(&mut input.fields); + model_item.add_serde_skip_attrs(); } - (quote! { #input }).into() + Ok(model_item.item_tokenstream()) } fn validate_model_attr(attr: &TokenStream2) -> syn::Result<()> { @@ -55,51 +60,115 @@ fn build_model_derive_attr(crate_path: &TokenStream2) -> syn::Attribute { } } -fn add_serde_skip_attrs(fields: &mut Fields) { - for field in fields.iter_mut() { - if has_recallable_skip_attr(field) { - field.attrs.push(parse_quote! { #[serde(skip)] }); - } +fn parse_model_item_tokens(item: TokenStream2) -> syn::Result { + let item: Item = syn::parse2(item)?; + match item { + Item::Struct(item) => Ok(ModelItem::Struct(item)), + Item::Enum(item) => Ok(ModelItem::Enum(item)), + other => Err(syn::Error::new_spanned( + other, + "`#[recallable_model]` can only be applied to structs or enums", + )), } } +fn add_serde_skip_attrs_to_fields(fields: &mut Fields) { + fields + .iter_mut() + .filter(|field| has_recallable_skip_attr(field)) + .for_each(|field| field.attrs.push(parse_quote! { #[serde(skip)] })); +} + /// Returns an error if any existing `#[derive(...)]` attribute on the struct /// already includes a serde-backed `Serialize` derive. /// /// Called only when `SERDE_ENABLED` is true, before `#[recallable_model]` /// injects its own `::serde::Serialize` derive. fn check_no_serialize_derive(attrs: &[syn::Attribute]) -> syn::Result<()> { - for attr in attrs { - if !attr.path().is_ident("derive") { - continue; - } - attr.parse_nested_meta(|meta| { - if is_serde_serialize_path(&meta.path) { - return Err(meta.error( - "`#[recallable_model]` already derives `serde::Serialize` when the \ - `serde` feature is enabled — remove the manual `Serialize` derive", - )); - } - Ok(()) - })?; - } - Ok(()) + attrs + .iter() + .filter(|attr| attr.path().is_ident(DERIVE)) + .try_for_each(|attr| { + attr.parse_nested_meta(|meta| { + if is_serde_serialize_path(&meta.path) { + Err(meta.error( + "`#[recallable_model]` already derives `serde::Serialize` when the \ + `serde` feature is enabled — remove the manual `Serialize` derive", + )) + } else { + Ok(()) + } + }) + }) } fn is_serde_serialize_path(path: &syn::Path) -> bool { - if path.is_ident("Serialize") { - // Attribute macros cannot resolve imported names, so keep treating a bare - // `Serialize` derive as serde-shaped for the common `use serde::Serialize;` case. - return true; - } - - let mut segments = path.segments.iter(); - matches!( - (segments.next(), segments.next(), segments.next()), - (Some(first), Some(second), None) - if (first.ident == "serde" || first.ident == "serde_derive") - && second.ident == "Serialize" - ) + // Attribute macros cannot resolve imported names, so keep treating a bare + // `Serialize` derive as serde-shaped for the common `use serde::Serialize;` case. + path.is_ident("Serialize") || { + let mut segments = path.segments.iter(); + matches!( + (segments.next(), segments.next(), segments.next()), + (Some(first), Some(second), None) + if (first.ident == SERDE || first.ident == SERDE_DERIVE) + && second.ident == SERIALIZE + ) + } +} + +enum ModelItem { + Struct(ItemStruct), + Enum(ItemEnum), +} + +impl ModelItem { + fn attrs(&self) -> &[syn::Attribute] { + match self { + Self::Struct(item) => &item.attrs, + Self::Enum(item) => &item.attrs, + } + } + + fn attrs_mut(&mut self) -> &mut Vec { + match self { + Self::Struct(item) => &mut item.attrs, + Self::Enum(item) => &mut item.attrs, + } + } + + fn with_fields_mut(&mut self, mut apply: impl FnMut(&mut Fields)) { + match self { + Self::Struct(item) => apply(&mut item.fields), + Self::Enum(item) => item + .variants + .iter_mut() + .for_each(|variant| apply(&mut variant.fields)), + } + } + + fn add_derives(&mut self) { + let crate_path = crate_path(); + let derives = build_model_derive_attr(&crate_path); + self.attrs_mut().push(derives); + } + + fn add_serde_skip_attrs(&mut self) { + self.with_fields_mut(add_serde_skip_attrs_to_fields); + } + + fn item_tokenstream(&self) -> TokenStream2 { + match self { + ModelItem::Struct(item) => item.to_token_stream(), + ModelItem::Enum(item) => item.to_token_stream(), + } + } + + fn parse(&self) -> syn::DeriveInput { + match self { + ModelItem::Struct(item) => item.clone().into(), + ModelItem::Enum(item) => item.clone().into(), + } + } } #[cfg(test)] @@ -107,7 +176,9 @@ mod tests { use quote::quote; use syn::parse_quote; - use super::{is_serde_serialize_path, validate_model_attr}; + use super::{ + expand_tokens, is_serde_serialize_path, parse_model_item_tokens, validate_model_attr, + }; #[test] fn serde_serialize_path_detection_is_precise() { @@ -141,4 +212,81 @@ mod tests { .contains("`#[recallable_model]` does not accept arguments") ); } + + #[test] + fn parse_model_item_rejects_non_struct_or_enum_items() { + let error = match parse_model_item_tokens(quote!( + fn example() {} + )) { + Ok(_) => panic!("expected parse_model_item to reject functions"), + Err(error) => error, + }; + + assert_eq!( + error.to_string(), + "`#[recallable_model]` can only be applied to structs or enums" + ); + } + + #[test] + fn expand_tokens_reject_model_arguments() { + let error = expand_tokens( + quote!(unexpected), + quote!( + struct Example; + ), + ) + .unwrap_err(); + + assert!(error.to_string().contains("does not accept arguments")); + } + + #[test] + fn expand_tokens_reject_non_model_items() { + let error = expand_tokens( + quote!(), + quote!( + fn example() {} + ), + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("can only be applied to structs or enums") + ); + } + + #[test] + fn expand_tokens_reject_model_analysis_failures() { + let error = expand_tokens( + quote!(), + quote! { + enum Example { + Value(#[recallable] Inner), + } + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("assignment-only variants")); + } + + #[cfg(feature = "serde")] + #[test] + fn expand_tokens_reject_manual_serialize_derives() { + let error = expand_tokens( + quote!(), + quote! { + #[derive(serde::Serialize)] + struct Example { + value: u32, + } + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("already derives")); + } } diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index 336969f..4783730 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -1,6 +1,6 @@ //! # Recallable //! -//! A crate for handling partial updates to data structures. +//! A crate for memento-based recall on live data structures. //! //! This crate provides the [`Recallable`], [`Recall`], and [`TryRecall`] traits, along with //! derive macros for `Recallable` and `Recall`, and an attribute macro `recallable_model` @@ -10,6 +10,12 @@ //! turns on macro-generated serde support, and it remains usable in `no_std` environments when //! your serde stack is configured without `std`. //! +//! Persisted-state compatibility depends on the chosen codec or format: serialize the source +//! value, deserialize into the generated memento, then apply it. Generated mementos are +//! apply-side/deserialization targets, not the default write-side/export format. Any +//! Serde-compatible format can be used if it can target the generated memento, and fixed-layout +//! binary codecs should be treated as schema-sensitive unless you version them explicitly. +//! //! ## Motivation //! //! Many systems receive incremental updates where only a subset of fields change or can be @@ -26,15 +32,22 @@ extern crate self as recallable; -/// Attribute macro that prepares a struct for the Memento pattern. +/// Attribute macro that prepares a struct or assignment-only enum for the Memento pattern. /// /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, -/// also derives `serde::Serialize` on the struct and injects `#[serde(skip)]` on fields -/// marked with `#[recallable(skip)]`. +/// also derives `serde::Serialize` on the source item and injects `#[serde(skip)]` on fields +/// marked with `#[recallable(skip)]`, so the source-side serde shape stays aligned with the +/// generated memento in the common path. +/// Complex enums with nested `#[recallable]` fields or non-marker +/// `#[recallable(skip)]` fields should derive [`Recallable`] and implement +/// [`Recall`] or [`TryRecall`] manually. Skipped `PhantomData<_>` marker fields +/// remain supported on assignment-only enums, and `PhantomData<_>` fields are +/// auto-skipped by the derive even without an explicit `#[recallable(skip)]`. /// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// @@ -63,26 +76,85 @@ extern crate self as recallable; /// // on_change is skipped — unchanged by recall /// # } /// ``` +/// +/// Enum example: +/// +/// ```rust +/// # #[cfg(feature = "serde")] +/// # { +/// use recallable::{Recall, Recallable, recallable_model}; +/// +/// #[recallable_model] +/// #[derive(Clone, Debug, PartialEq)] +/// enum Mode { +/// Off, +/// On { level: u8 }, +/// } +/// +/// let mut mode = Mode::Off; +/// let memento: ::Memento = +/// serde_json::from_str(r#"{"On":{"level":3}}"#).unwrap(); +/// mode.recall(memento); +/// assert_eq!(mode, Mode::On { level: 3 }); +/// # } +/// ``` pub use recallable_macro::recallable_model; -/// Derive macro that generates a companion memento struct and the [`Recallable`] trait impl. -/// -/// The memento struct mirrors the original but replaces `#[recallable]`-annotated fields -/// with their `::Memento` type and omits `#[recallable(skip)]` fields. -/// The generated companion type has the same visibility as the input struct. +/// Derive macro that generates a companion memento type and the [`Recallable`] trait impl. +/// +/// Supports structs directly. +/// Supports enums by generating an enum-shaped memento with matching variants. +/// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only +/// when every non-marker variant field is assignment-only. +/// Enums with nested `#[recallable]` fields or non-marker `#[recallable(skip)]` +/// fields can still derive [`Recallable`] and provide manual [`Recall`] or +/// [`TryRecall`] behavior. +/// +/// The generated memento type mirrors the original item but replaces `#[recallable]`-annotated +/// fields with their `::Memento` type and omits +/// `#[recallable(skip)]` fields. +/// The generated companion type has the same visibility as the input item. /// Its fields are always emitted without visibility modifiers, so they remain private to the -/// containing module. This is intentional: mementos are meant to be created and consumed alongside -/// the companion struct, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with only -/// occasional same-file testing or debugging use. +/// containing module. This is intentional: mementos are meant to be deserialized and applied +/// alongside the source item, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with +/// only occasional same-file testing or debugging use. /// For container-like field types, this is whatever memento shape that field type chose; the macro /// does not special-case merge semantics. /// When the `impl_from` feature is enabled, `#[derive(Recallable)]` also generates -/// `From` for the memento type, which requires +/// `From` for the memento type, which is useful for in-memory snapshot flows and requires /// `::Memento: From` for each `#[recallable]` field. /// +/// When a skipped field would otherwise remove the last field-level mention of a +/// retained generic parameter, the derive keeps that generic alive with an +/// internal `PhantomData` marker on the generated memento. This matters for +/// cases like: +/// +/// ```rust +/// use core::any::TypeId; +/// use core::marker::PhantomData; +/// use recallable::Recallable; +/// use std::string::String; +/// +/// #[derive(Recallable)] +/// struct BoundDependent, U> { +/// value: T, +/// #[recallable(skip)] +/// marker: PhantomData, +/// } +/// +/// type Left = as Recallable>::Memento; +/// type Right = as Recallable>::Memento; +/// +/// assert_ne!(TypeId::of::(), TypeId::of::()); +/// ``` +/// +/// Here `U` is not a visible memento field, but it still matters because the +/// retained generic `T` depends on it through `T: From`. +/// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// @@ -122,10 +194,18 @@ pub use recallable_macro::Recallable; /// Fields marked `#[recallable(skip)]` are left untouched. /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// [`Recall`] implementation. +/// Enums are supported only when every non-marker variant field is +/// assignment-only. `PhantomData<_>` marker fields are allowed because the derive +/// auto-skips them, and explicit `#[recallable(skip)]` on such fields remains accepted. +/// Enums with nested `#[recallable]` fields or other skipped fields should +/// derive [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. +/// For supported enums, the derived implementation restores the target variant +/// from the memento directly. /// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// diff --git a/recallable/tests/basic.rs b/recallable/tests/basic.rs index 4ad0549..7d14347 100644 --- a/recallable/tests/basic.rs +++ b/recallable/tests/basic.rs @@ -65,6 +65,7 @@ fn test_try_recall_custom_error() { } mod phantom_lifetime { + use core::any::TypeId; use core::marker::PhantomData; use recallable::{Recall, Recallable}; @@ -87,6 +88,32 @@ mod phantom_lifetime { s.recall(memento); assert_eq!(s.value, 42); } + + #[derive(Recallable, Recall)] + struct PhantomType { + marker: PhantomData, + value: u8, + } + + type PhantomTypeLeft = as Recallable>::Memento; + type PhantomTypeRight = as Recallable>::Memento; + + #[test] + fn test_phantom_type_is_auto_skipped_from_memento() { + let mut s = PhantomType:: { + marker: PhantomData, + value: 10, + }; + let memento = PhantomTypeLeft { value: 42 }; + + s.recall(memento); + + assert_eq!(s.value, 42); + assert_eq!( + TypeId::of::(), + TypeId::of::() + ); + } } mod skipped_borrowed_field { diff --git a/recallable/tests/enum_model.rs b/recallable/tests/enum_model.rs new file mode 100644 index 0000000..6f06a0a --- /dev/null +++ b/recallable/tests/enum_model.rs @@ -0,0 +1,25 @@ +#![cfg(feature = "serde")] + +use recallable::{Recall, Recallable, recallable_model}; + +#[recallable_model] +#[derive(Clone, Debug, PartialEq)] +enum ModelEnum { + Idle, + Ready { value: u32, label: String }, +} + +#[test] +fn test_recallable_model_supports_assignment_only_enums() { + let expected = ModelEnum::Ready { + value: 7, + label: "ok".to_string(), + }; + let json = serde_json::to_string(&expected).unwrap(); + let memento: ::Memento = serde_json::from_str(&json).unwrap(); + + let mut actual = ModelEnum::Idle; + actual.recall(memento); + + assert_eq!(actual, expected); +} diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs new file mode 100644 index 0000000..c23f409 --- /dev/null +++ b/recallable/tests/enum_recall.rs @@ -0,0 +1,68 @@ +#![deny(dead_code)] + +use core::marker::PhantomData; + +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum AssignmentOnlyEnum { + #[allow(dead_code)] + Idle, + Loading(T), + Ready { + bytes: [u8; 2], + version: u8, + marker: PhantomData<[(); N]>, + }, +} + +type AssignmentOnlyMemento = as Recallable>::Memento; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum ExplicitSkippedPhantomEnum { + Idle, + Ready { + #[recallable(skip)] + marker: PhantomData, + value: u8, + }, +} + +type ExplicitSkippedPhantomMemento = as Recallable>::Memento; + +#[test] +fn test_assignment_only_enum_recall_switches_to_tuple_variant() { + let mut state = AssignmentOnlyEnum::::Idle; + state.recall(AssignmentOnlyMemento::Loading(7)); + assert_eq!(state, AssignmentOnlyEnum::Loading(7)); +} + +#[test] +fn test_assignment_only_enum_recall_switches_to_named_variant() { + let mut state = AssignmentOnlyEnum::::Loading(1); + state.recall(AssignmentOnlyMemento::Ready { + bytes: [4, 5], + version: 9, + }); + assert_eq!( + state, + AssignmentOnlyEnum::Ready { + bytes: [4, 5], + version: 9, + marker: PhantomData, + } + ); +} + +#[test] +fn test_assignment_only_enum_recall_accepts_explicit_skipped_phantom_field() { + let mut state = ExplicitSkippedPhantomEnum::::Idle; + state.recall(ExplicitSkippedPhantomMemento::Ready { value: 7 }); + assert_eq!( + state, + ExplicitSkippedPhantomEnum::Ready { + marker: PhantomData, + value: 7, + } + ); +} diff --git a/recallable/tests/enum_recallable.rs b/recallable/tests/enum_recallable.rs new file mode 100644 index 0000000..16e1c3d --- /dev/null +++ b/recallable/tests/enum_recallable.rs @@ -0,0 +1,135 @@ +use core::marker::PhantomData; +use std::any::TypeId; + +use recallable::Recallable; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +struct GenericInner { + value: T, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum NestedEnum { + Idle, + Ready { + #[recallable] + inner: GenericInner, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum BareGenericEnum { + Ready { + #[recallable] + inner: T, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum UnitVariantEnum { + Idle, + Value(u8), +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum SkippedEnum<'a> { + Idle, + Borrowed { + #[recallable(skip)] + name: &'a str, + value: u8, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum PhantomEnum<'a, T> { + Idle, + Value { + marker: PhantomData<&'a T>, + value: T, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum BoundDependentEnum, U> { + Value { + value: T, + #[recallable(skip)] + marker: PhantomData, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum SkippedGenericEnum { + Value(T), + Marker(#[recallable(skip)] PhantomData), +} + +#[test] +fn test_enum_with_recallable_field_derives_recallable() { + fn assert_recallable() {} + assert_recallable::>(); +} + +#[test] +fn test_enum_with_skipped_field_derives_recallable() { + type Memento = as Recallable>::Memento; + let _ = core::any::type_name::(); +} + +#[test] +fn test_enum_with_phantom_lifetime_derives_recallable() { + type Memento = as Recallable>::Memento; + let _ = core::any::type_name::(); +} + +#[test] +fn test_enum_memento_retains_bound_dependencies() { + type Left = as Recallable>::Memento; + type Right = as Recallable>::Memento; + + assert_ne!(TypeId::of::(), TypeId::of::()); +} + +#[test] +fn test_enum_memento_prunes_skipped_generic_params() { + type Left = as Recallable>::Memento; + type Right = as Recallable>::Memento; + + assert_eq!(TypeId::of::(), TypeId::of::()); +} + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_is_generated_for_recallable_only_enums() { + let _: fn(NestedEnum) -> as Recallable>::Memento = + ::core::convert::From::from; +} + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_supports_bare_generic_recallable_fields() { + let original = BareGenericEnum::Ready { + inner: GenericInner { value: 42_u32 }, + }; + + let _: > as Recallable>::Memento = original.into(); +} + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_preserves_unit_variants() { + let original = UnitVariantEnum::Idle; + let memento: ::Memento = original.clone().into(); + let mut target = UnitVariantEnum::Value(7); + + ::recall(&mut target, memento); + assert_eq!(target, original); +} diff --git a/recallable/tests/macro_expansion_failures.rs b/recallable/tests/macro_expansion_failures.rs index 21faf2a..97a4fb9 100644 --- a/recallable/tests/macro_expansion_failures.rs +++ b/recallable/tests/macro_expansion_failures.rs @@ -4,6 +4,9 @@ fn derive_macro_reports_expected_failures() { tests.compile_fail("tests/ui/derive_fail_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_multiple_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_non_struct.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_recall_nested.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_recall_skip.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_field_not_path.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_unknown_parameter.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_skip_with_unknown_parameter.rs"); @@ -12,7 +15,10 @@ fn derive_macro_reports_expected_failures() { tests.compile_fail("tests/ui/derive_fail_recallable_skip_on_struct.rs"); tests.compile_fail("tests/ui/model_fail_recallable_skip_with_unknown_parameter.rs"); tests.compile_fail("tests/ui/model_fail_recallable_conflicting_attributes.rs"); + tests.compile_fail("tests/ui/model_fail_enum_recallable_variant.rs"); + tests.compile_fail("tests/ui/model_fail_enum_skip_variant.rs"); tests.pass("tests/ui/derive_pass_skip_memento_default_derives.rs"); + tests.pass("tests/ui/derive_pass_enum_no_warnings.rs"); #[cfg(feature = "serde")] { tests.compile_fail("tests/ui/model_fail_duplicate_serialize.rs"); diff --git a/recallable/tests/no_serde.rs b/recallable/tests/no_serde.rs index b80155c..053656f 100644 --- a/recallable/tests/no_serde.rs +++ b/recallable/tests/no_serde.rs @@ -95,6 +95,35 @@ struct DependentBoundOuter, U> { marker: PhantomData, } +#[derive(recallable::Recallable, recallable::Recall)] +struct LifetimeBoundOuter<'a, T> +where + T: Clone + core::fmt::Debug + PartialEq + From<&'a str>, +{ + value: T, + #[recallable(skip)] + marker: PhantomData<&'a str>, +} + +#[derive(recallable::Recallable, recallable::Recall)] +struct ConstBoundOuter +where + T: Clone + core::fmt::Debug + PartialEq + From>, +{ + value: T, + #[recallable(skip)] + marker: PhantomData>, +} + +#[derive(recallable::Recallable, recallable::Recall)] +struct ConcretePredicateOuter +where + T: Clone + core::fmt::Debug + PartialEq, + [u8; 16]: Copy, +{ + value: T, +} + #[derive(Clone, Debug, PartialEq, Deserialize, recallable::Recallable, recallable::Recall)] struct GenericInner { value: T, @@ -136,6 +165,15 @@ struct ConstTag { value: usize, } +#[derive(Clone, Debug, PartialEq)] +struct GenericConstValue; + +impl From> for GenericConstValue { + fn from(_: ConstTag) -> Self { + Self + } +} + #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] struct ConstBuffer { tag: ConstTag, @@ -153,6 +191,15 @@ struct ConstOuter { inner: ConstBuffer, } +mod fixed_lengths { + pub(super) const WIDTH: usize = 8; +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +struct FixedWidthBuffer { + bytes: [u8; fixed_lengths::WIDTH], +} + #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] struct MixedConstOuter { #[recallable] @@ -264,6 +311,41 @@ fn test_bound_dependencies_keep_other_generic_params_retained() { assert_ne!(TypeId::of::(), TypeId::of::()); } +#[test] +fn test_bound_dependencies_keep_lifetimes_on_memento_types() { + // Regression: the retained where-clause lifetime `'a` must stay on the generated + // memento type, and the skipped field means the synthetic marker must mention it + // using the lifetime branch of `marker_component`. + let _: fn( + &mut LifetimeBoundOuter<'static, String>, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; +} + +#[test] +fn test_bound_dependencies_keep_const_params_on_memento_types() { + // Regression: the retained const param `N` only survives via the skipped field, + // so the synthetic marker must mention it using the const branch of + // `marker_component`. + let _: fn( + &mut ConstBoundOuter, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; + + type Left = as recallable::Recallable>::Memento; + type Right = as recallable::Recallable>::Memento; + + assert_ne!(TypeId::of::(), TypeId::of::()); +} + +#[test] +fn test_concrete_only_where_predicates_are_accepted() { + let _: fn( + &mut ConcretePredicateOuter, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; +} + #[test] fn test_recallable_field_accepts_generic_path_type() { let _: fn(&mut GenericPathOuter, ::Memento) = @@ -311,6 +393,12 @@ fn test_const_generic_recallable_field_with_mixed_type_and_const_args() { ) = as recallable::Recall>::recall; } +#[test] +fn test_multi_segment_const_paths_are_supported() { + let _: fn(&mut FixedWidthBuffer, ::Memento) = + ::recall; +} + #[test] fn test_skipped_only_const_params_are_pruned_from_memento_type() { type Left = as recallable::Recallable>::Memento; diff --git a/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs b/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs new file mode 100644 index 0000000..4d2ddaa --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs @@ -0,0 +1,8 @@ +use recallable::Recallable; + +#[derive(Recallable)] +enum BorrowedEnum<'a> { + Borrowed(&'a str), +} + +fn main() {} diff --git a/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr b/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr new file mode 100644 index 0000000..fd6b377 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr @@ -0,0 +1,5 @@ +error: Recall derives do not support borrowed fields + --> tests/ui/derive_fail_enum_borrowed_fields.rs:5:14 + | +5 | Borrowed(&'a str), + | ^^^^^^^ diff --git a/recallable/tests/ui/derive_fail_enum_recall_nested.rs b/recallable/tests/ui/derive_fail_enum_recall_nested.rs new file mode 100644 index 0000000..d6d8bb3 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_nested.rs @@ -0,0 +1,16 @@ +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, Recallable)] +struct Inner { + value: u8, +} + +#[derive(Recallable, Recall)] +enum InvalidNestedRecallEnum { + Ready { + #[recallable] + inner: Inner, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/derive_fail_enum_recall_nested.stderr b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr new file mode 100644 index 0000000..5be4d9e --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr @@ -0,0 +1,6 @@ +error: enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and implement `Recall` or `TryRecall` manually + --> tests/ui/derive_fail_enum_recall_nested.rs:11:9 + | +11 | / #[recallable] +12 | | inner: Inner, + | |____________________^ diff --git a/recallable/tests/ui/derive_fail_enum_recall_skip.rs b/recallable/tests/ui/derive_fail_enum_recall_skip.rs new file mode 100644 index 0000000..7487792 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_skip.rs @@ -0,0 +1,12 @@ +use recallable::{Recall, Recallable}; + +#[derive(Recallable, Recall)] +enum InvalidSkippedRecallEnum { + Ready { + #[recallable(skip)] + sticky: u8, + value: u8, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/derive_fail_enum_recall_skip.stderr b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr new file mode 100644 index 0000000..037054f --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr @@ -0,0 +1,6 @@ +error: enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and implement `Recall` or `TryRecall` manually + --> tests/ui/derive_fail_enum_recall_skip.rs:6:9 + | +6 | / #[recallable(skip)] +7 | | sticky: u8, + | |__________________^ diff --git a/recallable/tests/ui/derive_fail_non_struct.rs b/recallable/tests/ui/derive_fail_non_struct.rs index ae2b592..254a395 100644 --- a/recallable/tests/ui/derive_fail_non_struct.rs +++ b/recallable/tests/ui/derive_fail_non_struct.rs @@ -1,8 +1,8 @@ use recallable::Recallable; #[derive(Recallable)] -enum NotAStruct { - Value(i32), +union NotSupported { + value: u32, } fn main() {} diff --git a/recallable/tests/ui/derive_fail_non_struct.stderr b/recallable/tests/ui/derive_fail_non_struct.stderr index 56d102d..319e129 100644 --- a/recallable/tests/ui/derive_fail_non_struct.stderr +++ b/recallable/tests/ui/derive_fail_non_struct.stderr @@ -1,7 +1,7 @@ -error: This derive macro can only be applied to structs +error: This derive macro can only be applied to structs or enums --> tests/ui/derive_fail_non_struct.rs:4:1 | -4 | / enum NotAStruct { -5 | | Value(i32), +4 | / union NotSupported { +5 | | value: u32, 6 | | } | |_^ diff --git a/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr b/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr index 5e85180..cc5778a 100644 --- a/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr +++ b/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr @@ -1,4 +1,4 @@ -error: `skip` is a field-level attribute, not a struct-level attribute +error: `skip` is a field-level attribute, not an item-level attribute --> tests/ui/derive_fail_recallable_skip_on_struct.rs:4:14 | 4 | #[recallable(skip)] diff --git a/recallable/tests/ui/derive_pass_enum_no_warnings.rs b/recallable/tests/ui/derive_pass_enum_no_warnings.rs new file mode 100644 index 0000000..e767a05 --- /dev/null +++ b/recallable/tests/ui/derive_pass_enum_no_warnings.rs @@ -0,0 +1,28 @@ +#![deny(warnings)] + +use core::marker::PhantomData; + +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum Example { + Idle, + Loading(T), + Ready { + value: T, + marker: PhantomData<[(); N]>, + }, +} + +fn main() { + type Memento = as Recallable>::Memento; + + let _ = Example::::Idle; + + let mut state = Example::::Loading(1); + state.recall(Memento::Ready { + value: 2, + }); + + let _ = state; +} diff --git a/recallable/tests/ui/model_fail_enum_recallable_variant.rs b/recallable/tests/ui/model_fail_enum_recallable_variant.rs new file mode 100644 index 0000000..eb2af8d --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_recallable_variant.rs @@ -0,0 +1,16 @@ +use recallable::recallable_model; + +#[derive(Clone, Debug, PartialEq)] +struct Inner { + value: u8, +} + +#[recallable_model] +enum InvalidModelNestedEnum { + Ready { + #[recallable] + inner: Inner, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/model_fail_enum_recallable_variant.stderr b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr new file mode 100644 index 0000000..80f6e98 --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr @@ -0,0 +1,5 @@ +error: `#[recallable_model]` on enums requires assignment-only variants; complex enums should derive `Recallable` and implement `Recall` or `TryRecall` manually + --> tests/ui/model_fail_enum_recallable_variant.rs:9:6 + | +9 | enum InvalidModelNestedEnum { + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/recallable/tests/ui/model_fail_enum_skip_variant.rs b/recallable/tests/ui/model_fail_enum_skip_variant.rs new file mode 100644 index 0000000..cf903d9 --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_skip_variant.rs @@ -0,0 +1,12 @@ +use recallable::recallable_model; + +#[recallable_model] +enum InvalidModelSkippedEnum { + Ready { + #[recallable(skip)] + sticky: u8, + value: u8, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/model_fail_enum_skip_variant.stderr b/recallable/tests/ui/model_fail_enum_skip_variant.stderr new file mode 100644 index 0000000..5f85d9b --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_skip_variant.stderr @@ -0,0 +1,5 @@ +error: `#[recallable_model]` on enums requires assignment-only variants; complex enums should derive `Recallable` and implement `Recall` or `TryRecall` manually + --> tests/ui/model_fail_enum_skip_variant.rs:4:6 + | +4 | enum InvalidModelSkippedEnum { + | ^^^^^^^^^^^^^^^^^^^^^^^