Current section

Files

Jump to
funx lib ord dsl parser.ex
Raw

lib/ord/dsl/parser.ex

defmodule Funx.Ord.Dsl.Parser do
@moduledoc false
# Internal parser for Ord DSL - converts keyword list AST into Step structs
#
# ## Normalization Contract
#
# This parser normalizes all projection syntax into one of four canonical types
# that 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 or_else value
# 4. (a -> b) - projection function
#
# Plus special types for modules and runtime values:
#
# 5. Module with lt?/2 - converted via to_ord_map
# 6. Behaviour module - calls ord/1 at runtime
# 7. 0-arity helper - runtime type detection
# 8. Ord variable - runtime validation of ord map
#
# All syntax sugar resolves to these types:
#
# - :atom → Prism.key(:atom)
# - [:a, :b] → Prism.path([:a, :b]) (supports structs too)
# - :atom, or_else: x → {Prism.key(:atom), x}
# - Lens.key(...) → Lens.key(...) (pass through)
# - Prism.key(...) → Prism.key(...) (pass through)
# - {Prism, x} → {Prism, x} (pass through)
# - fn -> ... end → fn -> ... end (pass through)
# - Behaviour → fn v -> Behaviour.project(v, []) end
# - my_ord (variable) → runtime ord map validation
#
# contramap/2 is the ONLY place that converts optics to functions.
# The parser never creates function wrappers around optics.
alias Funx.Optics.Prism
alias Funx.Ord.Dsl.Errors
alias Funx.Ord.Dsl.Step
# ============================================================================
# PUBLIC API
# ============================================================================
@doc """
Parse a DSL block into a list of Step structs
"""
def parse_operations(block, caller_env) do
block
|> extract_operations()
|> Enum.map(&parse_entry_to_step(&1, caller_env))
end
# ============================================================================
# OPERATION EXTRACTION
# ============================================================================
defp extract_operations({:__block__, _meta, lines}) when is_list(lines), do: lines
defp extract_operations(single_line), do: [single_line]
# ============================================================================
# ENTRY PARSING
# ============================================================================
# Parse "asc value, opt: val" or "desc value" syntax
# AST format: {direction, meta, [value, [opts]]} for function-style calls with options
# or {direction, meta, [value]} for function-style calls without options
defp parse_entry_to_step({direction, meta, [projection_value, opts]}, caller_env)
when direction in [:asc, :desc] and is_list(opts) do
parse_projection(direction, projection_value, opts, meta, caller_env)
end
defp parse_entry_to_step({direction, meta, [projection_value]}, caller_env)
when direction in [:asc, :desc] do
parse_projection(direction, projection_value, [], meta, caller_env)
end
defp parse_entry_to_step(other, _caller_env) do
raise CompileError, description: Errors.invalid_dsl_syntax(other)
end
# ============================================================================
# PROJECTION PARSING
# ============================================================================
defp parse_projection(direction, projection_value, opts, meta, caller_env) do
or_else = Keyword.get(opts, :or_else)
custom_ord = Keyword.get(opts, :ord)
behaviour_opts = Keyword.drop(opts, [:or_else, :ord])
{projection_ast, type} =
build_projection_ast(projection_value, or_else, behaviour_opts, meta, caller_env)
ord_ast = custom_ord || quote(do: Funx.Ord.Protocol)
metadata = extract_meta(meta)
Step.new(direction, projection_ast, ord_ast, type, metadata)
end
# ============================================================================
# PROJECTION AST BUILDING
# ============================================================================
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
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
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
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
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
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 `OrdHelpers.by_name()` which could return either a
# projection or an Ord 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
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
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
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
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
# Variable reference - runtime ord map
# Matches patterns like `my_ord` where my_ord is a variable holding an ord map
defp build_projection_ast(
{var_name, _meta, context} = var_ast,
or_else,
_behaviour_opts,
meta,
_caller_env
)
when is_atom(var_name) and is_atom(context) do
unless is_nil(or_else) do
raise CompileError,
line: Keyword.get(meta, :line),
description: Errors.or_else_with_ord_variable()
end
# Pass through the variable AST - will be evaluated at runtime
{var_ast, :ord_variable}
end
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)
cond do
function_exported?(expanded_module, :lt?, 2) ->
{module_alias, :module_ord}
function_exported?(expanded_module, :ord, 1) ->
ast = build_behaviour_ord_ast(expanded_module, behaviour_opts, meta)
{ast, :ord_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 lt?/2, ord/1, or __struct__/0"
end
end
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
# ============================================================================
# MODULE PROJECTION HELPERS
# ============================================================================
# Build a type filter projection for struct modules
# Returns true for matching structs, false otherwise
# This allows type partitioning without imposing struct-field ordering
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 ord/1 function with options.
#
# The behaviour's ord/1 returns an Ord map at runtime, which the executor
# uses directly (no contramap wrapping needed).
defp build_behaviour_ord_ast(behaviour_module, behaviour_opts, _meta) do
quote do
unquote(behaviour_module).ord(unquote(behaviour_opts))
end
end
# ============================================================================
# METADATA EXTRACTION
# ============================================================================
defp extract_meta(meta) when is_list(meta) do
%{
line: Keyword.get(meta, :line),
column: Keyword.get(meta, :column)
}
end
end