defmodule Funx.Monad.Maybe.Dsl.Parser do @moduledoc false # Internal parser for Maybe DSL - converts AST into Step structs alias Funx.Monad.Maybe.Dsl.{Errors, Step} # Operation type classification (for error messages) @maybe_functions [:or_else] @protocol_functions %{ tap: Funx.Tappable, filter: Funx.Filterable, filter_map: Funx.Filterable, guard: Funx.Filterable } # DSL operation → Behavior routing: # bind, tap, filter_map → Bind behavior (bind/3) # map → Map behavior (map/3) # filter, guard → Predicate behavior (predicate/3) # ap → Ap behavior (ap/3) # or_else → No module support (passes function through) @all_allowed_functions @maybe_functions ++ Map.keys(@protocol_functions) # ============================================================================ # PUBLIC API # ============================================================================ @doc """ Parse a DSL block into a list of Step structs """ def parse_operations(block, caller_env, user_env) do block |> extract_operations() |> Enum.map(&parse_operation_to_step(&1, user_env, caller_env)) end # ============================================================================ # OPERATION PARSING # ============================================================================ defp extract_operations({:__block__, _meta, lines}) when is_list(lines), do: lines defp extract_operations(single_line), do: [single_line] defp parse_operation_to_step(operation_ast, user_env, caller_env) do case operation_ast do {:bind, meta, args} -> parse_monad_operation(:bind, args, meta, user_env, caller_env) {:map, meta, args} -> parse_monad_operation(:map, args, meta, user_env, caller_env) {:ap, meta, args} -> {operation, opts} = parse_operation_args(args) # Don't lift for ap - it receives Maybe values directly, not functions # Lifting would incorrectly transform just(42) to fn x -> just(x, 42) end transformed_op = ast_transform_module_for_monad_op(operation, opts, :ap, user_env) %Step.Ap{applicative: transformed_op, __meta__: extract_meta(meta)} {:__aliases__, _, _} = module_alias -> raise_bare_module_error(module_alias) {func_name, meta, args} when is_atom(func_name) and is_list(args) -> parse_maybe_function_to_step(func_name, args, meta, user_env, caller_env) other -> raise_invalid_operation_error(other) end end # Extract common logic for bind and map operations defp parse_monad_operation(type, args, meta, user_env, caller_env) do {operation, opts} = parse_operation_args(args) lifted_op = ast_lift_call_to_unary(operation, caller_env) || operation # Transform modules to call bind/3 or map/3 based on operation type transformed_op = ast_transform_module_for_monad_op(lifted_op, opts, type, user_env) metadata = extract_meta(meta) case type do :bind -> %Step.Bind{operation: transformed_op, opts: [], __meta__: metadata} :map -> %Step.Map{operation: transformed_op, opts: [], __meta__: metadata} end end # Transform modules for bind/map/ap operations to call behavior methods defp ast_transform_module_for_monad_op(operation, opts, type, user_env) do case operation do # Module: transform to call bind/3, map/3, or ap/3 {:__aliases__, _, _} = module_alias -> method = behavior_method_for_type(type) quote do fn value -> unquote(module_alias).unquote(method)(value, unquote(opts), unquote(user_env)) end end # Not a module (function, etc.) - pass through other -> other end end defp parse_maybe_function_to_step(func_name, args, meta, user_env, caller_env) do transformed_args = transform_function_args(func_name, args, user_env, caller_env) metadata = extract_meta(meta) cond do func_name in @maybe_functions -> %Step.MaybeFunction{function: func_name, args: transformed_args, __meta__: metadata} protocol = Map.get(@protocol_functions, func_name) -> %Step.ProtocolFunction{ protocol: protocol, function: func_name, args: transformed_args, __meta__: metadata } true -> raise_invalid_function_error(func_name) end end # Transform arguments based on the function type defp transform_function_args(func_name, args, user_env, caller_env) do Enum.map(args, fn arg -> lifted = ast_lift_call_to_unary(arg, caller_env) || arg transform_arg_for_function(func_name, lifted, user_env, caller_env) end) end defp transform_arg_for_function(:tap, arg, user_env, caller_env) do ast_transform_module_to_bind(arg, user_env, caller_env) end defp transform_arg_for_function(:filter, arg, user_env, caller_env) do ast_transform_module_to_predicate(arg, user_env, caller_env) end defp transform_arg_for_function(:filter_map, arg, user_env, caller_env) do # filter_map returns Maybe/tuple like bind, not boolean like predicate ast_transform_module_to_bind(arg, user_env, caller_env) end defp transform_arg_for_function(:guard, arg, user_env, caller_env) do ast_transform_module_to_predicate(arg, user_env, caller_env) end # Default: pass through (for or_else which doesn't accept modules) defp transform_arg_for_function(_func_name, arg, _user_env, _caller_env) do arg end # Parses operation arguments: {Module, opts} or Module -> {Module, opts} defp parse_operation_args([{_, _} = tuple_op]), do: tuple_op defp parse_operation_args([operation]), do: {operation, []} # ============================================================================ # METADATA EXTRACTION # ============================================================================ # Extract relevant metadata from AST meta keyword list # Note: Elixir AST metadata is always a keyword list, so we don't need a fallback defp extract_meta(meta) when is_list(meta) do %{ line: Keyword.get(meta, :line), column: Keyword.get(meta, :column) } end # ============================================================================ # AST TRANSFORMATIONS # ============================================================================ # Returns the behavior method name for the operation type defp behavior_method_for_type(:bind), do: :bind defp behavior_method_for_type(:map), do: :map defp behavior_method_for_type(:ap), do: :ap # Generic helper to transform modules to call a specific behavior method # Used by bind, map, tap, filter, filter_map, guard, and ap defp ast_transform_module_to_behavior(arg, method, env) do case arg do # Module with options: {Module, opts} {{:__aliases__, _, _} = module_alias, opts_ast} when is_list(opts_ast) -> quote do fn value -> unquote(module_alias).unquote(method)(value, unquote(opts_ast), unquote(env)) end end # Bare module: Module {:__aliases__, _, _} = module_alias -> quote do fn value -> unquote(module_alias).unquote(method)(value, [], unquote(env)) end end # Not a module - return as-is (function, etc.) other -> other end end # Transform modules to call bind/3 (for bind, tap, filter_map operations) defp ast_transform_module_to_bind(arg, user_env, _caller_env) do ast_transform_module_to_behavior(arg, :bind, user_env) end # Transform modules to call predicate/3 (for filter and guard operations) defp ast_transform_module_to_predicate(arg, user_env, _caller_env) do ast_transform_module_to_behavior(arg, :predicate, user_env) end # Lifts Module.fun(args) to fn x -> Module.fun(x, args) end # Matches qualified calls like String.pad_leading(3, "0") defp ast_lift_call_to_unary({{:., _, [mod_ast, fun_atom]}, _, args_ast}, _caller_env) when is_atom(fun_atom) and is_list(args_ast) and args_ast != [] do quote do fn x -> unquote(mod_ast).unquote(fun_atom)(x, unquote_splicing(args_ast)) end end end # Lifts Module.fun() (zero-arity) to &Module.fun/1 # Matches qualified calls like Validators.positive?() defp ast_lift_call_to_unary({{:., meta, [mod_ast, fun_atom]}, _call_meta, []}, _caller_env) when is_atom(fun_atom) do {:&, meta, [{:/, meta, [{{:., meta, [mod_ast, fun_atom]}, meta, []}, 1]}]} end # Lifts bare function calls with arguments: fun(args) to fn x -> fun(x, args) end # Simple structural transformation - no arity checking defp ast_lift_call_to_unary({fun_atom, _meta, args_ast}, _caller_env) when is_atom(fun_atom) and fun_atom not in [:__aliases__, :fn, :&] and is_list(args_ast) and args_ast != [] do quote do fn x -> unquote(fun_atom)(x, unquote_splicing(args_ast)) end end end # Lifts zero-arity function calls to function captures: fun() to &fun/1 defp ast_lift_call_to_unary({fun_atom, meta, args_ast}, _caller_env) when is_atom(fun_atom) and fun_atom not in [:__aliases__, :fn, :&] and is_list(args_ast) and args_ast == [] do fun_tuple = {fun_atom, meta, Elixir} {:&, meta, [{:/, meta, [fun_tuple, 1]}]} end # Non-liftable expressions return nil defp ast_lift_call_to_unary(_other, _caller_env), do: nil # ============================================================================ # ERROR HELPERS # ============================================================================ defp raise_bare_module_error(module_alias) do raise CompileError, description: Errors.bare_module_error(module_alias) end defp raise_invalid_operation_error(other) do raise CompileError, description: Errors.invalid_operation_error(other) end defp raise_invalid_function_error(func_name) do raise CompileError, description: Errors.invalid_function_error(func_name, @all_allowed_functions) end end