Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
otp: '26.2'
- elixir: '1.17.2'
otp: '27.1'
- elixir: '1.18.4'
- elixir: '1.19.5'
otp: '28.3'
env:
MIX_ENV: test
Expand Down
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
elixir 1.17.2-otp-27
erlang 27.1
elixir 1.19.5-otp-28
erlang 28.3
7 changes: 6 additions & 1 deletion lib/eq/dsl/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ defmodule Funx.Eq.Dsl.Executor do
end

# Projection type - use contramap (non-negated)
defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: false, type: :projection}) do
defp node_to_ast(%Step{
projection: projection_ast,
eq: eq_ast,
negate: false,
type: :projection
}) do
quote do
Eq.contramap(unquote(projection_ast), unquote(eq_ast))
end
Expand Down
64 changes: 41 additions & 23 deletions lib/macros.ex
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,43 @@ defmodule Funx.Macros do
end
end

# ============================================================================
# RUNTIME HELPERS (PUBLIC - used by generated code)
# ============================================================================

@doc false
# Runtime check for eq_map - returns the value as-is if it's already an eq_map,
# otherwise wraps it with contramap. Using a separate function avoids type
# warnings in generated code when the projection type is statically known.
@spec maybe_eq_map(term(), module()) :: map()
def maybe_eq_map(projection, eq_module) do
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, eq_module)
end
end

@doc false
# Runtime check for ord_map - returns the value as-is if it's already an ord_map,
# otherwise wraps it with contramap. Using a separate function avoids type
# warnings in generated code when the projection type is statically known.
@spec maybe_ord_map(term(), module()) :: map()
def maybe_ord_map(projection, ord_module) do
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, ord_module)
end
end

# ============================================================================
# AST BUILDERS (PRIVATE)
# ============================================================================
Expand All @@ -423,19 +460,10 @@ defmodule Funx.Macros do
end
end

# For function calls that might return an eq_map, do runtime check
# For function calls that might return an eq_map, use runtime helper
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
Funx.Macros.maybe_eq_map(unquote(projection_ast), unquote(eq_module_ast))
end
end

Expand All @@ -446,20 +474,10 @@ defmodule Funx.Macros do
end
end

# For function calls that might return an ord_map, do runtime check
# For function calls that might return an ord_map, use runtime helper
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
Funx.Macros.maybe_ord_map(unquote(projection_ast), unquote(ord_module_ast))
end
end

Expand Down
13 changes: 1 addition & 12 deletions lib/optics/lens.ex
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,7 @@ defmodule Funx.Optics.Lens do
iex> Funx.Optics.Lens.set!(data, lens, "Bob")
%{user: %{profile: %{name: "Bob"}}}

Raises on missing keys when accessed:

iex> lens = Funx.Optics.Lens.path([:user, :name])
iex> Funx.Optics.Lens.view!(%{}, lens)
** (KeyError) key :user not found in: %{}
Raises `KeyError` on missing keys when accessed.
"""
@spec path([term()]) :: t(map(), term())
def path(keys) when is_list(keys) do
Expand Down Expand Up @@ -239,9 +235,6 @@ defmodule Funx.Optics.Lens do
iex> Funx.Optics.Lens.view!(%{name: "Alice"}, lens)
"Alice"

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.view!(%{}, lens)
** (KeyError) key :name not found in: %{}
"""
@spec view!(s, t(s, a)) :: a
when s: term(), a: term()
Expand All @@ -263,10 +256,6 @@ defmodule Funx.Optics.Lens do
iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set!(%{age: 30, name: "Alice"}, lens, 31)
%{age: 31, name: "Alice"}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set!(%{name: "Alice"}, lens, 31)
** (KeyError) key :age not found in: %{name: "Alice"}
"""
@spec set!(s, t(s, a), a) :: s
when s: term(), a: term()
Expand Down
65 changes: 37 additions & 28 deletions lib/ord/dsl/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -139,36 +139,11 @@ defmodule Funx.Ord.Dsl.Executor do
# Runtime validation of ord map variable

defp step_to_ord_ast(%Step{direction: direction, projection: var_ast, type: :ord_variable}) do
executor = __MODULE__

base_ord_ast =
quote do
ord_var = unquote(var_ast)

case ord_var 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) ->
# Valid ord map - use it directly
ord_var

_ ->
raise RuntimeError, """
Expected an Ord map, got: #{inspect(ord_var)}

An Ord map must have the following structure:
%{
lt?: fn(a, b) -> boolean end,
le?: fn(a, b) -> boolean end,
gt?: fn(a, b) -> boolean end,
ge?: fn(a, b) -> boolean end
}

You can create ord maps using:
- ord do ... end
- Ord.contramap(...)
- Ord.reverse(...)
- Ord.compose([...])
"""
end
unquote(executor).validate_ord_map!(unquote(var_ast))
end

case direction do
Expand All @@ -193,4 +168,38 @@ defmodule Funx.Ord.Dsl.Executor do
%Funx.Monoid.Ord{}
end
end

@doc false
# Validates that a value is an ord_map at runtime. Returns the value if valid,
# raises RuntimeError with a helpful message if invalid. The function boundary
# prevents the type checker from warning about unreachable branches when the
# input type is statically known.
@spec validate_ord_map!(term()) :: Funx.Ord.ord_map()
def validate_ord_map!(ord_var) do
case ord_var 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) ->
ord_var

_ ->
raise RuntimeError, """
Expected an Ord map, got: #{inspect(ord_var)}

An Ord map must have the following structure:
%{
lt?: fn(a, b) -> boolean end,
le?: fn(a, b) -> boolean end,
gt?: fn(a, b) -> boolean end,
ge?: fn(a, b) -> boolean end
}

You can create ord maps using:
- ord do ... end
- Ord.contramap(...)
- Ord.reverse(...)
- Ord.compose([...])
"""
end
end
end
19 changes: 12 additions & 7 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Funx.MixProject do
[
app: :funx,
version: @version,
elixir: "~> 1.16 or ~> 1.17 or ~> 1.18",
elixir: "~> 1.16 or ~> 1.17 or ~> 1.18 or ~> 1.19",
start_permanent: Mix.env() == :prod,
deps: deps(),
consolidate_protocols: Mix.env() != :test,
Expand Down Expand Up @@ -53,12 +53,6 @@ defmodule Funx.MixProject do
]
],
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test
],
package: [
name: "funx",
description:
Expand Down Expand Up @@ -92,6 +86,17 @@ defmodule Funx.MixProject do
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

def cli do
[
preferred_envs: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test
]
]
end

def application do
[
extra_applications: [:logger]
Expand Down
63 changes: 63 additions & 0 deletions test/macros_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,23 @@ defmodule Funx.MacrosTest do
Funx.Macros.ord_for(FnOrdTask, OrdEqHelpers.reverse_priority_ord())
end

# Test structs for function call returning Prism (no or_else, uses maybe_map path)
defmodule FnEqWithPrism do
@moduledoc false
defstruct [:name, :rating]

# Function call returning Prism without or_else - exercises maybe_eq_map fallback
Funx.Macros.eq_for(FnEqWithPrism, OrdEqHelpers.rating_prism())
end

defmodule FnOrdWithPrism do
@moduledoc false
defstruct [:name, :score]

# Function call returning Prism without or_else - exercises maybe_ord_map fallback
Funx.Macros.ord_for(FnOrdWithPrism, OrdEqHelpers.score_prism())
end

# Test structs for function call + or_else (returns Prism, not ord/eq map)
defmodule FnOrdWithOrElse do
@moduledoc false
Expand Down Expand Up @@ -852,6 +869,52 @@ defmodule Funx.MacrosTest do
end
end

describe "eq_for with function call returning Prism (no or_else)" do
test "compares values using Prism projection" do
r1 = %FnEqWithPrism{name: "A", rating: 5}
r2 = %FnEqWithPrism{name: "B", rating: 5}
r3 = %FnEqWithPrism{name: "C", rating: 3}

# Same rating → equal (via Prism/Maybe lift)
assert Eq.eq?(r1, r2)
refute Eq.eq?(r1, r3)
end

test "nil values are equal to each other (Nothing == Nothing)" do
r1 = %FnEqWithPrism{name: "A", rating: nil}
r2 = %FnEqWithPrism{name: "B", rating: nil}

# Both nil → Nothing, Nothing == Nothing
assert Eq.eq?(r1, r2)
end

test "nil not equal to present value (Nothing != Just)" do
r1 = %FnEqWithPrism{name: "A", rating: nil}
r2 = %FnEqWithPrism{name: "B", rating: 5}

# nil vs 5 → Nothing vs Just(5)
refute Eq.eq?(r1, r2)
end
end

describe "ord_for with function call returning Prism (no or_else)" do
test "compares values using Prism projection" do
s1 = %FnOrdWithPrism{name: "A", score: 10}
s2 = %FnOrdWithPrism{name: "B", score: 20}

assert Protocol.lt?(s1, s2)
assert Protocol.gt?(s2, s1)
end

test "nil is less than any present value (Nothing < Just)" do
s1 = %FnOrdWithPrism{name: "A", score: nil}
s2 = %FnOrdWithPrism{name: "B", score: 0}

# nil → Nothing, 0 → Just(0), Nothing < Just
assert Protocol.lt?(s1, s2)
end
end

describe "eq_for with function call + or_else (returns Prism)" do
test "compares values when both present" do
r1 = %FnEqWithOrElse{name: "A", rating: 5}
Expand Down
27 changes: 13 additions & 14 deletions test/monad/either/dsl/parser_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -127,30 +127,29 @@ defmodule Funx.Monad.Either.Dsl.ParserTest do
@either_functions [:filter_or_else, :or_else, :map_left, :flip]
@protocol_functions [:tap]

@either_function_steps %{
filter_or_else: quote(do: filter_or_else(&(&1 > 0), fn -> "error" end)),
or_else: quote(do: or_else(fn -> right(42) end)),
map_left: quote(do: map_left(fn e -> "Error: #{e}" end)),
flip: quote(do: flip())
}

for func <- @either_functions do
test "parses #{func} as either_function" do
func_name = unquote(func)

step =
case func_name do
:filter_or_else -> parse_one(quote do: filter_or_else(&(&1 > 0), fn -> "error" end))
:or_else -> parse_one(quote do: or_else(fn -> right(42) end))
:map_left -> parse_one(quote do: map_left(fn e -> "Error: #{e}" end))
:flip -> parse_one(quote do: flip())
end

step = parse_one(@either_function_steps[func_name])
assert %Step.EitherFunction{function: ^func_name, args: _args} = step
end
end

@protocol_function_steps %{
tap: quote(do: tap(fn x -> x end))
}

for func <- @protocol_functions do
test "parses #{func} as protocol_function" do
func_name = unquote(func)

step =
case func_name do
:tap -> parse_one(quote do: tap(fn x -> x end))
end
step = parse_one(@protocol_function_steps[func_name])

assert %Step.ProtocolFunction{function: ^func_name, protocol: Funx.Tappable, args: _args} =
step
Expand Down
Loading