From 7a2a33c1aaaa850b15540f041dc2e2d46799cf59 Mon Sep 17 00:00:00 2001 From: nothingnesses <18732253+nothingnesses@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:54:06 +0000 Subject: [PATCH 1/2] Add #[kind] attribute macro and improve documentation macros - Add #[kind] attribute macro for ergonomic Kind supertrait bounds, replacing raw hash-based trait names (e.g. Kind_cdc7cd43dac7585f) with #[kind(type Of<'a, A: 'a>: 'a;)] annotations - Add Kind variant to TraitCategory for proper Kind_* classification - Fix WarningEmitter name collision across multiple macro invocations using global AtomicUsize counter - Reject duplicate #[document_*] attributes with clear error messages, with compile-fail tests for each macro - Remove #[document_fields] macro in favor of native /// doc comments - Restrict #[document_signature] to functions and methods only - Warn when traits use raw Kind_* supertraits instead of #[kind(...)] --- .gitignore | 3 +- CLAUDE.md | 36 ++ README.md | 2 +- fp-library/src/classes/bifoldable.rs | 3 +- fp-library/src/classes/bifunctor.rs | 5 +- fp-library/src/classes/category.rs | 2 +- fp-library/src/classes/compactable.rs | 3 +- fp-library/src/classes/contravariant.rs | 5 +- fp-library/src/classes/filterable.rs | 2 - fp-library/src/classes/foldable.rs | 3 +- fp-library/src/classes/functor.rs | 5 +- fp-library/src/classes/lift.rs | 3 +- fp-library/src/classes/par_compactable.rs | 3 +- fp-library/src/classes/par_foldable.rs | 3 +- fp-library/src/classes/par_functor.rs | 3 +- fp-library/src/classes/pointed.rs | 3 +- fp-library/src/classes/profunctor.rs | 8 +- fp-library/src/classes/profunctor/choice.rs | 2 +- fp-library/src/classes/profunctor/strong.rs | 2 +- fp-library/src/classes/ref_functor.rs | 3 +- fp-library/src/classes/semigroupoid.rs | 9 +- fp-library/src/classes/semimonad.rs | 3 +- fp-library/src/lib.rs | 2 +- fp-library/src/types/cat_list.rs | 6 +- fp-library/src/types/endofunction.rs | 3 +- fp-library/src/types/endomorphism.rs | 7 +- fp-library/src/types/fn_brand.rs | 2 +- fp-library/src/types/free.rs | 1 - fp-library/src/types/identity.rs | 7 +- fp-library/src/types/lazy.rs | 7 +- fp-library/src/types/pair.rs | 9 +- fp-library/src/types/thunk.rs | 7 +- fp-library/src/types/trampoline.rs | 7 +- fp-library/src/types/try_lazy.rs | 2 +- fp-library/src/types/try_thunk.rs | 7 +- fp-library/src/types/try_trampoline.rs | 7 +- fp-library/tests/document_fields_example.rs | 52 --- fp-macros/src/analysis/traits.rs | 2 + fp-macros/src/core/constants.rs | 5 +- fp-macros/src/core/warning_emitter.rs | 28 +- fp-macros/src/documentation.rs | 3 - .../src/documentation/document_examples.rs | 10 +- .../src/documentation/document_fields.rs | 396 ------------------ .../src/documentation/document_module.rs | 43 +- .../src/documentation/document_parameters.rs | 7 +- .../src/documentation/document_returns.rs | 3 + .../src/documentation/document_signature.rs | 15 +- .../documentation/document_type_parameters.rs | 48 ++- fp-macros/src/documentation/generation.rs | 118 ++++-- fp-macros/src/hkt.rs | 2 + fp-macros/src/hkt/kind_attr.rs | 118 ++++++ fp-macros/src/lib.rs | 209 +++------ fp-macros/src/support.rs | 2 - fp-macros/src/support/attributes.rs | 33 +- fp-macros/src/support/document_field.rs | 362 ---------------- .../src/support/generate_documentation.rs | 4 + fp-macros/src/support/parsing.rs | 237 +---------- .../tests/document_fields_integration.rs | 41 -- fp-macros/tests/document_module_tests.rs | 33 ++ .../tests/ui/duplicate_document_examples.rs | 13 + .../ui/duplicate_document_examples.stderr | 7 + .../tests/ui/duplicate_document_parameters.rs | 9 + .../ui/duplicate_document_parameters.stderr | 7 + .../tests/ui/duplicate_document_returns.rs | 11 + .../ui/duplicate_document_returns.stderr | 7 + .../ui/duplicate_document_signature_fn.rs | 14 + .../ui/duplicate_document_signature_fn.stderr | 7 + .../ui/duplicate_document_signature_module.rs | 16 + ...duplicate_document_signature_module.stderr | 10 + .../ui/duplicate_document_signature_trait.rs | 13 + .../duplicate_document_signature_trait.stderr | 7 + .../ui/duplicate_document_type_parameters.rs | 9 + .../duplicate_document_type_parameters.stderr | 7 + 73 files changed, 697 insertions(+), 1396 deletions(-) delete mode 100644 fp-library/tests/document_fields_example.rs delete mode 100644 fp-macros/src/documentation/document_fields.rs create mode 100644 fp-macros/src/hkt/kind_attr.rs delete mode 100644 fp-macros/src/support/document_field.rs delete mode 100644 fp-macros/tests/document_fields_integration.rs create mode 100644 fp-macros/tests/ui/duplicate_document_examples.rs create mode 100644 fp-macros/tests/ui/duplicate_document_examples.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_parameters.rs create mode 100644 fp-macros/tests/ui/duplicate_document_parameters.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_returns.rs create mode 100644 fp-macros/tests/ui/duplicate_document_returns.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_signature_fn.rs create mode 100644 fp-macros/tests/ui/duplicate_document_signature_fn.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_signature_module.rs create mode 100644 fp-macros/tests/ui/duplicate_document_signature_module.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_signature_trait.rs create mode 100644 fp-macros/tests/ui/duplicate_document_signature_trait.stderr create mode 100644 fp-macros/tests/ui/duplicate_document_type_parameters.rs create mode 100644 fp-macros/tests/ui/duplicate_document_type_parameters.stderr diff --git a/.gitignore b/.gitignore index b1fcbb52..d49957bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .direnv/ *.code-workspace /target -.vscode \ No newline at end of file +.vscode +.claude/test-cache/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 80c96414..33cf8a98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,42 @@ eval "$(direnv export bash 2>/dev/null)"; cargo doc --workspace --all-features - eval "$(direnv export bash 2>/dev/null)"; cargo test --workspace --all-features ``` +### Test Output Caching + +To avoid re-running expensive test suites when source files haven't changed, cache test outputs: + +**Cache file location:** `.claude/test-cache/` (gitignored) + +**After running tests**, save the output: +```bash +mkdir -p .claude/test-cache +eval "$(direnv export bash 2>/dev/null)"; cargo test --workspace --all-features 2>&1 | tee .claude/test-cache/test-output.txt +# Record the timestamp of the newest source file at cache time +find fp-library/src fp-macros/src -name '*.rs' -printf '%T@\n' | sort -rn | head -1 > .claude/test-cache/source-timestamp.txt +``` + +**Before re-running tests**, check if cache is still valid: +```bash +# Get newest source file timestamp +LATEST=$(find fp-library/src fp-macros/src -name '*.rs' -printf '%T@\n' | sort -rn | head -1) +CACHED=$(cat .claude/test-cache/source-timestamp.txt 2>/dev/null || echo "0") +if [ "$LATEST" = "$CACHED" ]; then + echo "=== CACHED TEST OUTPUT (no source changes) ===" + cat .claude/test-cache/test-output.txt +else + echo "=== Source files changed, re-running tests ===" + eval "$(direnv export bash 2>/dev/null)"; cargo test --workspace --all-features 2>&1 | tee .claude/test-cache/test-output.txt + echo "$LATEST" > .claude/test-cache/source-timestamp.txt +fi +``` + +**Always invalidate** (re-run tests) when: +- Any `.rs` file under `fp-library/src/` or `fp-macros/src/` has been modified since the cached timestamp +- `Cargo.toml` files have changed +- Test files under `tests/` have changed + +**Use cached output** when you just need to re-check results (e.g., confirming a test name, reviewing output) and no source files have changed. + ## Language Server & Code Intelligence This repository has rust-analyzer configured via MCP (Model Context Protocol). Claude Code can use the LSP tool to access: diff --git a/README.md b/README.md index baacc2eb..7822ea55 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A functional programming library for Rust featuring your favourite higher-kinded - **Higher-Kinded Types (HKT):** Implemented using lightweight higher-kinded polymorphism (type-level defunctionalization/brands). - **Macros:** Procedural macros for working with HKTs and monadic code: - - **HKT:** `trait_kind!`, `impl_kind!`, `Apply!` for defining and applying higher-kinded type encodings + - **HKT:** `trait_kind!`, `impl_kind!`, `Apply!`, `#[kind]` for defining and applying higher-kinded type encodings - **Do-Notation:** `m_do!` for monadic do-notation, `a_do!` for applicative do-notation - **Type Classes:** A comprehensive collection of standard type classes including: - **Core:** `Functor`, `Contravariant`, `Pointed`, `Applicative`, `Semiapplicative`, `Monad`, `Semimonad`, `Semigroup`, `Monoid`, `Foldable`, `Traversable`, `Alt`, `Plus`, `Alternative` diff --git a/fp-library/src/classes/bifoldable.rs b/fp-library/src/classes/bifoldable.rs index 0a1690de..a42da8aa 100644 --- a/fp-library/src/classes/bifoldable.rs +++ b/fp-library/src/classes/bifoldable.rs @@ -91,7 +91,8 @@ mod inner { /// ), /// ); /// ``` - pub trait Bifoldable: Kind_266801a817966495 { + #[kind(type Of<'a, A: 'a, B: 'a>: 'a;)] + pub trait Bifoldable { /// Folds the bifoldable structure from right to left using two step functions. /// /// This method performs a right-associative fold, dispatching to `f` for diff --git a/fp-library/src/classes/bifunctor.rs b/fp-library/src/classes/bifunctor.rs index df88429c..64e7cf63 100644 --- a/fp-library/src/classes/bifunctor.rs +++ b/fp-library/src/classes/bifunctor.rs @@ -30,7 +30,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait inherits from [`Kind_266801a817966495`], ensuring that all bifunctor + /// This trait inherits from [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495), ensuring that all bifunctor /// contexts satisfy the strict lifetime requirements where both type arguments must /// outlive the context's application lifetime. /// @@ -77,7 +77,8 @@ mod inner { /// bimap::(f, h, bimap::(g, i, err)), /// ); /// ``` - pub trait Bifunctor: Kind_266801a817966495 { + #[kind(type Of<'a, A: 'a, B: 'a>: 'a;)] + pub trait Bifunctor { /// Maps functions over the values in the bifunctor context. /// /// This method applies two functions to the values inside the bifunctor context, producing a new bifunctor context with the transformed values. diff --git a/fp-library/src/classes/category.rs b/fp-library/src/classes/category.rs index 32e0533a..6660a837 100644 --- a/fp-library/src/classes/category.rs +++ b/fp-library/src/classes/category.rs @@ -30,7 +30,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// By inheriting from [`Semigroupoid`], this trait implicitly requires [`Kind_266801a817966495`]. + /// By inheriting from [`Semigroupoid`], this trait implicitly requires [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495). /// This unification ensures that categorical identity morphisms also satisfy the strict lifetime /// requirements where the object type must outlive the morphism's application lifetime. /// diff --git a/fp-library/src/classes/compactable.rs b/fp-library/src/classes/compactable.rs index bc014f92..fef042a3 100644 --- a/fp-library/src/classes/compactable.rs +++ b/fp-library/src/classes/compactable.rs @@ -89,7 +89,8 @@ mod inner { /// plus_empty::(), /// ); /// ``` - pub trait Compactable: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Compactable { /// Compacts a data structure of [`Option`]s, discarding [`None`] values and keeping [`Some`] values. #[document_signature] /// diff --git a/fp-library/src/classes/contravariant.rs b/fp-library/src/classes/contravariant.rs index 3b69be09..bcaf195f 100644 --- a/fp-library/src/classes/contravariant.rs +++ b/fp-library/src/classes/contravariant.rs @@ -31,7 +31,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait inherits from [`Kind_cdc7cd43dac7585f`], ensuring that all contravariant + /// This trait inherits from [`Kind!(type Of<'a, A: 'a>: 'a;)`](crate::kinds::Kind_cdc7cd43dac7585f), ensuring that all contravariant /// contexts satisfy the strict lifetime requirements where the type argument must /// outlive the context's application lifetime. /// @@ -72,7 +72,8 @@ mod inner { /// assert_eq!(left(5), right(5)); /// assert_eq!(left(-10), right(-10)); /// ``` - pub trait Contravariant: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Contravariant { /// Maps a function contravariantly over the context. /// /// This method applies a function to the input before it is consumed by the context. diff --git a/fp-library/src/classes/filterable.rs b/fp-library/src/classes/filterable.rs index b21899aa..ac4f06f2 100644 --- a/fp-library/src/classes/filterable.rs +++ b/fp-library/src/classes/filterable.rs @@ -219,8 +219,6 @@ mod inner { /// Maps a function over a data structure and filters out [`None`] results. /// /// The default implementation uses [`map`](crate::functions::map) and [`compact`](crate::functions::compact). - /// - /// ### Type Signature #[document_signature] /// #[document_type_parameters( diff --git a/fp-library/src/classes/foldable.rs b/fp-library/src/classes/foldable.rs index eea2b4a3..aab55645 100644 --- a/fp-library/src/classes/foldable.rs +++ b/fp-library/src/classes/foldable.rs @@ -66,7 +66,8 @@ mod inner { /// ), /// ); /// ``` - pub trait Foldable: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Foldable { /// Folds the structure by applying a function from right to left. /// /// This method performs a right-associative fold of the structure. diff --git a/fp-library/src/classes/functor.rs b/fp-library/src/classes/functor.rs index 0137e471..387e72fb 100644 --- a/fp-library/src/classes/functor.rs +++ b/fp-library/src/classes/functor.rs @@ -27,7 +27,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait now inherits from [`Kind_cdc7cd43dac7585f`], ensuring that all functor + /// This trait inherits from [`Kind!(type Of<'a, A: 'a>: 'a;)`](crate::kinds::Kind_cdc7cd43dac7585f), ensuring that all functor /// contexts satisfy the strict lifetime requirements where the type argument must /// outlive the context's application lifetime. /// @@ -84,7 +84,8 @@ mod inner { /// map::(f, map::(g, vec![1, 2, 3])), /// ); /// ``` - pub trait Functor: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Functor { /// Maps a function over the values in the functor context. /// /// This method applies a function to the value(s) inside the functor context, producing a new functor context with the transformed value(s). diff --git a/fp-library/src/classes/lift.rs b/fp-library/src/classes/lift.rs index 790cd28c..53238c9e 100644 --- a/fp-library/src/classes/lift.rs +++ b/fp-library/src/classes/lift.rs @@ -29,7 +29,8 @@ mod inner { }; /// A type class for lifting binary functions into a context. - pub trait Lift: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Lift { /// Lifts a binary function into the context. /// /// This method lifts a binary function to operate on values within the context. diff --git a/fp-library/src/classes/par_compactable.rs b/fp-library/src/classes/par_compactable.rs index b08d6010..7404a8e1 100644 --- a/fp-library/src/classes/par_compactable.rs +++ b/fp-library/src/classes/par_compactable.rs @@ -55,7 +55,8 @@ mod inner { /// assert_eq!(errs, vec!["e"]); /// assert_eq!(oks, vec![1, 3]); /// ``` - pub trait ParCompactable: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait ParCompactable { /// Compacts a data structure of [`Option`]s in parallel, discarding [`None`] values and /// keeping [`Some`] values. /// diff --git a/fp-library/src/classes/par_foldable.rs b/fp-library/src/classes/par_foldable.rs index a981d439..69079e57 100644 --- a/fp-library/src/classes/par_foldable.rs +++ b/fp-library/src/classes/par_foldable.rs @@ -57,7 +57,8 @@ mod inner { /// let result: String = par_fold_map::(|x: i32| x.to_string(), v); /// assert_eq!(result, "12345"); /// ``` - pub trait ParFoldable: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait ParFoldable { /// Maps each element to a [`Monoid`] value and combines them in parallel. /// /// Maps each element using `f`, then reduces the results using diff --git a/fp-library/src/classes/par_functor.rs b/fp-library/src/classes/par_functor.rs index 3016f969..3b8e93be 100644 --- a/fp-library/src/classes/par_functor.rs +++ b/fp-library/src/classes/par_functor.rs @@ -65,7 +65,8 @@ mod inner { /// par_map::(f, par_map::(g, xs)), /// ); /// ``` - pub trait ParFunctor: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait ParFunctor { /// Maps a function over a data structure in parallel. /// /// When the `rayon` feature is enabled, elements are processed across multiple threads. diff --git a/fp-library/src/classes/pointed.rs b/fp-library/src/classes/pointed.rs index 4adc7119..bb2b8c8d 100644 --- a/fp-library/src/classes/pointed.rs +++ b/fp-library/src/classes/pointed.rs @@ -20,7 +20,8 @@ mod inner { }; /// A type class for contexts that can be initialized with a value. - pub trait Pointed: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Pointed { /// The value wrapped in the context. /// /// This method wraps a value in a context. diff --git a/fp-library/src/classes/profunctor.rs b/fp-library/src/classes/profunctor.rs index 913454bc..1221a993 100644 --- a/fp-library/src/classes/profunctor.rs +++ b/fp-library/src/classes/profunctor.rs @@ -56,9 +56,10 @@ mod inner { /// ### Hierarchy Unification /// /// This trait is now the root of the unified profunctor and arrow hierarchies on - /// [`Kind_266801a817966495`]. This unification ensures that all profunctor-based abstractions + /// [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495). + /// This unification ensures that all profunctor-based abstractions /// (including lenses and prisms) share a consistent higher-kinded representation with - /// strict lifetime bounds (`type Of<'a, T: 'a, U: 'a>: 'a;`). + /// strict lifetime bounds. /// /// By explicitly requiring that both type parameters outlive the application lifetime `'a`, /// we provide the compiler with the necessary guarantees to handle trait objects @@ -103,7 +104,8 @@ mod inner { /// assert_eq!(left(5), right(5)); /// assert_eq!(left(0), right(0)); /// ``` - pub trait Profunctor: Kind_266801a817966495 { + #[kind(type Of<'a, A: 'a, B: 'a>: 'a;)] + pub trait Profunctor { /// Maps over both arguments of the profunctor. /// /// This method applies a contravariant function to the first argument and a covariant diff --git a/fp-library/src/classes/profunctor/choice.rs b/fp-library/src/classes/profunctor/choice.rs index ea29bcb2..d91bf73a 100644 --- a/fp-library/src/classes/profunctor/choice.rs +++ b/fp-library/src/classes/profunctor/choice.rs @@ -37,7 +37,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait uses the strict Kind signature from [`Kind_266801a817966495`]. This ensures + /// This trait uses the strict Kind signature from [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495). This ensures /// that when lifting a profunctor, the alternative variants of the sum type correctly /// satisfy lifetime requirements relative to the profunctor's application. /// diff --git a/fp-library/src/classes/profunctor/strong.rs b/fp-library/src/classes/profunctor/strong.rs index 1da5c383..4280ef5b 100644 --- a/fp-library/src/classes/profunctor/strong.rs +++ b/fp-library/src/classes/profunctor/strong.rs @@ -35,7 +35,7 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait uses the strict Kind signature from [`Kind_266801a817966495`]. This ensures + /// This trait uses the strict Kind signature from [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495). This ensures /// that when lifting a profunctor, the secondary component of the product type (the context) /// correctly satisfies lifetime requirements relative to the profunctor's application. /// diff --git a/fp-library/src/classes/ref_functor.rs b/fp-library/src/classes/ref_functor.rs index 0985b8c7..649b3a32 100644 --- a/fp-library/src/classes/ref_functor.rs +++ b/fp-library/src/classes/ref_functor.rs @@ -25,7 +25,8 @@ mod inner { /// /// This is a variant of `Functor` for types where `map` receives/returns references. /// This is required for types like `Lazy` where `get()` returns `&A`, not `A`. - pub trait RefFunctor: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait RefFunctor { /// Maps a function over the values in the functor context, where the function takes a reference. #[document_signature] /// diff --git a/fp-library/src/classes/semigroupoid.rs b/fp-library/src/classes/semigroupoid.rs index 2b1602fb..64094bea 100644 --- a/fp-library/src/classes/semigroupoid.rs +++ b/fp-library/src/classes/semigroupoid.rs @@ -29,9 +29,9 @@ mod inner { /// /// ### Hierarchy Unification /// - /// This trait inherits from [`Kind_266801a817966495`], which uses the strict Kind signature - /// `type Of<'a, T: 'a, U: 'a>: 'a;`. This unification ensures that all profunctors and arrows - /// share a consistent higher-kinded representation, and requires that the source and target + /// This trait inherits from [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495). + /// This unification ensures that all profunctors and arrows share a + /// consistent higher-kinded representation, and requires that the source and target /// types of a morphism outlive the morphism's application lifetime. /// /// ### Laws @@ -66,7 +66,8 @@ mod inner { /// assert_eq!(left(10), right(10)); /// assert_eq!(left(0), right(0)); /// ``` - pub trait Semigroupoid: Kind_266801a817966495 { + #[kind(type Of<'a, A: 'a, B: 'a>: 'a;)] + pub trait Semigroupoid { /// Takes morphisms `f` and `g` and returns the morphism `f . g` (`f` composed with `g`). /// /// This method composes two morphisms `f` and `g` to produce a new morphism that represents the application of `g` followed by `f`. diff --git a/fp-library/src/classes/semimonad.rs b/fp-library/src/classes/semimonad.rs index a852fe16..785e07ae 100644 --- a/fp-library/src/classes/semimonad.rs +++ b/fp-library/src/classes/semimonad.rs @@ -25,7 +25,8 @@ mod inner { /// If `x` has type `m a` and `f` has type `a -> m b`, then `bind(x, f)` has type `m b`, /// representing the result of executing `x` to get a value of type `a` and then /// passing it to `f` to get a computation of type `m b`. - pub trait Semimonad: Kind_cdc7cd43dac7585f { + #[kind(type Of<'a, A: 'a>: 'a;)] + pub trait Semimonad { /// Sequences two computations, allowing the second to depend on the value computed by the first. /// /// This method chains two computations, where the second computation depends on the result of the first. diff --git a/fp-library/src/lib.rs b/fp-library/src/lib.rs index 46e265fa..dd5c9308 100644 --- a/fp-library/src/lib.rs +++ b/fp-library/src/lib.rs @@ -17,7 +17,7 @@ //! //! - **Higher-Kinded Types (HKT):** Implemented using lightweight higher-kinded polymorphism (type-level defunctionalization/brands). //! - **Macros:** Procedural macros for working with HKTs and monadic code: -//! - **HKT:** `trait_kind!`, `impl_kind!`, `Apply!` for defining and applying higher-kinded type encodings +//! - **HKT:** `trait_kind!`, `impl_kind!`, `Apply!`, `#[kind]` for defining and applying higher-kinded type encodings //! - **Do-Notation:** `m_do!` for monadic do-notation, `a_do!` for applicative do-notation //! - **Type Classes:** A comprehensive collection of standard type classes including: //! - **Core:** `Functor`, `Contravariant`, `Pointed`, `Applicative`, `Semiapplicative`, `Monad`, `Semimonad`, `Semigroup`, `Monoid`, `Foldable`, `Traversable`, `Alt`, `Plus`, `Alternative` diff --git a/fp-library/src/types/cat_list.rs b/fp-library/src/types/cat_list.rs index c9da4471..33415c1c 100644 --- a/fp-library/src/types/cat_list.rs +++ b/fp-library/src/types/cat_list.rs @@ -2463,8 +2463,10 @@ mod inner { /// An iterator that consumes a `CatList`. #[document_type_parameters("The type of the elements in the list.")] /// - #[document_fields("The list being iterated over.")] - pub struct CatListIterator(CatList); + pub struct CatListIterator( + /// The list being iterated over. + CatList, + ); #[document_type_parameters("The type of the elements in the list.")] #[document_parameters("The iterator state.")] diff --git a/fp-library/src/types/endofunction.rs b/fp-library/src/types/endofunction.rs index 6466eb2a..0b45ed85 100644 --- a/fp-library/src/types/endofunction.rs +++ b/fp-library/src/types/endofunction.rs @@ -40,9 +40,8 @@ mod inner { "The input and output type of the function." )] /// - #[document_fields("The wrapped function.")] - /// pub struct Endofunction<'a, FnBrand: CloneableFn, A: 'a>( + /// The wrapped function. pub ::Of<'a, A, A>, ); diff --git a/fp-library/src/types/endomorphism.rs b/fp-library/src/types/endomorphism.rs index 40e1783d..80956339 100644 --- a/fp-library/src/types/endomorphism.rs +++ b/fp-library/src/types/endomorphism.rs @@ -39,18 +39,17 @@ mod inner { /// /// ### Hierarchy Unification /// - /// `Endomorphism` now requires that its object type `A` outlive the lifetime `'a` of the + /// `Endomorphism` requires that its object type `A` outlive the lifetime `'a` of the /// endomorphism itself (`A: 'a`). This is necessary to satisfy the requirements of the - /// unified [`Kind_266801a817966495`] used by the [`Category`] hierarchy. + /// unified [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495) used by the [`Category`] hierarchy. #[document_type_parameters( "The lifetime of the function and its captured data.", "The category of the morphism.", "The object of the morphism." )] /// - #[document_fields("The wrapped morphism.")] - /// pub struct Endomorphism<'a, C: Category, A: 'a>( + /// The wrapped morphism. pub Apply!(: 'a; )>::Of<'a, A, A>), ); diff --git a/fp-library/src/types/fn_brand.rs b/fp-library/src/types/fn_brand.rs index 51034144..25171c1a 100644 --- a/fp-library/src/types/fn_brand.rs +++ b/fp-library/src/types/fn_brand.rs @@ -4,7 +4,7 @@ //! //! ### Hierarchy Unification //! -//! `FnBrand` uses [`Kind_266801a817966495`](crate::kinds::Kind_266801a817966495), which enforces +//! `FnBrand` uses [`Kind!(type Of<'a, A: 'a, B: 'a>: 'a;)`](crate::kinds::Kind_266801a817966495), which enforces //! that input and output types outlive the function wrapper's lifetime. This allows `FnBrand` to //! be used consistently across the unified profunctor and arrow hierarchies, while supporting //! non-static types where the lifetimes are correctly tracked. diff --git a/fp-library/src/types/free.rs b/fp-library/src/types/free.rs index 76f6ec8c..bd939b30 100644 --- a/fp-library/src/types/free.rs +++ b/fp-library/src/types/free.rs @@ -107,7 +107,6 @@ mod inner { "The base functor (must implement [`Functor`]).", "The result type." )] - #[document_fields] pub enum FreeInner where F: Functor + 'static, diff --git a/fp-library/src/types/identity.rs b/fp-library/src/types/identity.rs index 1871c575..5b554125 100644 --- a/fp-library/src/types/identity.rs +++ b/fp-library/src/types/identity.rs @@ -43,11 +43,12 @@ mod inner { /// This type supports serialization and deserialization via [`serde`](https://serde.rs) when the `serde` feature is enabled. #[document_type_parameters("The type of the wrapped value.")] /// - #[document_fields("The wrapped value.")] - /// #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] - pub struct Identity(pub A); + pub struct Identity( + /// The wrapped value. + pub A, + ); impl_kind! { for IdentityBrand { diff --git a/fp-library/src/types/lazy.rs b/fp-library/src/types/lazy.rs index fa204844..76c2cd22 100644 --- a/fp-library/src/types/lazy.rs +++ b/fp-library/src/types/lazy.rs @@ -391,9 +391,10 @@ mod inner { "The memoization configuration (determines Rc vs Arc)." )] /// - #[document_fields("The internal lazy cell.")] - /// - pub struct Lazy<'a, A, Config: LazyConfig = RcLazyConfig>(pub(crate) Config::Lazy<'a, A>) + pub struct Lazy<'a, A, Config: LazyConfig = RcLazyConfig>( + /// The internal lazy cell. + pub(crate) Config::Lazy<'a, A>, + ) where A: 'a; diff --git a/fp-library/src/types/pair.rs b/fp-library/src/types/pair.rs index 412e65bf..57de243e 100644 --- a/fp-library/src/types/pair.rs +++ b/fp-library/src/types/pair.rs @@ -52,11 +52,14 @@ mod inner { /// This type supports serialization and deserialization via [`serde`](https://serde.rs) when the `serde` feature is enabled. #[document_type_parameters("The type of the first value.", "The type of the second value.")] /// - #[document_fields("The first value.", "The second value.")] - /// #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] - pub struct Pair(pub First, pub Second); + pub struct Pair( + /// The first value. + pub First, + /// The second value. + pub Second, + ); impl_kind! { for PairBrand { diff --git a/fp-library/src/types/thunk.rs b/fp-library/src/types/thunk.rs index 961a4551..6c9fe4dd 100644 --- a/fp-library/src/types/thunk.rs +++ b/fp-library/src/types/thunk.rs @@ -80,9 +80,10 @@ mod inner { "The type of the value produced by the computation." )] /// - #[document_fields("The closure that performs the computation.")] - /// - pub struct Thunk<'a, A>(Box A + 'a>); + pub struct Thunk<'a, A>( + /// The closure that performs the computation. + Box A + 'a>, + ); #[document_type_parameters( "The lifetime of the computation.", diff --git a/fp-library/src/types/trampoline.rs b/fp-library/src/types/trampoline.rs index fa113797..a7265575 100644 --- a/fp-library/src/types/trampoline.rs +++ b/fp-library/src/types/trampoline.rs @@ -67,9 +67,10 @@ mod inner { /// ``` #[document_type_parameters("The type of the value produced by the task.")] /// - #[document_fields("The internal `Free` monad representation.")] - /// - pub struct Trampoline(Free); + pub struct Trampoline( + /// The internal `Free` monad representation. + Free, + ); #[document_type_parameters("The type of the value produced by the task.")] #[document_parameters("The `Trampoline` instance.")] diff --git a/fp-library/src/types/try_lazy.rs b/fp-library/src/types/try_lazy.rs index 205741a6..a3f997b5 100644 --- a/fp-library/src/types/try_lazy.rs +++ b/fp-library/src/types/try_lazy.rs @@ -40,8 +40,8 @@ mod inner { /// /// The higher-kinded representation of this type constructor is [`TryLazyBrand`](crate::brands::TryLazyBrand), /// which is parameterized by both the error type and the `LazyConfig`, and is polymorphic over the success value type. - #[document_fields("The internal lazy cell.")] pub struct TryLazy<'a, A, E, Config: LazyConfig = RcLazyConfig>( + /// The internal lazy cell. pub(crate) Config::TryLazy<'a, A, E>, ) where diff --git a/fp-library/src/types/try_thunk.rs b/fp-library/src/types/try_thunk.rs index 24fd7f53..473c4a77 100644 --- a/fp-library/src/types/try_thunk.rs +++ b/fp-library/src/types/try_thunk.rs @@ -57,9 +57,10 @@ mod inner { /// - [`TryThunkBrand`](crate::brands::TryThunkBrand): fully polymorphic over both error and success types (bifunctor). /// - [`TryThunkErrAppliedBrand`](crate::brands::TryThunkErrAppliedBrand): the error type is fixed, polymorphic over the success type (functor over `Ok`). /// - [`TryThunkOkAppliedBrand`](crate::brands::TryThunkOkAppliedBrand): the success type is fixed, polymorphic over the error type (functor over `Err`). - #[document_fields("The closure that performs the computation.")] - /// - pub struct TryThunk<'a, A, E>(Box Result + 'a>); + pub struct TryThunk<'a, A, E>( + /// The closure that performs the computation. + Box Result + 'a>, + ); #[document_type_parameters( "The lifetime of the computation.", diff --git a/fp-library/src/types/try_trampoline.rs b/fp-library/src/types/try_trampoline.rs index 970d49f5..bd9bbc27 100644 --- a/fp-library/src/types/try_trampoline.rs +++ b/fp-library/src/types/try_trampoline.rs @@ -33,9 +33,10 @@ mod inner { /// This is [`Trampoline>`] with ergonomic combinators. #[document_type_parameters("The type of the success value.", "The type of the error value.")] /// - #[document_fields("The internal `Trampoline` wrapping a `Result`.")] - /// - pub struct TryTrampoline(Trampoline>); + pub struct TryTrampoline( + /// The internal `Trampoline` wrapping a `Result`. + Trampoline>, + ); #[document_type_parameters("The type of the success value.", "The type of the error value.")] #[document_parameters("The fallible trampoline computation.")] diff --git a/fp-library/tests/document_fields_example.rs b/fp-library/tests/document_fields_example.rs deleted file mode 100644 index 9f5db3ff..00000000 --- a/fp-library/tests/document_fields_example.rs +++ /dev/null @@ -1,52 +0,0 @@ -use { - fp_library::{ - Apply, - kinds::*, - }, - fp_macros::document_fields, -}; - -// Example: Using document_fields on a tuple struct similar to Endomorphism -#[document_fields("The wrapped morphism from an object to itself")] -pub struct MyEndomorphism<'a, C: fp_library::classes::Category, A: 'a>( - pub Apply!(: 'a;)>::Of<'a, A, A>), -); - -// Example: Using document_fields on a named struct -#[document_fields( - value: "The wrapped value", - metadata: "Optional metadata about the value" -)] -pub struct Tagged { - pub value: T, - pub metadata: Option, -} - -// Example: Named struct with multiple fields -#[document_fields( - x: "The x coordinate", - y: "The y coordinate", - z: "The z coordinate" -)] -pub struct Point3D { - pub x: f64, - pub y: f64, - pub z: f64, -} - -#[test] -fn test_document_fields_usage() { - // Just verify they compile - let point = Point3D { - x: 1.0, - y: 2.0, - z: 3.0, - }; - assert_eq!(point.x, 1.0); - - let tagged = Tagged { - value: 42, - metadata: Some("answer".to_string()), - }; - assert_eq!(tagged.value, 42); -} diff --git a/fp-macros/src/analysis/traits.rs b/fp-macros/src/analysis/traits.rs index d5395ced..afc375d2 100644 --- a/fp-macros/src/analysis/traits.rs +++ b/fp-macros/src/analysis/traits.rs @@ -25,6 +25,7 @@ pub enum TraitCategory { FnTrait, FnBrand, ApplyMacro, + Kind, Other(String), } @@ -37,6 +38,7 @@ pub fn classify_trait( n if brands::FN_BRANDS.contains(&n) => TraitCategory::FnBrand, macros::APPLY_MACRO => TraitCategory::ApplyMacro, n if config.apply_macro_aliases().contains(n) => TraitCategory::ApplyMacro, + n if n.starts_with(markers::KIND_PREFIX) => TraitCategory::Kind, _ => TraitCategory::Other(name.to_string()), } } diff --git a/fp-macros/src/core/constants.rs b/fp-macros/src/core/constants.rs index 698794ab..fd76ee8b 100644 --- a/fp-macros/src/core/constants.rs +++ b/fp-macros/src/core/constants.rs @@ -90,6 +90,8 @@ pub mod markers { pub const FN_BRAND_MARKER: &str = "fn_brand_marker"; /// Common suffix for brand types pub const BRAND_SUFFIX: &str = "Brand"; + /// Prefix for generated Kind trait names (e.g., `Kind_cdc7cd43dac7585f`) + pub const KIND_PREFIX: &str = "Kind_"; } /// Known attribute names used by the documentation macros @@ -108,8 +110,6 @@ pub mod attributes { pub const DOCUMENT_RETURNS: &str = "document_returns"; /// Attribute for function examples documentation pub const DOCUMENT_EXAMPLES: &str = "document_examples"; - /// Attribute for struct field documentation - pub const DOCUMENT_FIELDS: &str = "document_fields"; /// Attribute for module documentation pub const DOCUMENT_MODULE: &str = "document_module"; /// Attribute to suppress the `impl Trait` lint for named generics @@ -125,7 +125,6 @@ pub mod attributes { DOCUMENT_PARAMETERS, DOCUMENT_RETURNS, DOCUMENT_EXAMPLES, - DOCUMENT_FIELDS, DOCUMENT_MODULE, ALLOW_NAMED_GENERICS, ]; diff --git a/fp-macros/src/core/warning_emitter.rs b/fp-macros/src/core/warning_emitter.rs index 6b2fc792..d67a0cd7 100644 --- a/fp-macros/src/core/warning_emitter.rs +++ b/fp-macros/src/core/warning_emitter.rs @@ -10,14 +10,20 @@ use { TokenStream, }, quote::ToTokens, + std::sync::atomic::{ + AtomicUsize, + Ordering, + }, }; +/// Global counter ensuring unique warning names across all proc-macro invocations. +static GLOBAL_WARNING_COUNTER: AtomicUsize = AtomicUsize::new(0); + /// Collects warnings and converts them to token streams for compile-time emission. /// /// Analogous to `ErrorCollector` but produces warnings (via `#[deprecated]`) /// instead of `compile_error!` invocations. pub struct WarningEmitter { - counter: usize, warnings: Vec, } @@ -25,22 +31,21 @@ impl WarningEmitter { /// Create a new, empty warning emitter. pub fn new() -> Self { Self { - counter: 0, warnings: Vec::new(), } } /// Emit a warning with the given span and message. /// - /// Each warning gets a unique name (`_fp_macros_warning_{counter}`) to avoid - /// name collisions when multiple warnings are emitted in a single expansion. + /// Each warning gets a globally unique name (`_fp_macros_warning_{id}`) to avoid + /// name collisions across multiple macro invocations at the same scope. pub fn warn( &mut self, span: Span, message: impl Into, ) { - let name = format!("_fp_macros_warning_{}", self.counter); - self.counter += 1; + let id = GLOBAL_WARNING_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = format!("_fp_macros_warning_{id}"); let warning = FormattedWarning::new_deprecated(&name, message, span); self.warnings.push(warning.into_token_stream()); @@ -107,8 +112,13 @@ mod tests { let token_strings: Vec = tokens.iter().map(|t| t.to_string()).collect(); // Each token stream should contain a distinct _fp_macros_warning_ identifier - assert!(token_strings[0].contains("_fp_macros_warning_0")); - assert!(token_strings[1].contains("_fp_macros_warning_1")); - assert!(token_strings[2].contains("_fp_macros_warning_2")); + assert!(token_strings[0].contains("_fp_macros_warning_")); + assert!(token_strings[1].contains("_fp_macros_warning_")); + assert!(token_strings[2].contains("_fp_macros_warning_")); + + // All three should be different + assert_ne!(token_strings[0], token_strings[1]); + assert_ne!(token_strings[1], token_strings[2]); + assert_ne!(token_strings[0], token_strings[2]); } } diff --git a/fp-macros/src/documentation.rs b/fp-macros/src/documentation.rs index 0c67e3c6..86f00bad 100644 --- a/fp-macros/src/documentation.rs +++ b/fp-macros/src/documentation.rs @@ -4,11 +4,9 @@ //! - #[document_signature] - Hindley-Milner signatures //! - #[document_parameters] - Parameter documentation //! - #[document_type_parameters] - Type parameter documentation -//! - #[document_fields] - Field documentation //! - #[document_module] - Module-level orchestration pub mod document_examples; -pub mod document_fields; pub mod document_module; pub mod document_parameters; pub mod document_returns; @@ -19,7 +17,6 @@ pub mod templates; pub use { document_examples::document_examples_worker, - document_fields::document_fields_worker, document_module::document_module_worker, document_parameters::document_parameters_worker, document_returns::document_returns_worker, diff --git a/fp-macros/src/documentation/document_examples.rs b/fp-macros/src/documentation/document_examples.rs index 21dec712..c1e85d2b 100644 --- a/fp-macros/src/documentation/document_examples.rs +++ b/fp-macros/src/documentation/document_examples.rs @@ -10,6 +10,7 @@ use { }, support::{ ast::RustAst, + attributes::reject_duplicate_attribute, generate_documentation::insert_doc_comment, }, }, @@ -178,14 +179,7 @@ pub fn document_examples_worker( let is_function = ast.signature().is_some(); // Check for duplicate #[document_examples] - let has_duplicate = ast.attributes().iter().any(|a| a.path().is_ident(DOCUMENT_EXAMPLES)); - if has_duplicate { - return Err(syn::Error::new( - proc_macro2::Span::call_site(), - format!("#[{DOCUMENT_EXAMPLES}] should only be applied once per item"), - ) - .into()); - } + reject_duplicate_attribute(ast.attributes(), DOCUMENT_EXAMPLES)?; // Extract and validate doc comment code blocks let doc_content = extract_doc_content(ast.attributes()); diff --git a/fp-macros/src/documentation/document_fields.rs b/fp-macros/src/documentation/document_fields.rs deleted file mode 100644 index e8401eeb..00000000 --- a/fp-macros/src/documentation/document_fields.rs +++ /dev/null @@ -1,396 +0,0 @@ -use { - crate::{ - core::{ - Error as CoreError, - Result, - constants::attributes::DOCUMENT_FIELDS, - error_handling::ErrorCollector, - }, - support::{ - attributes::AttributeExt, - document_field::{ - DocumentFieldParameters, - FieldDocumenter, - FieldInfo, - }, - }, - }, - proc_macro2::TokenStream, - quote::ToTokens, - syn::{ - ItemEnum, - ItemStruct, - Variant, - spanned::Spanned, - }, -}; - -/// Processes an enum with `#[document_fields]` on variants. -/// -/// This function looks for `#[document_fields(...)]` attributes on enum variants -/// and processes them to generate field documentation. -fn document_enum_fields(mut item_enum: ItemEnum) -> Result { - // Collect errors from all variants instead of returning early - let mut errors = ErrorCollector::new(); - - // Process each variant - for variant in &mut item_enum.variants { - if let Err(e) = process_variant_fields(variant) { - errors.push(e.into()); - } - } - - // Finish and convert any collected errors - errors.finish()?; - - Ok(item_enum.to_token_stream()) -} - -/// Processes a single variant's `#[document_fields(...)]` attribute if present. -fn process_variant_fields(variant: &mut Variant) -> Result<()> { - // Find, remove, and parse the attribute in one operation - let Some(args) = variant.attrs.find_and_remove::(DOCUMENT_FIELDS)? - else { - // No attribute on this variant, skip it - return Ok(()); - }; - - // Get the span for error messages (we need to reconstruct since we already consumed the attr) - let attr_span = variant.span(); - - // Extract field information from the variant - let field_info = - FieldInfo::from_fields(&variant.fields, variant.span(), "variant", DOCUMENT_FIELDS)?; - - // Use the documenter to validate and generate docs - let documenter = FieldDocumenter::new(field_info, attr_span, "variant"); - documenter.validate_and_generate(args, &mut variant.attrs)?; - - Ok(()) -} - -pub fn document_fields_worker( - attr: TokenStream, - item_tokens: TokenStream, -) -> Result { - // Try to parse as enum first, then struct - if let Ok(item_enum) = syn::parse2::(item_tokens.clone()) { - // For enums, the attribute should be empty (no arguments) - // The actual field documentation is on the variants themselves - if !attr.is_empty() { - return Err(CoreError::Parse(syn::Error::new( - attr.span(), - format!( - "{DOCUMENT_FIELDS} on enums should not have arguments. Use #[{DOCUMENT_FIELDS}] on the enum, and #[{DOCUMENT_FIELDS}(...)] on individual variants." - ), - ))); - } - - return document_enum_fields(item_enum); - } - - // Fall back to struct handling - let mut item_struct = syn::parse2::(item_tokens)?; - let args = syn::parse2::(attr.clone())?; - - // Extract field information - let field_info = - FieldInfo::from_fields(&item_struct.fields, item_struct.span(), "struct", DOCUMENT_FIELDS)?; - - // Use the documenter to validate and generate docs - let documenter = FieldDocumenter::new(field_info, attr.span(), "struct"); - - // Add section header - let header_attr: syn::Attribute = syn::parse_quote!(#[doc = r#"### Fields -"#]); - - if !item_struct.fields.is_empty() { - let mut insert_idx = item_struct.attrs.len(); - for (i, a) in item_struct.attrs.iter().enumerate() { - if a.span().start().line > attr.span().start().line { - insert_idx = i; - break; - } - } - item_struct.attrs.insert(insert_idx, header_attr); - } - - documenter.validate_and_generate(args, &mut item_struct.attrs)?; - - Ok(item_struct.to_token_stream()) -} - -#[cfg(test)] -mod tests { - use { - super::*, - crate::support::generate_documentation::get_doc, - quote::quote, - }; - - #[test] - fn test_document_fields_named_struct() { - let attr = quote! { x: "The x coordinate", y: "The y coordinate" }; - let item = quote! { - pub struct Point { - pub x: i32, - pub y: i32, - } - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_struct: ItemStruct = syn::parse2(output).unwrap(); - - // 2 fields + 1 header = 3 attributes - assert_eq!(output_struct.attrs.len(), 3); - assert_eq!(get_doc(&output_struct.attrs[0]), "### Fields\n"); - assert_eq!(get_doc(&output_struct.attrs[1]), "* `x`: The x coordinate"); - assert_eq!(get_doc(&output_struct.attrs[2]), "* `y`: The y coordinate"); - } - - #[test] - fn test_document_fields_tuple_struct() { - let attr = quote! { "The wrapped value", "The secondary value" }; - let item = quote! { - pub struct Wrapper(pub i32, pub String); - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_struct: ItemStruct = syn::parse2(output).unwrap(); - - // 2 fields + 1 header = 3 attributes - assert_eq!(output_struct.attrs.len(), 3); - assert_eq!(get_doc(&output_struct.attrs[0]), "### Fields\n"); - assert_eq!(get_doc(&output_struct.attrs[1]), "* `0`: The wrapped value"); - assert_eq!(get_doc(&output_struct.attrs[2]), "* `1`: The secondary value"); - } - - #[test] - fn test_document_fields_missing_field() { - let attr = quote! { x: "The x coordinate" }; - let item = quote! { - pub struct Point { - pub x: i32, - pub y: i32, - } - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Missing documentation for field `y`")); - } - - #[test] - fn test_document_fields_extra_field() { - let attr = quote! { x: "The x coordinate", y: "The y coordinate", z: "Extra" }; - let item = quote! { - pub struct Point { - pub x: i32, - pub y: i32, - } - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - eprintln!("Actual error: {}", error); - assert!(error.contains("Field `z` does not exist")); - } - - #[test] - fn test_document_fields_duplicate_field() { - let attr = quote! { x: "First", x: "Second", y: "Y coord" }; - let item = quote! { - pub struct Point { - pub x: i32, - pub y: i32, - } - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Duplicate documentation for")); - } - - #[test] - fn test_document_fields_tuple_wrong_count() { - let attr = quote! { "Only one description" }; - let item = quote! { - pub struct Wrapper(pub i32, pub String); - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Expected exactly 2 description arguments")); - assert!(error.contains("found 1")); - } - - #[test] - fn test_document_fields_unit_struct() { - let attr = quote! {}; - let item = quote! { - pub struct Unit; - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("cannot be used on unit struct")); - } - - #[test] - fn test_document_fields_empty_named_struct() { - let attr = quote! {}; - let item = quote! { - pub struct Empty {} - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("zero-sized types")); - } - - #[test] - fn test_document_fields_named_on_tuple() { - let attr = quote! { field: "Description" }; - let item = quote! { - pub struct Wrapper(pub i32); - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Expected unnamed field documentation")); - assert!(error.contains("Use comma-separated descriptions for tuple")); - } - - #[test] - fn test_document_fields_unnamed_on_named() { - let attr = quote! { "Description" }; - let item = quote! { - pub struct Point { - pub x: i32, - } - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Expected named field documentation")); - assert!(error.contains("Use named syntax for")); - } - - #[test] - fn test_document_fields_single_field_tuple() { - let attr = quote! { "The wrapped value" }; - let item = quote! { - pub struct Wrapper(pub i32); - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_struct: ItemStruct = syn::parse2(output).unwrap(); - - // 1 field + 1 header = 2 attributes - assert_eq!(output_struct.attrs.len(), 2); - assert_eq!(get_doc(&output_struct.attrs[0]), "### Fields\n"); - assert_eq!(get_doc(&output_struct.attrs[1]), "* `0`: The wrapped value"); - } - - #[test] - fn test_document_fields_enum_with_named_fields() { - let attr = quote! {}; - let item = quote! { - #[document_fields] - pub enum MyEnum { - #[document_fields( - x: "The x coordinate", - y: "The y coordinate" - )] - Point { - x: i32, - y: i32, - }, - } - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_enum: ItemEnum = syn::parse2(output).unwrap(); - - assert_eq!(output_enum.variants.len(), 1); - let variant = &output_enum.variants[0]; - assert_eq!(variant.attrs.len(), 2); - assert_eq!(get_doc(&variant.attrs[0]), "* `x`: The x coordinate"); - assert_eq!(get_doc(&variant.attrs[1]), "* `y`: The y coordinate"); - } - - #[test] - fn test_document_fields_enum_with_tuple_fields() { - let attr = quote! {}; - let item = quote! { - #[document_fields] - pub enum MyEnum { - #[document_fields( - "The first value", - "The second value" - )] - Tuple(i32, String), - } - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_enum: ItemEnum = syn::parse2(output).unwrap(); - - assert_eq!(output_enum.variants.len(), 1); - let variant = &output_enum.variants[0]; - assert_eq!(variant.attrs.len(), 2); - assert_eq!(get_doc(&variant.attrs[0]), "* `0`: The first value"); - assert_eq!(get_doc(&variant.attrs[1]), "* `1`: The second value"); - } - - #[test] - fn test_document_fields_enum_multiple_variants() { - let attr = quote! {}; - let item = quote! { - #[document_fields] - pub enum Result { - #[document_fields(value: "The success value")] - Ok { value: T }, - #[document_fields(error: "The error value")] - Err { error: E }, - } - }; - - let output = document_fields_worker(attr, item).unwrap(); - let output_enum: ItemEnum = syn::parse2(output).unwrap(); - - assert_eq!(output_enum.variants.len(), 2); - - let ok_variant = &output_enum.variants[0]; - assert_eq!(ok_variant.attrs.len(), 1); - assert_eq!(get_doc(&ok_variant.attrs[0]), "* `value`: The success value"); - - let err_variant = &output_enum.variants[1]; - assert_eq!(err_variant.attrs.len(), 1); - assert_eq!(get_doc(&err_variant.attrs[0]), "* `error`: The error value"); - } - - #[test] - fn test_document_fields_enum_with_args_fails() { - let attr = quote! { some_arg: "value" }; - let item = quote! { - #[document_fields] - pub enum MyEnum { - Variant, - } - }; - - let result = document_fields_worker(attr, item); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("should not have arguments")); - } -} diff --git a/fp-macros/src/documentation/document_module.rs b/fp-macros/src/documentation/document_module.rs index 716714da..4afcaabe 100644 --- a/fp-macros/src/documentation/document_module.rs +++ b/fp-macros/src/documentation/document_module.rs @@ -9,16 +9,19 @@ use { Result as OurResult, WarningEmitter, config::Config, - constants::attributes::{ - ALLOW_NAMED_GENERICS, - DOCUMENT_ATTR_ORDER, - DOCUMENT_EXAMPLES, - DOCUMENT_MODULE, - DOCUMENT_PARAMETERS, - DOCUMENT_RETURNS, - DOCUMENT_SIGNATURE, - DOCUMENT_TYPE_PARAMETERS, - NO_VALIDATION, + constants::{ + attributes::{ + ALLOW_NAMED_GENERICS, + DOCUMENT_ATTR_ORDER, + DOCUMENT_EXAMPLES, + DOCUMENT_MODULE, + DOCUMENT_PARAMETERS, + DOCUMENT_RETURNS, + DOCUMENT_SIGNATURE, + DOCUMENT_TYPE_PARAMETERS, + NO_VALIDATION, + }, + markers::KIND_PREFIX, }, error_handling::ErrorCollector, }, @@ -46,6 +49,7 @@ use { Item, ItemMod, TraitItem, + TypeParamBound, parse::{ Parse, ParseStream, @@ -560,12 +564,29 @@ fn validate_trait_documentation( item_trait: &syn::ItemTrait, warnings: &mut WarningEmitter, ) { + // Warn if trait uses raw Kind_* supertrait instead of #[kind(...)] attribute + for bound in &item_trait.supertraits { + if let TypeParamBound::Trait(trait_bound) = bound + && let Some(segment) = trait_bound.path.segments.last() + && segment.ident.to_string().starts_with(KIND_PREFIX) + { + warnings.warn( + segment.ident.span(), + format!( + "Trait `{}` uses raw `{}` supertrait. Use `#[kind(...)]` attribute instead", + item_trait.ident, segment.ident + ), + ); + } + } + + let label = format!("Trait `{}`", item_trait.ident); validate_container_documentation( &item_trait.attrs, &item_trait.generics, trait_has_receiver_methods(item_trait), item_trait.span(), - "Trait", + &label, warnings, ); diff --git a/fp-macros/src/documentation/document_parameters.rs b/fp-macros/src/documentation/document_parameters.rs index dfd6fea2..efca1257 100644 --- a/fp-macros/src/documentation/document_parameters.rs +++ b/fp-macros/src/documentation/document_parameters.rs @@ -11,6 +11,7 @@ use { Parameter, attributes::{ find_attribute, + reject_duplicate_attribute, remove_attribute_tokens, }, documentation_parameters::{ @@ -173,6 +174,8 @@ fn process_impl_block( attr: TokenStream, mut item_impl: syn::ItemImpl, ) -> Result { + reject_duplicate_attribute(&item_impl.attrs, DOCUMENT_PARAMETERS)?; + // Parse the receiver documentation let receiver_doc = syn::parse2::(attr.clone()).map_err(|e| { syn::Error::new( @@ -219,6 +222,8 @@ fn process_trait_block( attr: TokenStream, mut item_trait: syn::ItemTrait, ) -> Result { + reject_duplicate_attribute(&item_trait.attrs, DOCUMENT_PARAMETERS)?; + // Parse the receiver documentation let receiver_doc = syn::parse2::(attr.clone()).map_err(|e| { syn::Error::new( @@ -276,7 +281,7 @@ pub fn document_parameters_worker( } // Otherwise, process as a function with generate_doc_comments - generate_doc_comments(attr, item_tokens, "Parameters", |generic_item| { + generate_doc_comments(attr, item_tokens, "Parameters", DOCUMENT_PARAMETERS, |generic_item| { let config = get_config(); let sig = generic_item.signature().ok_or_else(|| { diff --git a/fp-macros/src/documentation/document_returns.rs b/fp-macros/src/documentation/document_returns.rs index d0aefc73..fa8d30d5 100644 --- a/fp-macros/src/documentation/document_returns.rs +++ b/fp-macros/src/documentation/document_returns.rs @@ -6,6 +6,7 @@ use { }, support::{ ast::RustAst, + attributes::reject_duplicate_attribute, generate_documentation::{ find_insertion_index, insert_doc_comments_batch, @@ -24,6 +25,8 @@ pub fn document_returns_worker( let description: syn::LitStr = syn::parse2(attr)?; let mut ast = RustAst::parse(item).map_err(crate::core::Error::Parse)?; + reject_duplicate_attribute(ast.attributes(), DOCUMENT_RETURNS)?; + if ast.signature().is_some() { process_document_returns_on_ast(&mut ast, &description); } else { diff --git a/fp-macros/src/documentation/document_signature.rs b/fp-macros/src/documentation/document_signature.rs index ae85f82d..33dee3db 100644 --- a/fp-macros/src/documentation/document_signature.rs +++ b/fp-macros/src/documentation/document_signature.rs @@ -21,12 +21,14 @@ use { }, support::{ ast::RustAst, + attributes::reject_duplicate_attribute, generate_documentation::insert_doc_comment, is_phantom_data, parsing::parse_empty_attributes, }, }, proc_macro2::TokenStream, + quote::quote, std::collections::{ HashMap, HashSet, @@ -49,11 +51,14 @@ pub fn document_signature_worker( // Parse the item let mut item = RustAst::parse(item_tokens).map_err(Error::Parse)?; - // Get the function signature + // Check for duplicate #[document_signature] attributes + reject_duplicate_attribute(item.attributes(), DOCUMENT_SIGNATURE)?; + + // Handle functions and methods — generate HM type signature let sig = item.signature().ok_or_else(|| { Error::validation( proc_macro2::Span::call_site(), - format!("{DOCUMENT_SIGNATURE} can only be used on functions or methods"), + format!("{DOCUMENT_SIGNATURE} can only be used on functions and methods"), ) })?; @@ -75,9 +80,7 @@ pub fn document_signature_worker( // Insert the documentation comment insert_doc_comment(item.attributes(), doc_comment, proc_macro2::Span::call_site()); - Ok(quote::quote! { - #item - }) + Ok(quote! { #item }) } pub struct SignatureData { @@ -226,7 +229,7 @@ fn format_trait_bound( let trait_name = segment.ident.to_string(); match classify_trait(&trait_name, config) { - TraitCategory::FnTrait | TraitCategory::FnBrand => None, + TraitCategory::FnTrait | TraitCategory::FnBrand | TraitCategory::Kind => None, TraitCategory::Other(name) => if config.ignored_traits().contains(&name) { None diff --git a/fp-macros/src/documentation/document_type_parameters.rs b/fp-macros/src/documentation/document_type_parameters.rs index dd856a7e..053dc40f 100644 --- a/fp-macros/src/documentation/document_type_parameters.rs +++ b/fp-macros/src/documentation/document_type_parameters.rs @@ -20,27 +20,33 @@ pub fn document_type_parameters_worker( attr: TokenStream, item_tokens: TokenStream, ) -> Result { - generate_doc_comments(attr, item_tokens, "Type Parameters", |generic_item| { - let generics = generic_item.generics(); - - // Error if there are no type parameters - let _count = parsing::parse_has_documentable_items( - generics.params.len(), - generics.span(), - DOCUMENT_TYPE_PARAMETERS, - "items with no type parameters", - )?; - - Ok(generics - .params - .iter() - .map(|param| match param { - GenericParam::Type(t) => t.ident.to_string(), - GenericParam::Lifetime(l) => l.lifetime.to_string(), - GenericParam::Const(c) => c.ident.to_string(), - }) - .collect()) - }) + generate_doc_comments( + attr, + item_tokens, + "Type Parameters", + DOCUMENT_TYPE_PARAMETERS, + |generic_item| { + let generics = generic_item.generics(); + + // Error if there are no type parameters + let _count = parsing::parse_has_documentable_items( + generics.params.len(), + generics.span(), + DOCUMENT_TYPE_PARAMETERS, + "items with no type parameters", + )?; + + Ok(generics + .params + .iter() + .map(|param| match param { + GenericParam::Type(t) => t.ident.to_string(), + GenericParam::Lifetime(l) => l.lifetime.to_string(), + GenericParam::Const(c) => c.ident.to_string(), + }) + .collect()) + }, + ) } #[cfg(test)] diff --git a/fp-macros/src/documentation/generation.rs b/fp-macros/src/documentation/generation.rs index 7c7da488..37035345 100644 --- a/fp-macros/src/documentation/generation.rs +++ b/fp-macros/src/documentation/generation.rs @@ -27,6 +27,7 @@ use { support::{ attributes::{ AttributeExt, + count_attributes, find_attribute, }, documentation_parameters::{ @@ -232,23 +233,43 @@ fn process_method_documentation( // 1. Handle HM Signature if let Some(attr_pos) = find_attribute(&method.attrs, DOCUMENT_SIGNATURE) { - process_document_signature( - method, - attr_pos, - self_ty, - self_ty_path, - trait_name, - trait_path_str, - document_use.as_deref(), - item_impl_generics, - config, - errors, - ); + if count_attributes(&method.attrs, DOCUMENT_SIGNATURE) > 1 { + errors.push(syn::Error::new( + method.sig.ident.span(), + format!( + "#[{DOCUMENT_SIGNATURE}] can only be used once per item. Remove the duplicate attribute on method `{}`", + method.sig.ident + ), + )); + } else { + process_document_signature( + method, + attr_pos, + self_ty, + self_ty_path, + trait_name, + trait_path_str, + document_use.as_deref(), + item_impl_generics, + config, + errors, + ); + } } // 2. Handle Doc Type Params if let Some(attr_pos) = find_attribute(&method.attrs, DOCUMENT_TYPE_PARAMETERS) { - process_document_type_parameters(method, attr_pos, errors); + if count_attributes(&method.attrs, DOCUMENT_TYPE_PARAMETERS) > 1 { + errors.push(syn::Error::new( + method.sig.ident.span(), + format!( + "#[{DOCUMENT_TYPE_PARAMETERS}] can only be used once per item. Remove the duplicate attribute on method `{}`", + method.sig.ident + ), + )); + } else { + process_document_type_parameters(method, attr_pos, errors); + } } // 3. Document parameters is now handled directly in document_parameters.rs @@ -268,7 +289,14 @@ fn process_impl_block( let trait_path_str = trait_path.map(|p| quote!(#p).to_string()); // Generate impl-level documentation for type parameters if attribute is present - if let Some(attr_pos) = find_attribute(&item_impl.attrs, DOCUMENT_TYPE_PARAMETERS) { + if count_attributes(&item_impl.attrs, DOCUMENT_TYPE_PARAMETERS) > 1 { + errors.push(syn::Error::new( + item_impl.self_ty.span(), + format!( + "#[{DOCUMENT_TYPE_PARAMETERS}] can only be used once per item. Remove the duplicate attribute on impl block for `{self_ty_path}`", + ), + )); + } else if let Some(attr_pos) = find_attribute(&item_impl.attrs, DOCUMENT_TYPE_PARAMETERS) { // Create impl key and process in one go to avoid borrow conflicts let impl_key = ImplKey::from_paths(&self_ty_path, trait_path_str.as_deref()); @@ -333,19 +361,39 @@ fn process_trait_method_documentation( ) { // 1. Handle HM Signature - no Self substitution needed if let Some(attr_pos) = find_attribute(&method.attrs, DOCUMENT_SIGNATURE) { - method.attrs.remove(attr_pos); - insert_signature_docs(&mut method.attrs, attr_pos, &method.sig, config); + if count_attributes(&method.attrs, DOCUMENT_SIGNATURE) > 1 { + errors.push(syn::Error::new( + method.sig.ident.span(), + format!( + "#[{DOCUMENT_SIGNATURE}] can only be used once per item. Remove the duplicate attribute on method `{}`", + method.sig.ident + ), + )); + } else { + method.attrs.remove(attr_pos); + insert_signature_docs(&mut method.attrs, attr_pos, &method.sig, config); + } } // 2. Handle Doc Type Params if let Some(attr_pos) = find_attribute(&method.attrs, DOCUMENT_TYPE_PARAMETERS) { - process_type_parameters_core( - &mut method.attrs, - &method.sig.generics, - &format!("method '{}'", method.sig.ident), - attr_pos, - errors, - ); + if count_attributes(&method.attrs, DOCUMENT_TYPE_PARAMETERS) > 1 { + errors.push(syn::Error::new( + method.sig.ident.span(), + format!( + "#[{DOCUMENT_TYPE_PARAMETERS}] can only be used once per item. Remove the duplicate attribute on method `{}`", + method.sig.ident + ), + )); + } else { + process_type_parameters_core( + &mut method.attrs, + &method.sig.generics, + &format!("method '{}'", method.sig.ident), + attr_pos, + errors, + ); + } } } @@ -357,13 +405,23 @@ fn process_trait_block( ) { // Handle trait-level #[document_type_parameters] if let Some(attr_pos) = find_attribute(&item_trait.attrs, DOCUMENT_TYPE_PARAMETERS) { - process_type_parameters_core( - &mut item_trait.attrs, - &item_trait.generics, - &format!("trait '{}'", item_trait.ident), - attr_pos, - errors, - ); + if count_attributes(&item_trait.attrs, DOCUMENT_TYPE_PARAMETERS) > 1 { + errors.push(syn::Error::new( + item_trait.ident.span(), + format!( + "#[{DOCUMENT_TYPE_PARAMETERS}] can only be used once per item. Remove the duplicate attribute on trait `{}`", + item_trait.ident + ), + )); + } else { + process_type_parameters_core( + &mut item_trait.attrs, + &item_trait.generics, + &format!("trait '{}'", item_trait.ident), + attr_pos, + errors, + ); + } } // Process each method in the trait diff --git a/fp-macros/src/hkt.rs b/fp-macros/src/hkt.rs index d7768357..0413284a 100644 --- a/fp-macros/src/hkt.rs +++ b/fp-macros/src/hkt.rs @@ -11,6 +11,7 @@ pub mod associated_type; pub mod canonicalizer; pub mod impl_kind; pub mod input; +pub mod kind_attr; pub mod trait_kind; // Only needed for tests @@ -31,5 +32,6 @@ pub use { AssociatedType, AssociatedTypes, }, + kind_attr::kind_attr_worker, trait_kind::trait_kind_worker, }; diff --git a/fp-macros/src/hkt/kind_attr.rs b/fp-macros/src/hkt/kind_attr.rs new file mode 100644 index 00000000..c21365be --- /dev/null +++ b/fp-macros/src/hkt/kind_attr.rs @@ -0,0 +1,118 @@ +//! Implementation of the `#[kind]` attribute macro. +//! +//! This module handles adding a `Kind` supertrait bound to a trait definition +//! based on a signature provided in the attribute arguments. + +use { + super::AssociatedTypes, + crate::{ + core::Result, + generate_name, + }, + proc_macro2::TokenStream, + quote::quote, + syn::{ + ItemTrait, + parse_quote, + }, +}; + +/// Generates the implementation for the `#[kind]` attribute macro. +/// +/// This function takes the parsed attribute arguments (a Kind signature) and +/// the annotated trait definition, then adds the corresponding `Kind_` trait +/// as a supertrait bound. +pub fn kind_attr_worker( + attr: AssociatedTypes, + mut item: ItemTrait, +) -> Result { + let name = generate_name(&attr)?; + + if !item.supertraits.is_empty() { + item.supertraits.push_punct(::default()); + } + item.supertraits.push_value(parse_quote!(#name)); + + Ok(quote!(#item)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_kind_input(input: &str) -> AssociatedTypes { + syn::parse_str(input).expect("Failed to parse KindInput") + } + + fn parse_trait(input: &str) -> ItemTrait { + syn::parse_str(input).expect("Failed to parse ItemTrait") + } + + #[test] + fn test_kind_attr_adds_supertrait() { + let attr = parse_kind_input("type Of<'a, A: 'a>: 'a;"); + let item = parse_trait("pub trait Functor { fn map(); }"); + let output = kind_attr_worker(attr, item).expect("kind_attr_worker failed"); + let output_str = output.to_string(); + + assert!( + output_str.contains("Kind_"), + "Expected Kind_ supertrait in output, got: {output_str}" + ); + assert!( + output_str.contains("pub trait Functor"), + "Expected trait definition preserved, got: {output_str}" + ); + } + + #[test] + fn test_kind_attr_preserves_existing_supertraits() { + let attr = parse_kind_input("type Of<'a, A: 'a>: 'a;"); + let item = parse_trait("pub trait Monad: Applicative { fn bind(); }"); + let output = kind_attr_worker(attr, item).expect("kind_attr_worker failed"); + let output_str = output.to_string(); + + assert!( + output_str.contains("Applicative"), + "Expected existing supertrait preserved, got: {output_str}" + ); + assert!(output_str.contains("Kind_"), "Expected Kind_ supertrait added, got: {output_str}"); + assert!(output_str.contains("+"), "Expected + between supertraits, got: {output_str}"); + } + + #[test] + fn test_kind_attr_deterministic_name() { + let attr1 = parse_kind_input("type Of<'a, A: 'a>: 'a;"); + let attr2 = parse_kind_input("type Of<'a, T: 'a>: 'a;"); + let item1 = parse_trait("trait Foo {}"); + let item2 = parse_trait("trait Bar {}"); + + let output1 = kind_attr_worker(attr1, item1).expect("kind_attr_worker failed"); + let output2 = kind_attr_worker(attr2, item2).expect("kind_attr_worker failed"); + + // Both should generate the same Kind_ name (parameter names don't matter) + let name1 = output1 + .to_string() + .split("Kind_") + .nth(1) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string(); + let name2 = output2 + .to_string() + .split("Kind_") + .nth(1) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string(); + + assert_eq!( + name1, name2, + "Same signature with different param names should produce same hash" + ); + } +} diff --git a/fp-macros/src/lib.rs b/fp-macros/src/lib.rs index 2c2a2c82..771a4a13 100644 --- a/fp-macros/src/lib.rs +++ b/fp-macros/src/lib.rs @@ -30,7 +30,6 @@ use { }, documentation::{ document_examples_worker, - document_fields_worker, document_module_worker, document_parameters_worker, document_returns_worker, @@ -44,6 +43,7 @@ use { apply_worker, generate_name, impl_kind_worker, + kind_attr_worker, trait_kind_worker, }, m_do::{ @@ -112,7 +112,13 @@ use { /// * Type aliases: `type MyKind = Kind!(...);` (Invalid) /// * Trait aliases: `trait MyKind = Kind!(...);` (Invalid) /// -/// In these cases, you must use the generated name directly (e.g., `Kind_...`). +/// For supertrait bounds, use the [`kind`] attribute macro instead: +/// ```ignore +/// #[kind(type Of<'a, A: 'a>: 'a;)] +/// pub trait Functor { ... } +/// ``` +/// +/// For other positions, you must use the generated name directly (e.g., `Kind_...`). #[proc_macro] #[allow(non_snake_case)] pub fn Kind(input: TokenStream) -> TokenStream { @@ -366,6 +372,59 @@ pub fn Apply(input: TokenStream) -> TokenStream { } } +/// Adds a `Kind` supertrait bound to a trait definition. +/// +/// This attribute macro parses a Kind signature and adds the corresponding +/// `Kind_` trait as a supertrait bound, avoiding the need to reference +/// hash-based trait names directly. +/// +/// ### Syntax +/// +/// ```ignore +/// #[kind(type AssocName: Bounds;)] +/// pub trait MyTrait { +/// // ... +/// } +/// ``` +/// +/// ### Examples +/// +/// ```ignore +/// // Invocation +/// #[kind(type Of<'a, A: 'a>: 'a;)] +/// pub trait Functor { +/// fn map<'a, A: 'a, B: 'a>( +/// f: impl Fn(A) -> B + 'a, +/// fa: Apply!(: 'a;)>::Of<'a, A>), +/// ) -> Apply!(: 'a;)>::Of<'a, B>); +/// } +/// +/// // Expanded code +/// pub trait Functor: Kind_cdc7cd43dac7585f { +/// // body unchanged +/// } +/// ``` +/// +/// ```ignore +/// // Works with existing supertraits +/// #[kind(type Of<'a, A: 'a>: 'a;)] +/// pub trait Monad: Applicative { +/// // Kind_ bound is appended: Monad: Applicative + Kind_... +/// } +/// ``` +#[proc_macro_attribute] +pub fn kind( + attr: TokenStream, + item: TokenStream, +) -> TokenStream { + let attr = parse_macro_input!(attr as AssociatedTypes); + let item = parse_macro_input!(item as syn::ItemTrait); + match kind_attr_worker(attr, item) { + Ok(tokens) => tokens.into(), + Err(e) => e.to_compile_error().into(), + } +} + /// Generates re-exports for all public free functions in a directory. /// /// This macro scans the specified directory for Rust files, parses them to find public free functions, @@ -802,152 +861,6 @@ pub fn document_examples( } } -/// Generates documentation for struct fields or enum variant fields. -/// -/// This macro analyzes a struct or enum and generates documentation comments for its fields. -/// It can be used on named structs, tuple structs, and enums with variants that have fields. -/// -/// ### Syntax -/// -/// For named structs: -/// ```ignore -/// #[document_fields( -/// field_name: "Description for field_name", -/// other_field: "Description for other_field", -/// ... -/// )] -/// pub struct MyStruct { -/// pub field_name: Type1, -/// pub other_field: Type2, -/// } -/// ``` -/// -/// For tuple structs: -/// ```ignore -/// #[document_fields( -/// "Description for first field", -/// "Description for second field", -/// ... -/// )] -/// pub struct MyTuple(Type1, Type2); -/// ``` -/// -/// For enums (similar to [`#[document_module]`](macro@document_module)): -/// ```ignore -/// #[document_fields] -/// pub enum MyEnum { -/// #[document_fields( -/// field1: "Description for field1", -/// field2: "Description for field2" -/// )] -/// Variant1 { -/// field1: Type1, -/// field2: Type2, -/// }, -/// -/// #[document_fields( -/// "Description for tuple field" -/// )] -/// Variant2(Type3), -/// } -/// ``` -/// -/// * For structs with named fields: A comma-separated list of `field_ident: "description"` pairs. -/// * For structs with tuple fields: A comma-separated list of string literal descriptions, in order. -/// * For enums: No arguments on the enum itself. Use `#[document_fields(...)]` on individual variants. -/// -/// ### Generates -/// -/// A list of documentation comments, one for each field, prepended to the struct or variant definition. -/// -/// ### Examples -/// -/// ```ignore -/// // Invocation (named struct) -/// #[document_fields( -/// x: "The x coordinate", -/// y: "The y coordinate" -/// )] -/// pub struct Point { -/// pub x: i32, -/// pub y: i32, -/// } -/// -/// // Expanded code -/// /// ### Fields -/// /// * `x`: The x coordinate -/// /// * `y`: The y coordinate -/// pub struct Point { -/// pub x: i32, -/// pub y: i32, -/// } -/// ``` -/// -/// ```ignore -/// // Invocation (tuple struct) -/// #[document_fields( -/// "The wrapped morphism" -/// )] -/// pub struct Endomorphism<'a, C, A>( -/// pub Apply!(;)>::Of<'a, A, A>), -/// ); -/// -/// // Expanded code -/// /// ### Fields -/// /// * `0`: The wrapped morphism -/// pub struct Endomorphism<'a, C, A>( -/// pub Apply!(;)>::Of<'a, A, A>), -/// ); -/// ``` -/// -/// ```ignore -/// // Invocation (enum with variants) -/// #[document_fields] -/// pub enum FreeInner { -/// Pure(A), -/// -/// #[document_fields( -/// head: "The initial computation.", -/// continuations: "The list of continuations." -/// )] -/// Bind { -/// head: Box>, -/// continuations: CatList>, -/// }, -/// } -/// -/// // Expanded code -/// pub enum FreeInner { -/// Pure(A), -/// -/// /// * `head`: The initial computation. -/// /// * `continuations`: The list of continuations. -/// Bind { -/// head: Box>, -/// continuations: CatList>, -/// }, -/// } -/// ``` -/// -/// ### Constraints -/// -/// * All fields must be documented - the macro will error if any field is missing documentation. -/// * The macro cannot be used on zero-sized types (unit structs/variants or structs/variants with no fields). -/// * For named fields, you must use the `field_name: "description"` syntax. -/// * For tuple fields, you must use just `"description"` (no field names). -/// * For enums, the outer `#[document_fields]` must have no arguments. -/// * The macro will error if the wrong syntax is used for the field type. -#[proc_macro_attribute] -pub fn document_fields( - attr: TokenStream, - item: TokenStream, -) -> TokenStream { - match document_fields_worker(attr.into(), item.into()) { - Ok(tokens) => tokens.into(), - Err(e) => e.to_compile_error().into(), - } -} - /// Orchestrates documentation generation for an entire module. /// /// This macro provides a centralized way to handle documentation for Higher-Kinded Type (HKT) diff --git a/fp-macros/src/support.rs b/fp-macros/src/support.rs index 75541457..319dd716 100644 --- a/fp-macros/src/support.rs +++ b/fp-macros/src/support.rs @@ -11,12 +11,10 @@ //! - [`generate_documentation`]: Documentation comment generation utilities. //! - [`get_parameters`]: Logical parameter extraction from function signatures. //! - [`type_visitor`]: Trait for traversing and transforming Rust type syntax trees. -//! - [`document_field`]: Unified field documentation generation for structs and enum variants. //! - [`method_utils`]: Utilities for analyzing methods and impl blocks. pub mod ast; pub mod attributes; -pub mod document_field; pub mod documentation_parameters; pub mod generate_documentation; pub mod get_parameters; diff --git a/fp-macros/src/support/attributes.rs b/fp-macros/src/support/attributes.rs index 02ab1e61..31b630cc 100644 --- a/fp-macros/src/support/attributes.rs +++ b/fp-macros/src/support/attributes.rs @@ -48,6 +48,32 @@ pub fn has_attribute( attrs.iter().any(|attr| attr_matches(attr, name)) } +/// Counts the number of attributes with the given name. +pub fn count_attributes( + attrs: &[Attribute], + name: &str, +) -> usize { + attrs.iter().filter(|attr| attr_matches(attr, name)).count() +} + +/// Returns an error if the attribute list contains a duplicate of the given attribute. +/// +/// Used by standalone attribute macros: the current attribute is consumed by rustc, +/// so any remaining occurrence in the parsed item is a duplicate. +pub fn reject_duplicate_attribute( + attrs: &[Attribute], + name: &str, +) -> crate::core::Result<()> { + if has_attribute(attrs, name) { + Err(crate::core::Error::validation( + proc_macro2::Span::call_site(), + format!("#[{name}] can only be used once per item. Remove the duplicate attribute"), + )) + } else { + Ok(()) + } +} + /// Returns true if the attribute should be kept in generated code. /// /// This filters out documentation-specific attributes like `document_default` @@ -125,9 +151,8 @@ pub fn filter_doc_attributes(attrs: &[Attribute]) -> impl Iterator, @@ -187,7 +212,7 @@ pub fn parse_unique_attribute_value( /// use crate::support::attributes::AttributeExt; /// /// // Find and remove a parsed attribute -/// if let Some(args) = item.attrs.find_and_remove::("document_fields")? { +/// if let Some(args) = item.attrs.find_and_remove::("my_attribute")? { /// // Process args /// } /// @@ -211,7 +236,7 @@ pub trait AttributeExt { /// ```ignore /// use crate::support::attributes::AttributeExt; /// - /// if let Some(args) = item.attrs.find_and_remove::("document_fields")? { + /// if let Some(args) = item.attrs.find_and_remove::("my_attribute")? { /// // The attribute has been removed and args are parsed /// } /// ``` diff --git a/fp-macros/src/support/document_field.rs b/fp-macros/src/support/document_field.rs deleted file mode 100644 index 917d77a3..00000000 --- a/fp-macros/src/support/document_field.rs +++ /dev/null @@ -1,362 +0,0 @@ -use { - std::collections::HashMap, - syn::{ - Attribute, - Fields, - Ident, - LitStr, - Token, - parse::{ - Parse, - ParseStream, - }, - punctuated::Punctuated, - }, -}; - -/// Field documentation generation utilities. -/// -/// This module provides a unified interface for documenting struct and enum variant fields, -/// handling both named and unnamed (tuple) fields. -use crate::{ - core::{ - Error as CoreError, - Result, - }, - support::{ - generate_documentation::{ - format_parameter_doc, - insert_doc_comment, - }, - parsing, - }, -}; - -/// Represents a field documentation entry. -/// -/// For named fields: `field_name: "description"` -/// For tuple fields: just `"description"` -pub enum DocumentFieldParameter { - /// Named field: `field_name: "description"` - Named(Ident, LitStr), - /// Unnamed field (tuple): `"description"` - Unnamed(LitStr), -} - -impl Parse for DocumentFieldParameter { - fn parse(input: ParseStream) -> syn::Result { - // Try to parse as named field first: ident : "string" - if input.peek(Ident) && input.peek2(Token![:]) { - let ident: Ident = input.parse()?; - let _: Token![:] = input.parse()?; - let lit: LitStr = input.parse()?; - Ok(DocumentFieldParameter::Named(ident, lit)) - } else { - // Otherwise, parse as unnamed field: "string" - let lit: LitStr = input.parse()?; - Ok(DocumentFieldParameter::Unnamed(lit)) - } - } -} - -pub struct DocumentFieldParameters { - pub entries: Punctuated, -} - -impl Parse for DocumentFieldParameters { - fn parse(input: ParseStream) -> syn::Result { - Ok(DocumentFieldParameters { - entries: Punctuated::parse_terminated(input)?, - }) - } -} - -/// Information about the fields in a struct or variant. -pub enum FieldInfo { - /// Named fields with their identifiers - Named(Vec), - /// Unnamed fields (tuple) with the count - Unnamed(usize), -} - -impl FieldInfo { - /// Extract field information from a Fields struct. - /// - /// # Returns - /// - `Ok(FieldInfo)` if the fields are valid - /// - `Err` if the fields are unit (no fields) - pub fn from_fields( - fields: &Fields, - span: proc_macro2::Span, - context: &str, - attribute_name: &str, - ) -> Result { - match fields { - Fields::Named(fields_named) => { - // SAFETY: named fields always have an ident - #[allow(clippy::unwrap_used)] - let field_names: Vec<_> = fields_named.named.iter().map(|f| f.ident.clone().unwrap()).collect(); - - let _ = parsing::parse_not_zero_sized( - field_names.len(), - span, - context, - attribute_name, - )?; - - Ok(FieldInfo::Named(field_names)) - } - Fields::Unnamed(fields_unnamed) => { - let field_count = fields_unnamed.unnamed.len(); - - let _ = parsing::parse_not_zero_sized(field_count, span, context, attribute_name)?; - - Ok(FieldInfo::Unnamed(field_count)) - } - Fields::Unit => Err(CoreError::Parse(syn::Error::new( - span, - format!("{attribute_name} cannot be used on unit {context}s"), - ))), - } - } -} - -/// A helper for generating field documentation. -/// -/// This struct encapsulates the logic for validating and generating documentation -/// for both named and unnamed fields. -pub struct FieldDocumenter { - field_info: FieldInfo, - attr_span: proc_macro2::Span, - context: &'static str, -} - -impl FieldDocumenter { - /// Create a new FieldDocumenter. - /// - /// # Parameters - /// - `field_info`: Information about the fields to document - /// - `attr_span`: The span of the attribute for error reporting - /// - `context`: A description of what's being documented (e.g., "struct", "variant") - pub fn new( - field_info: FieldInfo, - attr_span: proc_macro2::Span, - context: &'static str, - ) -> Self { - Self { - field_info, - attr_span, - context, - } - } - - /// Validate and generate documentation for fields. - /// - /// This method validates the provided documentation arguments against the field info, - /// then generates and inserts the appropriate doc comments. - /// - /// # Parameters - /// - `args`: The parsed field documentation arguments - /// - `attrs`: The attribute list to insert documentation into - /// - /// # Returns - /// - `Ok(())` if validation and generation succeeded - /// - `Err` if validation failed - pub fn validate_and_generate( - &self, - args: DocumentFieldParameters, - attrs: &mut Vec, - ) -> Result<()> { - match &self.field_info { - FieldInfo::Named(expected_fields) => - self.process_named_fields(args, expected_fields, attrs), - FieldInfo::Unnamed(expected_count) => - self.process_unnamed_fields(args, *expected_count, attrs), - } - } - - /// Process named fields. - fn process_named_fields( - &self, - args: DocumentFieldParameters, - expected_fields: &[Ident], - attrs: &mut Vec, - ) -> Result<()> { - // Collect all named entries - let mut provided_fields = HashMap::new(); - - for entry in &args.entries { - match entry { - DocumentFieldParameter::Named(ident, desc) => { - let existing = provided_fields.insert(ident.clone(), desc.clone()); - let _ = - parsing::parse_no_duplicate(ident, desc.clone(), existing, self.context)?; - } - DocumentFieldParameter::Unnamed(_) => { - return Err(CoreError::Parse(syn::Error::new( - self.attr_span, - format!( - r#"Expected named field documentation (e.g., `field_name: "description"`), found unnamed description. Use named syntax for {}s with named fields."#, - self.context - ), - ))); - } - } - } - - // Validate completeness - let (_expected, provided_fields) = parsing::parse_named_entries( - expected_fields, - provided_fields, - self.attr_span, - "field", - )?; - - // Generate documentation in the order of field declaration - for field_name in expected_fields { - if let Some(desc) = provided_fields.get(field_name) { - let doc_comment = format_parameter_doc(&field_name.to_string(), &desc.value()); - insert_doc_comment(attrs, doc_comment, proc_macro2::Span::call_site()); - } - } - - Ok(()) - } - - /// Process unnamed (tuple) fields. - fn process_unnamed_fields( - &self, - args: DocumentFieldParameters, - expected_count: usize, - attrs: &mut Vec, - ) -> Result<()> { - // Collect all unnamed entries - let mut descriptions = Vec::new(); - - for entry in &args.entries { - match entry { - DocumentFieldParameter::Unnamed(desc) => { - descriptions.push(desc.clone()); - } - DocumentFieldParameter::Named(ident, _) => { - return Err(CoreError::Parse(syn::Error::new( - ident.span(), - format!( - r#"Expected unnamed field documentation (e.g., just `"description"`), found named syntax. Use comma-separated descriptions for tuple {}s."#, - self.context - ), - ))); - } - } - } - - // Validate count - let _ = parsing::parse_entry_count( - expected_count, - descriptions.len(), - self.attr_span, - "field", - )?; - - // Generate documentation for tuple fields - for (idx, desc) in descriptions.iter().enumerate() { - let field_name = format!("{idx}"); - let doc_comment = format_parameter_doc(&field_name, &desc.value()); - insert_doc_comment(attrs, doc_comment, proc_macro2::Span::call_site()); - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use { - super::*, - quote::quote, - syn::parse_quote, - }; - - #[test] - fn test_field_doc_arg_named() { - let tokens = quote! { x: "description" }; - let arg: DocumentFieldParameter = syn::parse2(tokens).unwrap(); - match arg { - DocumentFieldParameter::Named(ident, desc) => { - assert_eq!(ident.to_string(), "x"); - assert_eq!(desc.value(), "description"); - } - _ => panic!("Expected Named variant"), - } - } - - #[test] - fn test_field_doc_arg_unnamed() { - let tokens = quote! { "description" }; - let arg: DocumentFieldParameter = syn::parse2(tokens).unwrap(); - match arg { - DocumentFieldParameter::Unnamed(desc) => { - assert_eq!(desc.value(), "description"); - } - _ => panic!("Expected Unnamed variant"), - } - } - - #[test] - fn test_field_info_from_named_fields() { - let item_struct: syn::ItemStruct = parse_quote! { - struct Test { x: i32, y: i32 } - }; - let info = FieldInfo::from_fields( - &item_struct.fields, - proc_macro2::Span::call_site(), - "struct", - "#[test]", - ) - .unwrap(); - - match info { - FieldInfo::Named(names) => { - assert_eq!(names.len(), 2); - assert_eq!(names[0].to_string(), "x"); - assert_eq!(names[1].to_string(), "y"); - } - _ => panic!("Expected Named variant"), - } - } - - #[test] - fn test_field_info_from_unnamed_fields() { - let item_struct: syn::ItemStruct = parse_quote! { - struct Test(i32, String); - }; - let info = FieldInfo::from_fields( - &item_struct.fields, - proc_macro2::Span::call_site(), - "struct", - "#[test]", - ) - .unwrap(); - - match info { - FieldInfo::Unnamed(count) => { - assert_eq!(count, 2); - } - _ => panic!("Expected Unnamed variant"), - } - } - - #[test] - fn test_field_info_from_unit_fails() { - let item_struct: syn::ItemStruct = parse_quote! { - struct Test; - }; - let result = FieldInfo::from_fields( - &item_struct.fields, - proc_macro2::Span::call_site(), - "struct", - "#[test]", - ); - assert!(result.is_err()); - } -} diff --git a/fp-macros/src/support/generate_documentation.rs b/fp-macros/src/support/generate_documentation.rs index 95affd80..4fa73572 100644 --- a/fp-macros/src/support/generate_documentation.rs +++ b/fp-macros/src/support/generate_documentation.rs @@ -1,6 +1,7 @@ use { crate::support::{ ast::RustAst, + attributes::reject_duplicate_attribute, documentation_parameters::{ DocumentationParameter, DocumentationParameters, @@ -32,12 +33,15 @@ pub fn generate_doc_comments( attr: TokenStream, item_tokens: TokenStream, section_title: &str, + attribute_name: &str, get_targets: F, ) -> crate::core::Result where F: FnOnce(&RustAst) -> Result, Error>, { let mut generic_item = RustAst::parse(item_tokens).map_err(crate::core::Error::Parse)?; + reject_duplicate_attribute(generic_item.attributes(), attribute_name)?; + let args = syn::parse2::(attr.clone()).map_err(crate::core::Error::Parse)?; diff --git a/fp-macros/src/support/parsing.rs b/fp-macros/src/support/parsing.rs index 784204de..875296bb 100644 --- a/fp-macros/src/support/parsing.rs +++ b/fp-macros/src/support/parsing.rs @@ -13,12 +13,9 @@ use { Span, TokenStream, }, - std::collections::HashMap, syn::{ GenericParam, Generics, - Ident, - LitStr, parse::{ Parse, ParseStream, @@ -130,80 +127,6 @@ pub fn parse_entry_count( Ok((expected, provided)) } -/// Parses a mapping of named entries, checking for completeness and extra entries. -/// -/// Returns both expected and provided if valid. -/// -/// # Parameters -/// - `expected`: Vec of expected field names -/// - `provided`: HashMap of provided field names to descriptions -/// - `span`: The span for error reporting -/// - `context`: A string describing what is being validated (e.g., "field", "parameter") -/// -/// # Returns -/// - `Ok((expected, provided))` if all expected fields are present and no extra fields exist -/// - `Err` with a descriptive error otherwise -pub fn parse_named_entries<'a>( - expected: &'a [Ident], - provided: HashMap, - span: Span, - context: &str, -) -> Result<(&'a [Ident], HashMap)> { - // Check that all expected entries have documentation - for expected_name in expected { - if !provided.contains_key(expected_name) { - return Err(Error::Parse(syn::Error::new( - span, - format_missing_doc_error(context, &expected_name.to_string()), - ))); - } - } - - // Check that no extra entries are documented - for provided_name in provided.keys() { - if !expected.iter().any(|e| e == provided_name) { - return Err(Error::Parse(syn::Error::new( - provided_name.span(), - format_nonexistent_item_error( - context, - &provided_name.to_string(), - &expected.iter().map(|e| e.to_string()).collect::>(), - ), - ))); - } - } - - Ok((expected, provided)) -} - -/// Parses duplicate check during HashMap insertion. -/// -/// Returns the value if it's not a duplicate. -/// -/// # Parameters -/// - `name`: The identifier being checked -/// - `value`: The new value being inserted -/// - `existing_value`: The result of HashMap::insert (Some if duplicate, None if new) -/// - `context`: A string describing what is being validated -/// -/// # Returns -/// - `Ok(value)` if no duplicate -/// - `Err` with a descriptive error if duplicate found -pub fn parse_no_duplicate( - name: &Ident, - value: T, - existing_value: Option, - context: &str, -) -> Result { - if existing_value.is_some() { - return Err(Error::Parse(syn::Error::new( - name.span(), - format_duplicate_doc_error(context, &name.to_string()), - ))); - } - Ok(value) -} - /// Generic helper to validate that a count is non-zero. /// /// Returns the count if valid, or an error with a custom message. @@ -229,30 +152,6 @@ where Ok(count) } -/// Parses that a type has at least one field (not zero-sized). -/// -/// Returns the field count if valid. -/// -/// # Parameters -/// - `field_count`: The number of fields -/// - `span`: The span for error reporting -/// - `type_kind`: A string describing the type (e.g., "struct", "variant") -/// - `attribute_name`: The name of the attribute being applied -/// -/// # Returns -/// - `Ok(field_count)` if field_count > 0 -/// - `Err` if field_count == 0 -pub fn parse_not_zero_sized( - field_count: usize, - span: Span, - type_kind: &str, - attribute_name: &str, -) -> Result { - parse_non_zero_count(field_count, span, || { - format!("{attribute_name} cannot be used on zero-sized types ({type_kind}s with no fields)") - }) -} - /// Parses that documentable items are provided (not empty). /// /// Returns the count if valid. @@ -278,66 +177,6 @@ pub fn parse_has_documentable_items( parse_non_zero_count(count, span, || format!("Cannot use #[{attr_name}] on {item_description}")) } -/// Helper function to capitalize the first character of a string. -fn capitalize_first(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - } -} - -/// Format a missing documentation error message. -/// -/// # Parameters -/// - `context`: A string describing what is being validated (e.g., "field", "parameter") -/// - `name`: The name of the item missing documentation -/// -/// # Returns -/// A formatted error message string -pub fn format_missing_doc_error( - context: &str, - name: &str, -) -> String { - format!("Missing documentation for {context} `{name}`. All {context}s must be documented.") -} - -/// Format a duplicate documentation error message. -/// -/// # Parameters -/// - `context`: A string describing what is being validated (e.g., "field", "parameter") -/// - `name`: The name of the item with duplicate documentation -/// -/// # Returns -/// A formatted error message string -pub fn format_duplicate_doc_error( - context: &str, - name: &str, -) -> String { - format!("Duplicate documentation for {context} `{name}`") -} - -/// Format a non-existent item error message. -/// -/// # Parameters -/// - `context`: A string describing what is being validated (e.g., "field", "parameter") -/// - `name`: The name of the non-existent item -/// - `available`: List of available item names -/// -/// # Returns -/// A formatted error message string -pub fn format_nonexistent_item_error( - context: &str, - name: &str, - available: &[impl std::fmt::Display], -) -> String { - format!( - "{} `{name}` does not exist. Available {context}s: {}", - capitalize_first(context), - available.iter().map(|f| format!("`{f}`")).collect::>().join(", ") - ) -} - /// Parses and validates parameter documentation pairs. /// /// Takes parameter names (targets) and their documentation entries, validates they match, @@ -373,10 +212,7 @@ pub fn parse_parameter_documentation_pairs( #[cfg(test)] mod tests { - use { - super::*, - quote::format_ident, - }; + use super::*; #[test] fn test_parse_many() { @@ -446,77 +282,6 @@ mod tests { assert!(error.contains("found 2")); } - #[test] - fn test_parse_named_entries_complete() { - let expected = vec![format_ident!("x"), format_ident!("y")]; - let mut provided = HashMap::new(); - provided.insert(format_ident!("x"), syn::parse_quote!("X coord")); - provided.insert(format_ident!("y"), syn::parse_quote!("Y coord")); - - let result = parse_named_entries(&expected, provided, Span::call_site(), "field"); - assert!(result.is_ok()); - let (exp, prov) = result.unwrap(); - assert_eq!(exp.len(), 2); - assert_eq!(prov.len(), 2); - } - - #[test] - fn test_parse_named_entries_missing() { - let expected = vec![format_ident!("x"), format_ident!("y")]; - let mut provided = HashMap::new(); - provided.insert(format_ident!("x"), syn::parse_quote!("X coord")); - - let result = parse_named_entries(&expected, provided, Span::call_site(), "field"); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Missing documentation for field `y`")); - } - - #[test] - fn test_parse_named_entries_extra() { - let expected = vec![format_ident!("x")]; - let mut provided = HashMap::new(); - provided.insert(format_ident!("x"), syn::parse_quote!("X coord")); - provided.insert(format_ident!("z"), syn::parse_quote!("Z coord")); - - let result = parse_named_entries(&expected, provided, Span::call_site(), "field"); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Field `z` does not exist")); - } - - #[test] - fn test_parse_no_duplicate_ok() { - let name = format_ident!("x"); - let result = parse_no_duplicate(&name, "new_value", None::<&str>, "field"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "new_value"); - } - - #[test] - fn test_parse_no_duplicate_error() { - let name = format_ident!("x"); - let result = parse_no_duplicate(&name, "new_value", Some("previous"), "field"); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("Duplicate documentation for field `x`")); - } - - #[test] - fn test_parse_not_zero_sized_ok() { - let result = parse_not_zero_sized(1, Span::call_site(), "struct", "#[document_fields]"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 1); - } - - #[test] - fn test_parse_not_zero_sized_zero() { - let result = parse_not_zero_sized(0, Span::call_site(), "struct", "#[document_fields]"); - assert!(result.is_err()); - let error = result.unwrap_err().to_string(); - assert!(error.contains("zero-sized types")); - } - #[test] fn test_parse_has_documentable_items_ok() { let result = parse_has_documentable_items( diff --git a/fp-macros/tests/document_fields_integration.rs b/fp-macros/tests/document_fields_integration.rs deleted file mode 100644 index 505a9365..00000000 --- a/fp-macros/tests/document_fields_integration.rs +++ /dev/null @@ -1,41 +0,0 @@ -use fp_macros::document_fields; - -// Test named struct -#[document_fields( - x: "The x coordinate", - y: "The y coordinate" -)] -pub struct Point { - pub x: i32, - pub y: i32, -} - -// Test tuple struct -#[document_fields("The wrapped value")] -pub struct Wrapper(pub i32); - -// Test tuple struct with multiple fields -#[document_fields("The first value", "The second value", "The third value")] -pub struct Triple(pub i32, pub String, pub bool); - -// Test struct with lifetimes and generics -#[document_fields( - data: "The wrapped data" -)] -pub struct Container<'a, T> { - pub data: &'a T, -} - -#[test] -fn test_structs_compile() { - let _p = Point { - x: 1, - y: 2, - }; - let _w = Wrapper(42); - let _t = Triple(1, "hello".to_string(), true); - let value = 100; - let _c = Container { - data: &value, - }; -} diff --git a/fp-macros/tests/document_module_tests.rs b/fp-macros/tests/document_module_tests.rs index 53b339d1..54690681 100644 --- a/fp-macros/tests/document_module_tests.rs +++ b/fp-macros/tests/document_module_tests.rs @@ -314,3 +314,36 @@ fn test_trait_support_integration() { fn test_trait_level_document_parameters_integration() { // Compile-time test: trait-level #[document_parameters] with receiver doc } + +/// Trait with #[document_signature] on methods — correct ordering. +#[document_module] +mod test_trait_signature_with_examples { + use fp_macros::{ + document_examples, + document_returns, + }; + + #[allow(dead_code)] + /// A test trait with examples. + #[document_examples] + /// + /// ``` + /// assert!(true); + /// ``` + pub trait Testable { + /// Does a thing. + #[fp_macros::document_signature] + #[document_returns("A result.")] + #[document_examples] + /// + /// ``` + /// assert!(true); + /// ``` + fn do_thing() -> bool; + } +} + +#[test] +fn test_trait_signature_with_examples() { + // Compile-time test: #[document_signature] on trait methods +} diff --git a/fp-macros/tests/ui/duplicate_document_examples.rs b/fp-macros/tests/ui/duplicate_document_examples.rs new file mode 100644 index 00000000..6115afc4 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_examples.rs @@ -0,0 +1,13 @@ +//! Test: Duplicate #[document_examples] on a function + +use fp_macros::document_examples; + +#[document_examples] +#[document_examples] +/// +/// ``` +/// assert!(true); +/// ``` +fn foo() {} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_examples.stderr b/fp-macros/tests/ui/duplicate_document_examples.stderr new file mode 100644 index 00000000..be95b03d --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_examples.stderr @@ -0,0 +1,7 @@ +error: Validation error: #[document_examples] can only be used once per item. Remove the duplicate attribute + --> tests/ui/duplicate_document_examples.rs:5:1 + | +5 | #[document_examples] + | ^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_examples` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/fp-macros/tests/ui/duplicate_document_parameters.rs b/fp-macros/tests/ui/duplicate_document_parameters.rs new file mode 100644 index 00000000..97c8237c --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_parameters.rs @@ -0,0 +1,9 @@ +//! Test: Duplicate #[document_parameters] on a function + +use fp_macros::document_parameters; + +#[document_parameters("The value")] +#[document_parameters("The value")] +fn foo(x: i32) {} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_parameters.stderr b/fp-macros/tests/ui/duplicate_document_parameters.stderr new file mode 100644 index 00000000..7361c01c --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_parameters.stderr @@ -0,0 +1,7 @@ +error: Validation error: #[document_parameters] can only be used once per item. Remove the duplicate attribute + --> tests/ui/duplicate_document_parameters.rs:5:1 + | +5 | #[document_parameters("The value")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_parameters` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/fp-macros/tests/ui/duplicate_document_returns.rs b/fp-macros/tests/ui/duplicate_document_returns.rs new file mode 100644 index 00000000..85d16601 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_returns.rs @@ -0,0 +1,11 @@ +//! Test: Duplicate #[document_returns] on a function + +use fp_macros::document_returns; + +#[document_returns("The result")] +#[document_returns("The result")] +fn foo() -> i32 { + 42 +} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_returns.stderr b/fp-macros/tests/ui/duplicate_document_returns.stderr new file mode 100644 index 00000000..ab1452ef --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_returns.stderr @@ -0,0 +1,7 @@ +error: Validation error: #[document_returns] can only be used once per item. Remove the duplicate attribute + --> tests/ui/duplicate_document_returns.rs:5:1 + | +5 | #[document_returns("The result")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_returns` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/fp-macros/tests/ui/duplicate_document_signature_fn.rs b/fp-macros/tests/ui/duplicate_document_signature_fn.rs new file mode 100644 index 00000000..bfb76093 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_fn.rs @@ -0,0 +1,14 @@ +//! Test: Duplicate #[document_signature] on a function +//! +//! This test verifies that using #[document_signature] more than once +//! on the same function produces a helpful error message. + +use fp_macros::document_signature; + +#[document_signature] +#[document_signature] +fn foo(x: i32) -> i32 { + x + 1 +} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_signature_fn.stderr b/fp-macros/tests/ui/duplicate_document_signature_fn.stderr new file mode 100644 index 00000000..6cf1bf8b --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_fn.stderr @@ -0,0 +1,7 @@ +error: Validation error: #[document_signature] can only be used once per item. Remove the duplicate attribute + --> tests/ui/duplicate_document_signature_fn.rs:8:1 + | +8 | #[document_signature] + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_signature` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/fp-macros/tests/ui/duplicate_document_signature_module.rs b/fp-macros/tests/ui/duplicate_document_signature_module.rs new file mode 100644 index 00000000..e7a492d5 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_module.rs @@ -0,0 +1,16 @@ +//! Test: #[document_signature] on a trait inside #[document_module] +//! +//! This test verifies that using #[document_signature] on a trait +//! inside a #[document_module] produces an error, since it is only +//! valid on functions and methods. + +#[fp_macros::document_module(no_validation)] +mod inner { + #[allow(dead_code)] + #[document_signature] + pub trait Functor { + fn map(); + } +} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_signature_module.stderr b/fp-macros/tests/ui/duplicate_document_signature_module.stderr new file mode 100644 index 00000000..e9eb6e4d --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_module.stderr @@ -0,0 +1,10 @@ +error: cannot find attribute `document_signature` in this scope + --> tests/ui/duplicate_document_signature_module.rs:10:4 + | +10 | #[document_signature] + | ^^^^^^^^^^^^^^^^^^ + | +help: consider importing this attribute macro + | + 9 + use fp_macros::document_signature; + | diff --git a/fp-macros/tests/ui/duplicate_document_signature_trait.rs b/fp-macros/tests/ui/duplicate_document_signature_trait.rs new file mode 100644 index 00000000..ed438397 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_trait.rs @@ -0,0 +1,13 @@ +//! Test: #[document_signature] on a trait +//! +//! This test verifies that using #[document_signature] on a trait +//! (rather than a function or method) produces a helpful error message. + +use fp_macros::document_signature; + +#[document_signature] +trait Functor { + fn map(); +} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_signature_trait.stderr b/fp-macros/tests/ui/duplicate_document_signature_trait.stderr new file mode 100644 index 00000000..ac2f4eb6 --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_signature_trait.stderr @@ -0,0 +1,7 @@ +error: Validation error: document_signature can only be used on functions and methods + --> tests/ui/duplicate_document_signature_trait.rs:8:1 + | +8 | #[document_signature] + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_signature` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/fp-macros/tests/ui/duplicate_document_type_parameters.rs b/fp-macros/tests/ui/duplicate_document_type_parameters.rs new file mode 100644 index 00000000..599cc13e --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_type_parameters.rs @@ -0,0 +1,9 @@ +//! Test: Duplicate #[document_type_parameters] on a function + +use fp_macros::document_type_parameters; + +#[document_type_parameters("Type A")] +#[document_type_parameters("Type A")] +fn foo() {} + +fn main() {} diff --git a/fp-macros/tests/ui/duplicate_document_type_parameters.stderr b/fp-macros/tests/ui/duplicate_document_type_parameters.stderr new file mode 100644 index 00000000..2ac5742e --- /dev/null +++ b/fp-macros/tests/ui/duplicate_document_type_parameters.stderr @@ -0,0 +1,7 @@ +error: Validation error: #[document_type_parameters] can only be used once per item. Remove the duplicate attribute + --> tests/ui/duplicate_document_type_parameters.rs:5:1 + | +5 | #[document_type_parameters("Type A")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `document_type_parameters` (in Nightly builds, run with -Z macro-backtrace for more info) From 8a107835b8c4ecf865062e73221b551ee9bc4488 Mon Sep 17 00:00:00 2001 From: nothingnesses <18732253+nothingnesses@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:05:34 +0000 Subject: [PATCH 2/2] chore: release fp-macros v0.6.0 / fp-library v0.13.1 --- Cargo.lock | 4 +-- docs/release-process.md | 59 ++++++++++++++++++++++++++++++++--------- fp-library/CHANGELOG.md | 7 +++++ fp-library/Cargo.toml | 4 +-- fp-macros/CHANGELOG.md | 18 +++++++++++++ fp-macros/Cargo.toml | 2 +- fp-macros/README.md | 2 +- 7 files changed, 77 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb71f531..424433b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "fp-library" -version = "0.13.0" +version = "0.13.1" dependencies = [ "criterion", "fp-macros", @@ -228,7 +228,7 @@ dependencies = [ [[package]] name = "fp-macros" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proc-macro-warning", "proc-macro2", diff --git a/docs/release-process.md b/docs/release-process.md index 74886f19..ebddfedd 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -82,28 +82,61 @@ git checkout -b release/fp-library-vX.Y.Z Run the full suite of checks locally to catch issues before the PR: ```bash -# Check compilation -cargo check - -# Run all tests (unit, integration, and doc) -cargo test +# Format code +cargo fmt --all # Run linter -cargo clippy +cargo clippy --workspace --all-features + +# Verify documentation builds (must produce zero warnings) +cargo doc --workspace --all-features --no-deps + +# Run all tests +cargo test --workspace --all-features +``` + +#### Test Output Caching + +To avoid re-running expensive test suites when source files haven't changed, cache test outputs: + +**Cache file location:** `.claude/test-cache/` (gitignored) -# Verify documentation builds and looks correct -cargo doc --open +**After running tests**, save the output: +```bash +mkdir -p .claude/test-cache +cargo test --workspace --all-features 2>&1 | tee .claude/test-cache/test-output.txt +# Record the timestamp of the newest source file at cache time +find fp-library/src fp-macros/src -name '*.rs' -printf '%T@\n' | sort -rn | head -1 > .claude/test-cache/source-timestamp.txt +``` + +**Before re-running tests**, check if cache is still valid: +```bash +# Get newest source file timestamp +LATEST=$(find fp-library/src fp-macros/src -name '*.rs' -printf '%T@\n' | sort -rn | head -1) +CACHED=$(cat .claude/test-cache/source-timestamp.txt 2>/dev/null || echo "0") +if [ "$LATEST" = "$CACHED" ]; then + echo "=== CACHED TEST OUTPUT (no source changes) ===" + cat .claude/test-cache/test-output.txt +else + echo "=== Source files changed, re-running tests ===" + cargo test --workspace --all-features 2>&1 | tee .claude/test-cache/test-output.txt + echo "$LATEST" > .claude/test-cache/source-timestamp.txt +fi ``` +**Always invalidate** (re-run tests) when: +- Any `.rs` file under `fp-library/src/` or `fp-macros/src/` has been modified since the cached timestamp +- `Cargo.toml` files have changed +- Test files under `tests/` have changed + +**Use cached output** when you just need to re-check results (e.g., confirming a test name, reviewing output) and no source files have changed. + ### 7. Commit and Open PR -1. Stage and commit the release changes: +1. Stage and commit the release changes (includes `Cargo.lock`, READMEs, and any other release-related updates): ```bash - git add fp-library/Cargo.toml fp-library/CHANGELOG.md - # Add fp-macros files if changed - git add fp-macros/Cargo.toml fp-macros/CHANGELOG.md - + git add . git commit -m "chore: release fp-library vX.Y.Z / fp-macros vA.B.C" ``` diff --git a/fp-library/CHANGELOG.md b/fp-library/CHANGELOG.md index 0705230b..74f1dc42 100644 --- a/fp-library/CHANGELOG.md +++ b/fp-library/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.1] - 2026-03-14 + +### Changed +- **`#[kind]` attribute migration**: All 15 type class traits migrated from raw `Kind_*` supertraits to `#[kind(type Of<'a, A: 'a>: 'a;)]` attribute annotations. The generated supertrait bounds are identical; this is a source-level readability improvement only. +- **Rustdoc link improvements**: Doc comments updated to use `Kind!(...)` rustdoc links instead of raw `Kind_*` trait names for better documentation readability. +- **Native field documentation**: Replaced all `#[document_fields]` usages with native `///` doc comments directly on struct fields and enum variants across 12 types (`CatListIterator`, `Endofunction`, `Endomorphism`, `Identity`, `Lazy`, `Pair`, `Thunk`, `Trampoline`, `TryLazy`, `TryThunk`, `TryTrampoline`, `FreeInner`). + ## [0.13.0] - 2026-03-13 ### Added diff --git a/fp-library/Cargo.toml b/fp-library/Cargo.toml index 075234ec..64241cf7 100644 --- a/fp-library/Cargo.toml +++ b/fp-library/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fp-library" -version = "0.13.0" +version = "0.13.1" edition = "2024" description = "A functional programming library for Rust featuring your favourite higher-kinded types and type classes." readme = "../README.md" @@ -18,7 +18,7 @@ harness = false name = "benchmarks" [dependencies] -fp-macros = { path = "../fp-macros", version = "0.5" } +fp-macros = { path = "../fp-macros", version = "0.6" } rayon = { version = "1.11", optional = true } serde = { version = "1.0", optional = true, features = ["derive"] } thiserror = "2.0" diff --git a/fp-macros/CHANGELOG.md b/fp-macros/CHANGELOG.md index 1995cb60..4e166ca6 100644 --- a/fp-macros/CHANGELOG.md +++ b/fp-macros/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-03-14 + +### Added +- **`#[kind]` attribute macro**: Ergonomic Kind supertrait bounds for trait definitions. Replaces raw hash-based trait names (e.g., `Kind_cdc7cd43dac7585f`) with `#[kind(type Of<'a, A: 'a>: 'a;)]` annotations that compute the deterministic hash and append the correct Kind trait as a supertrait bound. +- **`Kind` variant in `TraitCategory`**: Proper classification of `Kind_*` traits in trait analysis, with `KIND_PREFIX` constant. +- **Duplicate attribute rejection**: All standalone `#[document_*]` attribute macros (`document_signature`, `document_type_parameters`, `document_parameters`, `document_returns`, `document_examples`) now reject duplicate usage on the same item with clear error messages. Inside `#[document_module]`, the same checks apply via `count_attributes` for traits, methods, and impl blocks. Compile-fail tests added for each macro. +- **Raw `Kind_*` supertrait warning**: `#[document_module]` now emits a compile-time warning when traits use raw `Kind_*` supertraits instead of the `#[kind(...)]` attribute. + +### Changed +- **`#[document_signature]` restricted to functions and methods**: Using `#[document_signature]` on a trait definition now produces a clear error message instead of generating a class signature. Class signatures were redundant with `cargo doc`'s native supertrait display. +- **Validation label improvement**: `validate_container_documentation` now includes the trait name in its label (e.g., `Trait 'Functor'` instead of `Trait`). + +### Removed +- **`#[document_fields]` macro**: Removed in favor of native `///` doc comments directly on struct fields and enum variants. The macro worker, support module, proc macro export, constant, and integration tests have all been deleted. + +### Fixed +- **`WarningEmitter` name collision**: Fixed E0428 name conflicts when multiple `#[document_module]` invocations exist at the same scope by using a global `AtomicUsize` counter instead of a per-instance counter for warning constant names. + ## [0.5.0] - 2026-03-13 ### Added diff --git a/fp-macros/Cargo.toml b/fp-macros/Cargo.toml index 44d18a43..0fb81723 100644 --- a/fp-macros/Cargo.toml +++ b/fp-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fp-macros" -version = "0.5.0" +version = "0.6.0" edition = "2024" description = "Procedural macros for generating and working with Higher-Kinded Type (HKT) traits in the fp-library crate." repository = "https://github.com/nothingnesses/rust-fp-library" diff --git a/fp-macros/README.md b/fp-macros/README.md index 80424638..b76aab8b 100644 --- a/fp-macros/README.md +++ b/fp-macros/README.md @@ -14,7 +14,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -fp-macros = "0.5" +fp-macros = "0.6" ``` > **Note:** If you are using [`fp-library`](https://crates.io/crates/fp-library), these macros are already re-exported at the crate root. You only need to add this dependency if you are using the macros independently.