defmodule Funx.Macros do @moduledoc """ [![Run in Livebook](https://livebook.dev/badge/v1/black.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2FJKWA%2Ffunx%2Fblob%2Fmain%2Flivebooks%2Fmacros%2Fmacros.livemd) Provides macros for automatically implementing `Funx.Eq` and `Funx.Ord` protocols for structs based on field projections. The `Funx.Macros` module generates protocol implementations at compile time, eliminating boilerplate while providing flexible projection options for both equality and ordering comparisons. The macros support simple field access, nested structures, optional fields, and custom projections through a unified interface. This module is useful for: - Implementing `Funx.Eq` protocol for structs with projection-based equality - Implementing `Funx.Ord` protocol with various projection strategies - Handling optional fields with safe defaults via `or_else` - Accessing nested structures through Lens and Prism optics - Custom comparison logic via projection functions ## Macros - `eq_for/2` - Generate `Funx.Eq` protocol implementation (basic) - `eq_for/3` - Generate `Funx.Eq` protocol with options (e.g., `or_else`, `eq`) - `ord_for/2` - Generate `Funx.Ord` protocol implementation (basic) - `ord_for/3` - Generate `Funx.Ord` protocol with options (e.g., `or_else`) ## Projection Types Both `eq_for` and `ord_for` macros support multiple projection types, all normalized at compile time: - **Atom** - Converted to `Prism.key(atom)`. Safe for nil values with `Nothing < Just` semantics. - **Atom with or_else** - `ord_for(Struct, :field, or_else: default)` → `{Prism.key(:field), default}`. - **Lens** - Total access via `Lens.key/1` or `Lens.path/1`. Raises `KeyError` on missing keys. - **Prism** - Partial access via `Prism.key/1` or `Prism.path/1`. Returns `Maybe` with `Nothing < Just` semantics. - **Prism with or_else** - `ord_for(Struct, Prism.key(:field), or_else: default)` → `{prism, default}`. - **{Prism, default}** - Tuple syntax for partial access with explicit fallback value. - **Traversal** - Multiple foci via `Traversal.combine/1`. All foci must match for equality. - **Function** - Custom projection `fn x -> ... end` or `&fun/1`. Must return a comparable value. - **Eq DSL** - A pre-built equality comparator from `eq do ... end`. Used directly by `eq_for`. - **Ord DSL** - A pre-built ordering from `ord do ... end`. Used directly by `ord_for`. > Note: Atoms use Prism by default for safety. Use explicit `Lens.key(:field)` when you need > total access that raises on missing keys or nil intermediate values. ## or_else Option The `or_else` option provides fallback values for optional fields: - **Valid with:** Atoms, Prisms, and helper functions returning Prisms - **Invalid with:** Lens (always returns a value), Traversal (focuses on multiple elements), functions (must handle own defaults), struct literals, or `{Prism, default}` tuples (redundant) When `or_else` is used with an incompatible projection type, a clear compile-time error is raised with actionable guidance. ## Examples Simple equality by field: iex> defmodule Person do ...> defstruct [:name, :age] ...> ...> require Funx.Macros ...> Funx.Macros.eq_for(Person, :age) ...> end iex> alias Funx.Eq iex> Eq.eq?(%Person{name: "Alice", age: 30}, %Person{name: "Bob", age: 30}) true Equality with optional field: iex> defmodule Item do ...> defstruct [:name, :score] ...> ...> require Funx.Macros ...> Funx.Macros.eq_for(Item, :score, or_else: 0) ...> end iex> alias Funx.Eq iex> i1 = %Item{name: "A", score: nil} iex> i2 = %Item{name: "B", score: 0} iex> Eq.eq?(i1, i2) # nil becomes 0, so equal true Ordering by field with Prism (safe for nil): iex> defmodule Product do ...> defstruct [:name, :rating] ...> ...> require Funx.Macros ...> Funx.Macros.ord_for(Product, :rating) ...> end iex> alias Funx.Ord iex> p1 = %Product{name: "Widget", rating: 4} iex> p2 = %Product{name: "Gadget", rating: 5} iex> Ord.lt?(p1, p2) true Optional field with or_else: iex> defmodule Item do ...> defstruct [:name, :score] ...> ...> require Funx.Macros ...> Funx.Macros.ord_for(Item, :score, or_else: 0) ...> end iex> alias Funx.Ord iex> i1 = %Item{name: "A", score: nil} iex> i2 = %Item{name: "B", score: 10} iex> Ord.lt?(i1, i2) # nil becomes 0, so 0 < 10 true Nested structure access with Lens: iex> defmodule Address, do: defstruct [:city, :state] iex> defmodule Customer do ...> defstruct [:name, :address] ...> ...> require Funx.Macros ...> alias Funx.Optics.Lens ...> Funx.Macros.ord_for(Customer, Lens.path([:address, :city])) ...> end iex> alias Funx.Ord iex> c1 = %Customer{name: "Alice", address: %Address{city: "Austin", state: "TX"}} iex> c2 = %Customer{name: "Bob", address: %Address{city: "Boston", state: "MA"}} iex> Ord.lt?(c1, c2) # "Austin" < "Boston" true Function projection: iex> defmodule Article do ...> defstruct [:title, :content] ...> ...> require Funx.Macros ...> Funx.Macros.ord_for(Article, &String.length(&1.title)) ...> end iex> alias Funx.Ord iex> a1 = %Article{title: "Short", content: "..."} iex> a2 = %Article{title: "Very Long Title", content: "..."} iex> Ord.lt?(a1, a2) # length("Short") < length("Very Long Title") true ## Protocol Dispatch The generated `Ord` implementations leverage the `Funx.Ord` protocol for projected values. Any type implementing `Ord` can be used as a projection target: defmodule Priority do defstruct [:level] end defimpl Funx.Ord, for: Priority do def lt?(a, b), do: a.level < b.level def le?(a, b), do: a.level <= b.level def gt?(a, b), do: a.level > b.level def ge?(a, b), do: a.level >= b.level end defmodule Task do defstruct [:title, :priority] require Funx.Macros Funx.Macros.ord_for(Task, :priority) # Uses Funx.Ord.Priority end ## Compile-Time Behavior All macros expand at compile time into direct protocol implementations with zero runtime overhead. The `ord_for` macro normalizes all projection types into one of four canonical forms that `Funx.Ord.contramap/2` accepts: 1. `Lens.t()` - Bare Lens struct 2. `Prism.t()` - Bare Prism struct (uses `Maybe.lift_ord`) 3. `{Prism.t(), or_else}` - Prism with fallback value 4. `(a -> b)` - Projection function Example expansion: Funx.Macros.ord_for(Product, :rating, or_else: 0) Compiles to: defimpl Funx.Ord, for: Product do defp __ord_map__ do Funx.Ord.contramap({Prism.key(:rating), 0}) end def lt?(a, b) when is_struct(a, Product) and is_struct(b, Product) do __ord_map__().lt?.(a, b) end # ... other comparison functions end ## Error Handling The macros provide clear compile-time errors for invalid configurations: - Using `or_else` with Lens (total access doesn't need fallback) - Using `or_else` with functions (functions must handle own defaults) - Using `or_else` with `{Prism, default}` tuple (redundant) - Using `or_else` with struct literals (ambiguous semantics) All error messages include actionable guidance and examples of correct usage. """ # credo:disable-for-this-file Credo.Check.Design.AliasUsage alias Funx.Macros.Errors # ============================================================================ # PUBLIC MACROS - Equality (eq_for/2) # ============================================================================ @doc """ Generates an implementation of the `Funx.Eq` protocol for the given struct, using the specified projection as the basis for equality comparison. ## Projection Types The macro supports the same projection types as `ord_for`: - **Atom** - Converted to `Prism.key(atom)`. Safe for nil values. - **Atom with or_else** - `eq_for(Struct, :field, or_else: default)` → `{Prism.key(:field), default}`. - **Lens** - Total access via `Lens.key/1` or `Lens.path/1`. Raises on missing values. - **Prism** - Partial access via `Prism.key/1` or `Prism.path/1`. - **Prism with or_else** - `eq_for(Struct, Prism.key(:field), or_else: default)` → `{prism, default}`. - **{Prism, default}** - Partial access with fallback value. - **Traversal** - Multiple foci via `Traversal.combine/1`. All foci must match. - **Function** - Custom projection function `(struct -> value)`. - **Eq DSL** - A pre-built equality comparator from `eq do ... end`. Used directly without contramap. ## Options - `:or_else` - Fallback value for optional fields. Only valid with atoms and Prisms. - `:eq` - Custom Eq module or map for comparison. Defaults to `Funx.Eq.Protocol`. ## Examples # Atom (backward compatible) defmodule Person do defstruct [:name, :age] end Funx.Macros.eq_for(Person, :age) # Atom with or_else Funx.Macros.eq_for(Person, :score, or_else: 0) # Lens - total access Funx.Macros.eq_for(Customer, Lens.path([:address, :city])) # Prism - partial access Funx.Macros.eq_for(Item, Prism.key(:rating)) # Traversal - multiple foci Funx.Macros.eq_for(Person, Traversal.combine([Lens.key(:name), Lens.key(:age)])) # Function projection Funx.Macros.eq_for(Article, &String.length(&1.title)) # Custom Eq module Funx.Macros.eq_for(Person, :name, eq: CaseInsensitiveEq) # Eq DSL - complex equality with multiple fields use Funx.Eq Funx.Macros.eq_for(Person, eq do on :name on :age end) """ # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defmacro eq_for(for_struct, projection, opts \\ []) do or_else = Keyword.get(opts, :or_else) custom_eq = Keyword.get(opts, :eq) {projection_ast, projection_type} = normalize_projection(projection, or_else) eq_module_ast = custom_eq || quote(do: Funx.Eq.Protocol) eq_map_ast = build_eq_map_ast(projection_ast, eq_module_ast, projection_type) quote do alias Funx.Eq alias Funx.Optics.Prism defimpl Funx.Eq.Protocol, for: unquote(for_struct) do defp __eq_map__, do: unquote(eq_map_ast) def eq?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __eq_map__().eq?.(a, b) end def eq?(%unquote(for_struct){}, b) when is_struct(b), do: false def not_eq?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __eq_map__().not_eq?.(a, b) end def not_eq?(%unquote(for_struct){}, b) when is_struct(b), do: true end end end # ============================================================================ # PUBLIC MACROS - Ordering (ord_for/2, ord_for/3) # ============================================================================ @doc """ Generates an implementation of the `Funx.Ord` protocol for the given struct, using the specified projection as the basis for ordering comparisons. ## Projection Types The macro supports multiple projection types: - **Atom** - Converted to `Prism.key(atom)`. Safe for nil values (Nothing < Just). - **Atom with or_else** - `ord_for(Struct, :field, or_else: default)` → `{Prism.key(:field), default}`. - **Lens** - Total access via `Lens.key/1` or `Lens.path/1`. Raises on missing values. - **Prism** - Partial access via `Prism.key/1` or `Prism.path/1`. Nothing < Just semantics. - **Prism with or_else** - `ord_for(Struct, Prism.key(:field), or_else: default)` → `{prism, default}`. - **{Prism, default}** - Partial access with fallback value for Nothing. - **Function** - Custom projection function `(struct -> comparable)`. - **Ord DSL** - A pre-built ordering from `ord do ... end`. Used directly without contramap. ## Options - `:or_else` - Fallback value for optional fields. Only valid with atoms and Prisms. - `:ord` - Custom Ord module or map for comparison. Defaults to `Funx.Ord.Protocol`. ## Examples # Atom - uses Prism.key (safe for nil) defmodule Product do defstruct [:name, :rating] end Funx.Macros.ord_for(Product, :rating) # Atom with or_else - provides default for nil values Funx.Macros.ord_for(Product, :rating, or_else: 0) # Lens - total access (raises on nil) defmodule Customer do defstruct [:name, :address] end Funx.Macros.ord_for(Customer, Lens.path([:address, :city])) # Prism - partial access Funx.Macros.ord_for(Item, Prism.key(:score)) # Prism with or_else Funx.Macros.ord_for(Item, Prism.key(:score), or_else: 0) # Prism with default tuple (alternative to or_else) Funx.Macros.ord_for(Task, {Prism.key(:priority), 0}) # Function projection Funx.Macros.ord_for(Article, &String.length(&1.title)) # Ord DSL - complex ordering with multiple fields use Funx.Ord Funx.Macros.ord_for(Person, ord do asc :name desc :age end) """ # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defmacro ord_for(for_struct, projection, opts \\ []) do or_else = Keyword.get(opts, :or_else) custom_ord = Keyword.get(opts, :ord) {projection_ast, projection_type} = normalize_projection(projection, or_else) ord_module_ast = custom_ord || quote(do: Funx.Ord.Protocol) ord_map_ast = build_ord_map_ast(projection_ast, ord_module_ast, projection_type) quote do alias Funx.Optics.Prism alias Funx.Ord defimpl Funx.Ord.Protocol, for: unquote(for_struct) do defp __ord_map__, do: unquote(ord_map_ast) def lt?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __ord_map__().lt?.(a, b) end def lt?(%unquote(for_struct){} = a, b) when is_struct(b), do: a.__struct__ < b.__struct__ def le?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __ord_map__().le?.(a, b) end def le?(%unquote(for_struct){} = a, b) when is_struct(b), do: a.__struct__ <= b.__struct__ def gt?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __ord_map__().gt?.(a, b) end def gt?(%unquote(for_struct){} = a, b) when is_struct(b), do: a.__struct__ > b.__struct__ def ge?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __ord_map__().ge?.(a, b) end def ge?(%unquote(for_struct){} = a, b) when is_struct(b), do: a.__struct__ >= b.__struct__ end end end # ============================================================================ # AST BUILDERS (PRIVATE) # ============================================================================ # For known projections, directly call contramap defp build_eq_map_ast(projection_ast, eq_module_ast, :projection) do quote do Funx.Eq.contramap(unquote(projection_ast), unquote(eq_module_ast)) end end # For function calls that might return an eq_map, do runtime check defp build_eq_map_ast(projection_ast, eq_module_ast, :maybe_map) do quote do projection = unquote(projection_ast) case projection do %{eq?: eq_fun, not_eq?: not_eq_fun} when is_function(eq_fun, 2) and is_function(not_eq_fun, 2) -> projection _ -> Funx.Eq.contramap(projection, unquote(eq_module_ast)) end end end # For known projections, directly call contramap defp build_ord_map_ast(projection_ast, ord_module_ast, :projection) do quote do Funx.Ord.contramap(unquote(projection_ast), unquote(ord_module_ast)) end end # For function calls that might return an ord_map, do runtime check defp build_ord_map_ast(projection_ast, ord_module_ast, :maybe_map) do quote do projection = unquote(projection_ast) case projection do %{lt?: lt_fun, le?: le_fun, gt?: gt_fun, ge?: ge_fun} when is_function(lt_fun, 2) and is_function(le_fun, 2) and is_function(gt_fun, 2) and is_function(ge_fun, 2) -> projection _ -> Funx.Ord.contramap(projection, unquote(ord_module_ast)) end end end # ============================================================================ # PROJECTION NORMALIZATION (PRIVATE) # ============================================================================ # Returns {normalized_ast, type} where type is :projection or :maybe_map # Atom without or_else - convert to Prism.key (safe for nil values, Nothing < Just semantics) defp normalize_projection(atom, nil) when is_atom(atom) do ast = quote do Prism.key(unquote(atom)) end {ast, :projection} end # Atom with or_else - convert to {Prism.key, default} defp normalize_projection(atom, or_else) when is_atom(atom) and not is_nil(or_else) do ast = quote do {Prism.key(unquote(atom)), unquote(or_else)} end {ast, :projection} end # Lens.key(...) - cannot use or_else with Lens defp normalize_projection( {{:., _, [{:__aliases__, _, [:Lens]}, :key]}, _, _} = lens_ast, or_else ) do if is_nil(or_else) do {lens_ast, :projection} else raise ArgumentError, Errors.or_else_with_lens() end end # Lens.path(...) - cannot use or_else with Lens defp normalize_projection( {{:., _, [{:__aliases__, _, [:Lens]}, :path]}, _, _} = lens_ast, or_else ) do if is_nil(or_else) do {lens_ast, :projection} else raise ArgumentError, Errors.or_else_with_lens() end end # Prism.key(...) - can use or_else defp normalize_projection( {{:., _, [{:__aliases__, _, [:Prism]}, :key]}, _, _} = prism_ast, or_else ) do if is_nil(or_else) do {prism_ast, :projection} else ast = quote do {unquote(prism_ast), unquote(or_else)} end {ast, :projection} end end # Prism.path(...) - can use or_else defp normalize_projection( {{:., _, [{:__aliases__, _, [:Prism]}, :path]}, _, _} = prism_ast, or_else ) do if is_nil(or_else) do {prism_ast, :projection} else ast = quote do {unquote(prism_ast), unquote(or_else)} end {ast, :projection} end end # Traversal.* (any Traversal function) - cannot use or_else defp normalize_projection( {{:., _, [{:__aliases__, _, [:Traversal]}, _]}, _, _} = traversal_ast, or_else ) do if is_nil(or_else) do {traversal_ast, :projection} else raise ArgumentError, Errors.or_else_with_traversal() end end # {Prism, default} tuple - cannot have additional or_else (redundant) defp normalize_projection({_prism_ast, _or_else_ast} = tuple, nil) do ast = quote do unquote(tuple) end {ast, :projection} end defp normalize_projection({_prism_ast, _or_else_ast}, _extra_or_else) do raise ArgumentError, Errors.redundant_or_else() end # Captured function &fun/1 - cannot use or_else defp normalize_projection({:&, _, _} = fun_ast, or_else) do if is_nil(or_else) do {fun_ast, :projection} else raise ArgumentError, Errors.or_else_with_captured_function() end end # Anonymous function fn ... end - cannot use or_else defp normalize_projection({:fn, _, _} = fun_ast, or_else) do if is_nil(or_else) do {fun_ast, :projection} else raise ArgumentError, Errors.or_else_with_anonymous_function() end end # Struct literal (e.g., %Lens{...}) - cannot use or_else with Lens struct defp normalize_projection({:%, _, _} = struct_ast, or_else) do if is_nil(or_else) do {struct_ast, :projection} else raise ArgumentError, Errors.or_else_with_struct_literal() end end # Remote function call (Module.function()) - might return ord/eq map defp normalize_projection({{:., _, _}, _, _} = call_ast, or_else) do if is_nil(or_else) do {call_ast, :maybe_map} else # If or_else is provided, it's definitely a projection (wrapped in tuple) ast = quote do {unquote(call_ast), unquote(or_else)} end {ast, :projection} end end # Local function call (function_name()) - might return ord/eq map defp normalize_projection({function_name, _, args} = call_ast, or_else) when is_atom(function_name) and is_list(args) do if is_nil(or_else) do {call_ast, :maybe_map} else # If or_else is provided, it's definitely a projection (wrapped in tuple) ast = quote do {unquote(call_ast), unquote(or_else)} end {ast, :projection} end end end