defmodule Funx.Eq.Dsl.Parser do @moduledoc false # Compile-time parser that converts Eq DSL syntax into typed AST nodes. # # ## Architecture Overview # # The parser is the first phase of DSL compilation: # 1. Parser (this module) - Normalizes syntax → typed Step/Block nodes # 2. Executor - Converts nodes → quoted runtime code # 3. Runtime - Executes compiled equality checks # # ## Type-Based Optimization # # The parser detects projection types at compile time to eliminate runtime # branching and warnings. Each Step is tagged with one of: # # - :projection - Optics (Lens/Prism/Traversal) or functions → contramap # - :module_eq - Module with eq?/2 (like Funx.Eq) → to_eq_map # - :eq_map - Behaviour returning Eq map → use directly # - :dynamic - 0-arity helpers (unknown type) → runtime detection # # This allows the executor to generate specific code paths instead of # runtime case statements that trigger compiler warnings. # # ## Syntax Normalization # # All user syntax is normalized to canonical forms that contramap/2 accepts: # # - :atom → Prism.key(:atom) # - [:a, :b] → Prism.path([:a, :b]) (supports structs too) # - :atom, or_else: x → {Prism.key(:atom), x} # - Lens.key(...) → pass through # - Prism.key(...) → pass through (or tuple with or_else) # - fn x -> ... end → pass through # - Module with eq?/2 → module atom (converted by executor) # - Module with eq/1 → Module.eq(opts) call (returns Eq map) # - Struct module → type filter function # # ## Nested Blocks # # The DSL supports nested any/all blocks for complex logic: # # - any do ... end → Block{strategy: :any, children: [...]} # - all do ... end → Block{strategy: :all, children: [...]} # # Top-level is implicitly "all" (AND logic). alias Funx.Eq.Dsl.{Block, Errors, Step} alias Funx.Optics.Prism @doc """ Parse a DSL block into a list of Step/Block nodes """ def parse_operations(block, caller_env) do block |> extract_operations() |> Enum.map(&parse_entry_to_node(&1, caller_env)) end defp extract_operations({:__block__, _meta, lines}) when is_list(lines), do: lines defp extract_operations(single_line), do: [single_line] # Parse "on value, opt: val" or "on value" or "diff_on value" defp parse_entry_to_node({directive, meta, [[do: block]]}, caller_env) when directive in [:any, :all] do children = parse_operations(block, caller_env) metadata = extract_meta(meta) Block.new(directive, children, metadata) end defp parse_entry_to_node({directive, meta, [projection_value, opts]}, caller_env) when directive in [:on, :diff_on] and is_list(opts) do negate = directive == :diff_on parse_projection(projection_value, opts, negate, meta, caller_env) end defp parse_entry_to_node({directive, meta, [projection_value]}, caller_env) when directive in [:on, :diff_on] do negate = directive == :diff_on parse_projection(projection_value, [], negate, meta, caller_env) end # Bare behaviour module with options: "{UserByName, case_sensitive: false}" defp parse_entry_to_node({{:__aliases__, meta, _} = module_alias, opts}, caller_env) when is_list(opts) do parse_bare_behaviour_module(module_alias, opts, meta, caller_env) end # Catch-all for bare eq expressions (variables, helpers, behaviour modules, etc.) # This handles: # - Behaviour modules: UserById → check if module has eq/1 # - Other eq expressions: variables, function calls, etc. defp parse_entry_to_node(eq_ast, caller_env) do case eq_ast do {:__aliases__, meta, _} = module_alias -> # Try to parse as behaviour module expanded_module = Macro.expand(module_alias, caller_env) Code.ensure_compiled(expanded_module) if function_exported?(expanded_module, :eq, 1) do parse_bare_behaviour_module(module_alias, [], meta, caller_env) else # Error: bare module reference without eq/1 will cause runtime error raise CompileError, line: Keyword.get(meta, :line), description: Errors.bare_module_without_behaviour(expanded_module) end _ -> # Not a module alias, treat as bare Eq map expression Step.new_bare(eq_ast, false, %{}) end end # Parses a bare behaviour module reference into a Step node. defp parse_bare_behaviour_module(module_alias, opts, meta, caller_env) do expanded_module = Macro.expand(module_alias, caller_env) Code.ensure_compiled(expanded_module) unless function_exported?(expanded_module, :eq, 1) do raise CompileError, line: Keyword.get(meta, :line), description: Errors.bare_module_without_behaviour(expanded_module) end # Generate AST to call Module.eq(opts) at runtime behaviour_ast = quote do unquote(module_alias).eq(unquote(opts)) end metadata = extract_meta(meta) Step.new_behaviour(behaviour_ast, false, metadata) end # Parses a single projection (on/diff_on directive) into a Step node. # # Separates DSL-reserved options (:or_else, :eq) from behaviour options, # then delegates to build_projection_ast for type-specific handling. defp parse_projection(projection_value, opts, negate, meta, caller_env) do or_else = Keyword.get(opts, :or_else) custom_eq = Keyword.get(opts, :eq) behaviour_opts = Keyword.drop(opts, [:or_else, :eq]) {projection_ast, type} = build_projection_ast(projection_value, or_else, behaviour_opts, meta, caller_env) eq_ast = custom_eq || quote(do: Funx.Eq.Protocol) metadata = extract_meta(meta) Step.new(projection_ast, eq_ast, negate, type, metadata) end # Atom without or_else defp build_projection_ast(atom, nil, _behaviour_opts, _meta, _caller_env) when is_atom(atom) do ast = quote do Prism.key(unquote(atom)) end {ast, :projection} end # Atom with or_else defp build_projection_ast(atom, or_else, _behaviour_opts, _meta, _caller_env) 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 # List without or_else defp build_projection_ast(list, nil, _behaviour_opts, _meta, _caller_env) when is_list(list) do ast = quote do Prism.path(unquote(list)) end {ast, :projection} end # List with or_else defp build_projection_ast(list, or_else, _behaviour_opts, _meta, _caller_env) when is_list(list) and not is_nil(or_else) do ast = quote do {Prism.path(unquote(list)), unquote(or_else)} end {ast, :projection} end # Captured function defp build_projection_ast({:&, _, _} = fun_ast, or_else, _behaviour_opts, meta, _caller_env) do if is_nil(or_else) do {fun_ast, :projection} else raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_captured_function() end end # Anonymous function defp build_projection_ast({:fn, _, _} = fun_ast, or_else, _behaviour_opts, meta, _caller_env) do if is_nil(or_else) do {fun_ast, :projection} else raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_anonymous_function() end end # Helper function call (0-arity) # # Handles calls like `EqHelpers.by_name()` which could return either a # projection or an Eq map. We can't determine the return type at compile time. # # Without or_else: Use :dynamic type for runtime detection # With or_else: Creates {result, or_else} tuple → :projection type # (contramap handles tuples directly) defp build_projection_ast( {{:., _, [{:__aliases__, _, _}, _]}, _, args} = fun_ast, or_else, _behaviour_opts, _meta, _caller_env ) when args == [] or is_nil(args) do if is_nil(or_else) do {fun_ast, :dynamic} else ast = quote do {unquote(fun_ast), unquote(or_else)} end {ast, :projection} end end # Lens.key defp build_projection_ast( {{:., _, [{:__aliases__, _, [:Lens]}, :key]}, _, _} = lens_ast, or_else, _behaviour_opts, meta, _caller_env ) do if is_nil(or_else) do {lens_ast, :projection} else raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_lens() end end # Lens.path defp build_projection_ast( {{:., _, [{:__aliases__, _, [:Lens]}, :path]}, _, _} = lens_ast, or_else, _behaviour_opts, meta, _caller_env ) do if is_nil(or_else) do {lens_ast, :projection} else raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_lens() end end # Prism (any Prism function) defp build_projection_ast( {{:., _, [{:__aliases__, _, [:Prism]}, _]}, _, _} = prism_ast, or_else, _behaviour_opts, _meta, _caller_env ) do ast = if is_nil(or_else) do prism_ast else quote do {unquote(prism_ast), unquote(or_else)} end end {ast, :projection} end # Traversal (any Traversal function) defp build_projection_ast( {{:., _, [{:__aliases__, _, [:Traversal]}, _]}, _, _} = traversal_ast, or_else, _behaviour_opts, meta, _caller_env ) do if is_nil(or_else) do {traversal_ast, :projection} else raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_traversal() end end # Tuple with prism and or_else defp build_projection_ast({prism_ast, or_else_ast}, nil, _behaviour_opts, _meta, _caller_env) do ast = quote do {unquote(prism_ast), unquote(or_else_ast)} end {ast, :projection} end defp build_projection_ast( {_prism_ast, _or_else_ast}, _extra_or_else, _behaviour_opts, meta, _caller_env ) do raise CompileError, line: Keyword.get(meta, :line), description: Errors.redundant_or_else() end # Module reference - determines module type and generates appropriate code # # Modules can serve three purposes in the DSL, checked in precedence order: # # 1. Eq module (has eq?/2) - e.g., Funx.Eq, custom Eq implementations # Returns: module atom with :module_eq type # Executor: Calls Utils.to_eq_map(module) # # 2. Behaviour (has eq/1) - returns Eq map from options # Returns: Module.eq(opts) call with :eq_map type # Executor: Uses the returned Eq map directly # # 3. Struct (has __struct__/0) - type filter for structural equality # Returns: fn %Struct{} -> true; _ -> false end with :projection type # Executor: Wraps in contramap (always returns true for same type) # # Note: or_else is rejected with modules since it only makes sense with # optional projections (Prisms), not with Eq implementations or type filters. defp build_projection_ast( {:__aliases__, _, _} = module_alias, or_else, behaviour_opts, meta, caller_env ) do unless is_nil(or_else) do raise CompileError, line: Keyword.get(meta, :line), description: Errors.or_else_with_behaviour() end expanded_module = Macro.expand(module_alias, caller_env) Code.ensure_compiled(expanded_module) cond do function_exported?(expanded_module, :eq?, 2) -> {module_alias, :module_eq} function_exported?(expanded_module, :eq, 1) -> ast = build_behaviour_eq_ast(expanded_module, behaviour_opts, meta) {ast, :eq_map} function_exported?(expanded_module, :__struct__, 0) -> ast = build_struct_filter_ast(expanded_module) {ast, :projection} true -> raise CompileError, line: Keyword.get(meta, :line), description: "Module #{inspect(expanded_module)} does not have eq?/2, eq/1, or __struct__/0" end end # Unknown projection type defp build_projection_ast(other, _or_else, _behaviour_opts, meta, _caller_env) do raise CompileError, line: Keyword.get(meta, :line), description: Errors.invalid_projection_type(other) end # Generates a type filter function for struct modules. # # Used when a struct module doesn't implement Eq or Behaviour - creates # a function that returns true only for instances of that struct type. # When wrapped in contramap, this makes equality checks only pass for # values of the same struct type (structural equality). defp build_struct_filter_ast(struct_module) do quote do fn %unquote(struct_module){} -> true _ -> false end end end # Generates a call to the behaviour's eq/1 function with options. # # The behaviour's eq/1 returns an Eq map at runtime, which the executor # uses directly (no contramap wrapping needed). defp build_behaviour_eq_ast(behaviour_module, behaviour_opts, _meta) do quote do unquote(behaviour_module).eq(unquote(behaviour_opts)) end end # Extracts line and column metadata for error reporting. defp extract_meta(meta) when is_list(meta) do %{ line: Keyword.get(meta, :line), column: Keyword.get(meta, :column) } end end