Current section
Files
Jump to
Current section
Files
lib/predicate/dsl/parser.ex
defmodule Funx.Predicate.Dsl.Parser do
@moduledoc false
# Compile-time parser that converts Predicate DSL syntax into Step/Block nodes.
#
# ## Architecture Overview
#
# The parser is the first phase of DSL compilation:
# 1. Parser (this module) - Normalizes syntax → Step/Block nodes
# 2. Executor - Converts nodes → quoted runtime code
# 3. Runtime - Executes compiled predicate checks
#
# ## Syntax Recognition
#
# The parser recognizes these forms:
#
# - Bare predicate → Step{predicate: ast, negate: false}
# - negate predicate → Step{predicate: ast, negate: true}
# - check projection, predicate → Step{projection: proj, predicate: pred, negate: false}
# - negate check projection, pred → Step{projection: proj, predicate: pred, negate: true}
# - check projection, do: predicate → Step{projection: proj, predicate: pred}
# - all do ... end → Block{strategy: :all, children: [...]}
# - any do ... end → Block{strategy: :any, children: [...]}
# - negate_all do ... end → Block{strategy: :any, children: [negated...]} (De Morgan)
# - negate_any do ... end → Block{strategy: :all, children: [negated...]} (De Morgan)
#
# ## Projections
#
# The `check` directive supports:
# - Atom fields: `:name` → uses Prism.key(:name)
# - List/Struct paths: `[:a, :b]` or `[User, :name]` → uses Prism.path(...)
# - Lens: `Lens.key(:name)` or `Lens.path([:a, :b])`
# - Prism: `Prism.key(:name)`, `Prism.at(0)`, etc.
# - Functions: `&get_value/1` or `fn x -> x.value end`
alias Funx.Optics.Prism
alias Funx.Predicate.Dsl.{Block, Errors, Step}
@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 "negate_all do ... end"
# Apply De Morgan's Laws: not(A and B) = (not A) or (not B)
defp parse_entry_to_node({:negate_all, meta, [[do: block]]}, caller_env) do
children = parse_operations(block, caller_env)
negated_children = Enum.map(children, &negate_node/1)
metadata = extract_meta(meta)
Block.new(:any, negated_children, metadata)
end
# Parse "negate_any do ... end"
# Apply De Morgan's Laws: not(A or B) = (not A) and (not B)
defp parse_entry_to_node({:negate_any, meta, [[do: block]]}, caller_env) do
children = parse_operations(block, caller_env)
negated_children = Enum.map(children, &negate_node/1)
metadata = extract_meta(meta)
Block.new(:all, negated_children, metadata)
end
# Parse "any do ... end" or "all do ... end"
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
# Parse "check projection, predicate"
defp parse_entry_to_node({:check, meta, [projection_ast, predicate_ast]}, _caller_env) do
normalized_projection = normalize_projection(projection_ast)
normalized_predicate = normalize_check_predicate(predicate_ast)
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, normalized_predicate, false, metadata)
end
# Parse "check projection" (single argument) - defaults to truthy check
defp parse_entry_to_node({:check, meta, [projection_ast]}, _caller_env) do
normalized_projection = normalize_projection(projection_ast)
truthy_predicate = default_truthy_predicate()
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, truthy_predicate, false, metadata)
end
# Parse "negate check projection, predicate" - negated projection
defp parse_entry_to_node(
{:negate, meta, [{:check, _check_meta, [projection_ast, predicate_ast]}]},
_caller_env
) do
normalized_projection = normalize_projection(projection_ast)
normalized_predicate = normalize_check_predicate(predicate_ast)
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, normalized_predicate, true, metadata)
end
# Parse "negate check projection" (single argument) - negated truthy check
defp parse_entry_to_node(
{:negate, meta, [{:check, _check_meta, [projection_ast]}]},
_caller_env
) do
normalized_projection = normalize_projection(projection_ast)
truthy_predicate = default_truthy_predicate()
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, truthy_predicate, true, metadata)
end
# Parse "negate predicate" - bare negation
defp parse_entry_to_node({:negate, meta, [predicate_ast]}, _caller_env) do
metadata = extract_meta(meta)
Step.new(predicate_ast, true, metadata)
end
# Parse "negate" without predicate (error)
defp parse_entry_to_node({:negate, meta, nil}, _caller_env) do
raise CompileError,
line: Keyword.get(meta, :line),
description: Errors.negate_without_predicate()
end
defp parse_entry_to_node({:negate, meta, []}, _caller_env) do
raise CompileError,
line: Keyword.get(meta, :line),
description: Errors.negate_without_predicate()
end
# Parse behaviour module with options: "{HasMinimumAge, minimum: 21}"
defp parse_entry_to_node({{:__aliases__, meta, _} = module_alias, opts}, caller_env)
when is_list(opts) do
parse_behaviour_module(module_alias, opts, meta, caller_env)
end
# Parse bare behaviour module or other AST
# This catch-all handles:
# - Behaviour modules: IsActive → check if module has pred/1
# - Other predicates: variables, functions, etc.
defp parse_entry_to_node(predicate_ast, caller_env) do
case predicate_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, :pred, 1) do
parse_behaviour_module(module_alias, [], meta, caller_env)
else
# Error: bare module reference without pred/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 regular predicate
Step.new(predicate_ast, false, %{})
end
end
defp parse_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, :pred, 1) do
raise CompileError,
line: Keyword.get(meta, :line),
description:
"Module #{inspect(expanded_module)} does not implement the Predicate.Dsl.Behaviour (missing pred/1)"
end
# Generate AST to call Module.pred(opts) at runtime
behaviour_ast =
quote do
unquote(module_alias).pred(unquote(opts))
end
metadata = extract_meta(meta)
Step.new_behaviour(behaviour_ast, false, metadata)
end
defp extract_meta(meta) do
%{
line: Keyword.get(meta, :line),
file: Keyword.get(meta, :file)
}
end
# Default predicate for single-argument check: truthy check
# Returns AST for `fn value -> !!value end` (truthy, not strict == true)
defp default_truthy_predicate do
quote do
fn value -> !!value end
end
end
# Normalize predicate AST in check directive
#
# Handles behaviour module tuple syntax: {Module, opts} -> Module.pred(opts)
# All other predicates pass through unchanged.
#
# Note: Unlike bare behaviour modules at top-level, we don't validate
# that the module implements the behaviour at compile time. This matches
# how the validate DSL handles validators - shape validation only.
# Runtime will fail with a clear error if the module doesn't have pred/1.
defp normalize_check_predicate({{:__aliases__, _meta, _} = module_alias, opts})
when is_list(opts) do
quote do
unquote(module_alias).pred(unquote(opts))
end
end
# Bare module reference in check: Module -> Module.pred([])
defp normalize_check_predicate({:__aliases__, _meta, _} = module_alias) do
quote do
unquote(module_alias).pred([])
end
end
# All other predicates pass through unchanged
defp normalize_check_predicate(predicate_ast), do: predicate_ast
# Normalize projection AST to canonical form
#
# Atoms are converted to Prism.key calls for safe nil handling.
# Lists are converted to Prism.path calls for nested field access (supports structs too).
# Optics and functions are validated and passed through.
defp normalize_projection(atom) when is_atom(atom) do
quote do
Prism.key(unquote(atom))
end
end
defp normalize_projection(list) when is_list(list) do
quote do
Prism.path(unquote(list))
end
end
# Lens/Prism/Traversal - pass through
defp normalize_projection({{:., _, [{:__aliases__, _, [:Lens | _]}, _]}, _, _} = optic_ast) do
optic_ast
end
defp normalize_projection({{:., _, [{:__aliases__, _, [:Prism | _]}, _]}, _, _} = optic_ast) do
optic_ast
end
defp normalize_projection({{:., _, [{:__aliases__, _, [:Traversal | _]}, _]}, _, _} = optic_ast) do
optic_ast
end
# Functions (captured and anonymous) - pass through
defp normalize_projection({:&, _, _} = fun_ast), do: fun_ast
defp normalize_projection({:fn, _, _} = fun_ast), do: fun_ast
# Variables - pass through (runtime values)
defp normalize_projection({var_name, _, context} = var_ast)
when is_atom(var_name) and is_atom(context) do
var_ast
end
# Module function calls - pass through (e.g., OpticHelpers.my_lens())
defp normalize_projection({{:., _, _}, _, _} = call_ast) do
call_ast
end
# Invalid projection type
defp normalize_projection(other_ast) do
raise CompileError,
description: Errors.invalid_projection_type(other_ast)
end
# ============================================================================
# De Morgan's Law Helpers
# ============================================================================
# Flip strategy for De Morgan's transformation
defp flip_strategy(:any), do: :all
defp flip_strategy(:all), do: :any
# Negate a node (Step or Block)
defp negate_node(%Step{negate: negate} = step) do
# Flip the negate flag
%{step | negate: not negate}
end
defp negate_node(%Block{strategy: strategy, children: children} = block) do
# Apply De Morgan's recursively: flip strategy and negate children
flipped_strategy = flip_strategy(strategy)
negated_children = Enum.map(children, &negate_node/1)
%{block | strategy: flipped_strategy, children: negated_children}
end
end