diff --git a/docs/architecture.md b/docs/architecture.md index 631daea0..1fb791f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ lib/ │ │ │ └── lisp/ # PTC-Lisp DSL (planned) │ ├── parser.ex # S-expression parsing -│ ├── analyzer.ex # AST analysis +│ ├── analyze.ex # AST analysis │ └── interpreter.ex # Evaluation ``` diff --git a/lib/ptc_runner/lisp/analyze.ex b/lib/ptc_runner/lisp/analyze.ex new file mode 100644 index 00000000..ac5e2285 --- /dev/null +++ b/lib/ptc_runner/lisp/analyze.ex @@ -0,0 +1,534 @@ +defmodule PtcRunner.Lisp.Analyze do + @moduledoc """ + Validates and desugars RawAST into CoreAST. + + The analyzer transforms the parser's output (RawAST) into a validated, + desugared intermediate form (CoreAST) that the interpreter can safely evaluate. + + ## Error Handling + + Returns `{:ok, CoreAST.t()}` on success or `{:error, error_reason()}` on failure. + """ + + alias PtcRunner.Lisp.CoreAST + + @type error_reason :: + {:invalid_form, String.t()} + | {:invalid_arity, atom(), String.t()} + | {:invalid_where_form, String.t()} + | {:invalid_where_operator, atom()} + | {:invalid_call_tool_name, any()} + | {:invalid_cond_form, String.t()} + | {:invalid_thread_form, atom(), String.t()} + | {:unsupported_pattern, term()} + + @spec analyze(term()) :: {:ok, CoreAST.t()} | {:error, error_reason()} + def analyze(raw_ast) do + do_analyze(raw_ast) + end + + # ============================================================ + # Literals and basic values + # ============================================================ + + defp do_analyze(nil), do: {:ok, nil} + defp do_analyze(true), do: {:ok, true} + defp do_analyze(false), do: {:ok, false} + defp do_analyze(n) when is_integer(n) or is_float(n), do: {:ok, n} + + defp do_analyze({:string, s}), do: {:ok, {:string, s}} + defp do_analyze({:keyword, k}), do: {:ok, {:keyword, k}} + + # ============================================================ + # Collections + # ============================================================ + + defp do_analyze({:vector, elems}) do + with {:ok, elems2} <- analyze_list(elems) do + {:ok, {:vector, elems2}} + end + end + + defp do_analyze({:map, pairs}) do + with {:ok, pairs2} <- analyze_pairs(pairs) do + {:ok, {:map, pairs2}} + end + end + + # ============================================================ + # Symbols and variables + # ============================================================ + + defp do_analyze({:symbol, name}), do: {:ok, {:var, name}} + defp do_analyze({:ns_symbol, :ctx, key}), do: {:ok, {:ctx, key}} + defp do_analyze({:ns_symbol, :memory, key}), do: {:ok, {:memory, key}} + + # ============================================================ + # List forms (special forms and function calls) + # ============================================================ + + defp do_analyze({:list, [head | rest]} = list) do + dispatch_list_form(head, rest, list) + end + + defp do_analyze({:list, []}) do + {:error, {:invalid_form, "Empty list is not a valid expression"}} + end + + # Dispatch special forms based on the head symbol + defp dispatch_list_form({:symbol, :let}, rest, _list), do: analyze_let(rest) + defp dispatch_list_form({:symbol, :if}, rest, _list), do: analyze_if(rest) + defp dispatch_list_form({:symbol, :fn}, rest, _list), do: analyze_fn(rest) + defp dispatch_list_form({:symbol, :when}, rest, _list), do: analyze_when(rest) + defp dispatch_list_form({:symbol, :cond}, rest, _list), do: analyze_cond(rest) + defp dispatch_list_form({:symbol, :->}, rest, _list), do: analyze_thread(:->, rest) + defp dispatch_list_form({:symbol, :"->>"}, rest, _list), do: analyze_thread(:"->>", rest) + defp dispatch_list_form({:symbol, :and}, rest, _list), do: analyze_and(rest) + defp dispatch_list_form({:symbol, :or}, rest, _list), do: analyze_or(rest) + defp dispatch_list_form({:symbol, :where}, rest, _list), do: analyze_where(rest) + defp dispatch_list_form({:symbol, :"all-of"}, rest, _list), do: analyze_pred_comb(:all_of, rest) + defp dispatch_list_form({:symbol, :"any-of"}, rest, _list), do: analyze_pred_comb(:any_of, rest) + + defp dispatch_list_form({:symbol, :"none-of"}, rest, _list), + do: analyze_pred_comb(:none_of, rest) + + defp dispatch_list_form({:symbol, :call}, rest, _list), do: analyze_call_tool(rest) + + # Comparison operators (strict 2-arity per spec section 8.4) + defp dispatch_list_form({:symbol, op}, rest, _list) + when op in [:=, :"not=", :>, :<, :>=, :<=], + do: analyze_comparison(op, rest) + + # Generic function call + defp dispatch_list_form(_head, _rest, list), do: analyze_call(list) + + # ============================================================ + # Special form: let + # ============================================================ + + defp analyze_let([bindings_ast, body_ast]) do + with {:ok, bindings} <- analyze_bindings(bindings_ast), + {:ok, body} <- do_analyze(body_ast) do + {:ok, {:let, bindings, body}} + end + end + + defp analyze_let(_) do + {:error, {:invalid_arity, :let, "expected (let [bindings] body)"}} + end + + defp analyze_bindings({:vector, elems}) do + if rem(length(elems), 2) != 0 do + {:error, {:invalid_form, "let bindings require even number of forms"}} + else + elems + |> Enum.chunk_every(2) + |> Enum.reduce_while({:ok, []}, fn [pattern_ast, value_ast], {:ok, acc} -> + with {:ok, pattern} <- analyze_pattern(pattern_ast), + {:ok, value} <- do_analyze(value_ast) do + {:cont, {:ok, [{:binding, pattern, value} | acc]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, rev} -> {:ok, Enum.reverse(rev)} + other -> other + end + end + end + + defp analyze_bindings(_) do + {:error, {:invalid_form, "let bindings must be a vector"}} + end + + # ============================================================ + # Pattern analysis (destructuring) + # ============================================================ + + defp analyze_pattern({:symbol, name}), do: {:ok, {:var, name}} + + defp analyze_pattern({:map, pairs}) do + analyze_destructure_map(pairs) + end + + defp analyze_pattern(other) do + {:error, {:unsupported_pattern, other}} + end + + defp analyze_destructure_map(pairs) do + keys_pair = Enum.find(pairs, fn {{:keyword, k}, _} -> k == :keys end) + or_pair = Enum.find(pairs, fn {{:keyword, k}, _} -> k == :or end) + as_pair = Enum.find(pairs, fn {{:keyword, k}, _} -> k == :as end) + + case keys_pair do + {{:keyword, :keys}, {:vector, key_asts}} -> + keys = extract_keys(key_asts) + defaults = extract_defaults(or_pair) + base_pattern = {:destructure, {:keys, keys, defaults}} + maybe_wrap_as(base_pattern, as_pair) + + _ -> + {:error, {:unsupported_pattern, pairs}} + end + end + + defp extract_keys(key_asts) do + Enum.map(key_asts, fn + {:symbol, name} -> name + {:keyword, k} -> k + end) + end + + defp extract_defaults(or_pair) do + case or_pair do + {{:keyword, :or}, {:map, default_pairs}} -> + Enum.map(default_pairs, fn {{:keyword, k}, v} -> {k, v} end) + + nil -> + [] + end + end + + defp maybe_wrap_as(base_pattern, as_pair) do + case as_pair do + {{:keyword, :as}, {:symbol, as_name}} -> + {:ok, {:destructure, {:as, as_name, base_pattern}}} + + nil -> + {:ok, base_pattern} + end + end + + # ============================================================ + # Special form: if and when + # ============================================================ + + defp analyze_if([cond_ast, then_ast, else_ast]) do + with {:ok, c} <- do_analyze(cond_ast), + {:ok, t} <- do_analyze(then_ast), + {:ok, e} <- do_analyze(else_ast) do + {:ok, {:if, c, t, e}} + end + end + + defp analyze_if(_) do + {:error, {:invalid_arity, :if, "expected (if cond then else)"}} + end + + defp analyze_when([cond_ast, body_ast]) do + with {:ok, c} <- do_analyze(cond_ast), + {:ok, b} <- do_analyze(body_ast) do + {:ok, {:if, c, b, nil}} + end + end + + defp analyze_when(_) do + {:error, {:invalid_arity, :when, "expected (when cond body)"}} + end + + # ============================================================ + # Special form: cond → nested if + # ============================================================ + + defp analyze_cond([]) do + {:error, {:invalid_cond_form, "cond requires at least one test/result pair"}} + end + + defp analyze_cond(args) do + with {:ok, pairs, default} <- split_cond_args(args) do + build_nested_if(pairs, default) + end + end + + defp split_cond_args(args) do + case Enum.split(args, length(args) - 2) do + {prefix, [{:keyword, :else}, default_ast]} -> + validate_pairs(prefix, default_ast) + + _ -> + validate_pairs(args, nil) + end + end + + defp validate_pairs(args, default_ast) do + if rem(length(args), 2) != 0 do + {:error, {:invalid_cond_form, "cond requires even number of test/result forms"}} + else + pairs = args |> Enum.chunk_every(2) |> Enum.map(fn [c, r] -> {c, r} end) + {:ok, pairs, default_ast} + end + end + + defp build_nested_if(pairs, default_ast) do + with {:ok, default_core} <- maybe_analyze(default_ast) do + pairs + |> Enum.reverse() + |> Enum.reduce_while({:ok, default_core}, fn {c_ast, r_ast}, {:ok, acc} -> + with {:ok, c} <- do_analyze(c_ast), + {:ok, r} <- do_analyze(r_ast) do + {:cont, {:ok, {:if, c, r, acc}}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + defp maybe_analyze(nil), do: {:ok, nil} + defp maybe_analyze(ast), do: do_analyze(ast) + + # ============================================================ + # Special form: fn (anonymous functions) + # ============================================================ + + defp analyze_fn([params_ast, body_ast]) do + with {:ok, params} <- analyze_fn_params(params_ast), + {:ok, body} <- do_analyze(body_ast) do + {:ok, {:fn, params, body}} + end + end + + defp analyze_fn(_) do + {:error, {:invalid_arity, :fn, "expected (fn [params] body)"}} + end + + defp analyze_fn_params({:vector, param_asts}) do + params = + Enum.reduce_while(param_asts, {:ok, []}, fn ast, {:ok, acc} -> + case analyze_simple_param(ast) do + {:ok, pattern} -> {:cont, {:ok, [pattern | acc]}} + {:error, _} = err -> {:halt, err} + end + end) + + case params do + {:ok, rev} -> {:ok, Enum.reverse(rev)} + other -> other + end + end + + defp analyze_fn_params(_) do + {:error, {:invalid_form, "fn parameters must be a vector"}} + end + + defp analyze_simple_param({:symbol, name}), do: {:ok, {:var, name}} + + defp analyze_simple_param(other) do + {:error, + {:invalid_form, + "fn parameters must be simple symbols, not destructuring patterns. " <> + "Use (fn [m] (let [{:keys [a b]} m] ...)) instead. Got: #{inspect(other)}"}} + end + + # ============================================================ + # Short-circuit logic: and/or + # ============================================================ + + defp analyze_and(args) do + with {:ok, exprs} <- analyze_list(args) do + {:ok, {:and, exprs}} + end + end + + defp analyze_or(args) do + with {:ok, exprs} <- analyze_list(args) do + {:ok, {:or, exprs}} + end + end + + # ============================================================ + # Threading macros: -> and ->> + # ============================================================ + + defp analyze_thread(kind, []) do + {:error, {:invalid_thread_form, kind, "requires at least one expression"}} + end + + defp analyze_thread(kind, [first | steps]) do + with {:ok, acc} <- do_analyze(first) do + thread_steps(kind, acc, steps) + end + end + + defp thread_steps(_kind, acc, []), do: {:ok, acc} + + defp thread_steps(kind, acc, [step | rest]) do + with {:ok, acc2} <- apply_thread_step(kind, acc, step) do + thread_steps(kind, acc2, rest) + end + end + + defp apply_thread_step(kind, acc, {:list, [f_ast | arg_asts]}) do + with {:ok, f} <- do_analyze(f_ast), + {:ok, args} <- analyze_list(arg_asts) do + new_args = + case kind do + :-> -> [acc | args] + :"->>" -> args ++ [acc] + end + + {:ok, {:call, f, new_args}} + end + end + + defp apply_thread_step(_kind, acc, step_ast) do + with {:ok, f} <- do_analyze(step_ast) do + {:ok, {:call, f, [acc]}} + end + end + + # ============================================================ + # Predicates: where + # ============================================================ + + defp analyze_where(args) do + case args do + [field_ast] -> + with {:ok, field_path} <- analyze_field_path(field_ast) do + {:ok, {:where, field_path, :truthy, nil}} + end + + [field_ast, {:symbol, op}, value_ast] -> + with {:ok, field_path} <- analyze_field_path(field_ast), + {:ok, op_tag} <- classify_where_op(op), + {:ok, value} <- do_analyze(value_ast) do + {:ok, {:where, field_path, op_tag, value}} + end + + _ -> + {:error, {:invalid_where_form, "expected (where field) or (where field op value)"}} + end + end + + defp analyze_field_path({:keyword, k}) do + {:ok, {:field, [{:keyword, k}]}} + end + + defp analyze_field_path({:vector, elems}) do + segments = + Enum.map(elems, fn + {:keyword, k} -> {:keyword, k} + {:string, s} -> {:string, s} + end) + + {:ok, {:field, segments}} + end + + defp analyze_field_path(other) do + {:error, {:invalid_where_form, "field must be keyword or vector, got: #{inspect(other)}"}} + end + + defp classify_where_op(:=), do: {:ok, :eq} + defp classify_where_op(:"not="), do: {:ok, :not_eq} + defp classify_where_op(:>), do: {:ok, :gt} + defp classify_where_op(:<), do: {:ok, :lt} + defp classify_where_op(:>=), do: {:ok, :gte} + defp classify_where_op(:<=), do: {:ok, :lte} + defp classify_where_op(:includes), do: {:ok, :includes} + defp classify_where_op(:in), do: {:ok, :in} + defp classify_where_op(op), do: {:error, {:invalid_where_operator, op}} + + # ============================================================ + # Predicate combinators: all-of, any-of, none-of + # ============================================================ + + defp analyze_pred_comb(kind, args) do + with {:ok, preds} <- analyze_list(args) do + {:ok, {:pred_combinator, kind, preds}} + end + end + + # ============================================================ + # Tool invocation: call + # ============================================================ + + defp analyze_call_tool([{:string, name}]) do + {:ok, {:call_tool, name, {:map, []}}} + end + + defp analyze_call_tool([{:string, name}, args_ast]) do + with {:ok, args_core} <- do_analyze(args_ast) do + case args_core do + {:map, _} = args_map -> + {:ok, {:call_tool, name, args_map}} + + other -> + {:error, {:invalid_form, "call args must be a map, got: #{inspect(other)}"}} + end + end + end + + defp analyze_call_tool([other | _]) do + {:error, + {:invalid_call_tool_name, "tool name must be string literal, got: #{inspect(other)}"}} + end + + defp analyze_call_tool(_) do + {:error, + {:invalid_arity, :call, "expected (call \"tool-name\") or (call \"tool-name\" args)"}} + end + + # ============================================================ + # Comparison operators (strict 2-arity) + # ============================================================ + + defp analyze_comparison(op, [left_ast, right_ast]) do + with {:ok, left} <- do_analyze(left_ast), + {:ok, right} <- do_analyze(right_ast) do + {:ok, {:call, {:var, op}, [left, right]}} + end + end + + defp analyze_comparison(op, args) do + {:error, + {:invalid_arity, op, + "comparison operators require exactly 2 arguments, got #{length(args)}. " <> + "Use (and (#{op} a b) (#{op} b c)) for chained comparisons."}} + end + + # ============================================================ + # Generic function call + # ============================================================ + + defp analyze_call({:list, [f_ast | arg_asts]}) do + with {:ok, f} <- do_analyze(f_ast), + {:ok, args} <- analyze_list(arg_asts) do + {:ok, {:call, f, args}} + end + end + + # ============================================================ + # Helper functions + # ============================================================ + + defp analyze_list(xs) do + xs + |> Enum.reduce_while({:ok, []}, fn x, {:ok, acc} -> + case do_analyze(x) do + {:ok, x2} -> {:cont, {:ok, [x2 | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, rev} -> {:ok, Enum.reverse(rev)} + other -> other + end + end + + defp analyze_pairs(pairs) do + pairs + |> Enum.reduce_while({:ok, []}, fn {k, v}, {:ok, acc} -> + with {:ok, k2} <- do_analyze(k), + {:ok, v2} <- do_analyze(v) do + {:cont, {:ok, [{k2, v2} | acc]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, rev} -> {:ok, Enum.reverse(rev)} + other -> other + end + end +end diff --git a/lib/ptc_runner/lisp/core_ast.ex b/lib/ptc_runner/lisp/core_ast.ex new file mode 100644 index 00000000..048500f9 --- /dev/null +++ b/lib/ptc_runner/lisp/core_ast.ex @@ -0,0 +1,71 @@ +defmodule PtcRunner.Lisp.CoreAST do + @moduledoc """ + Core, validated AST for PTC-Lisp. + + This module defines the type specifications for the intermediate + representation that the analyzer produces. The interpreter evaluates + CoreAST to produce results. + + ## Pipeline + + ``` + source → Parser → RawAST → Analyze → CoreAST → Eval → result + ``` + """ + + @type literal :: + nil + | boolean() + | number() + | {:string, String.t()} + | {:keyword, atom()} + + @type t :: + literal + # Collections + | {:vector, [t()]} + | {:map, [{t(), t()}]} + # Variables and namespace access + | {:var, atom()} + | {:ctx, atom()} + | {:memory, atom()} + # Function call: f(args...) + | {:call, t(), [t()]} + # Let bindings: (let [p1 v1 p2 v2 ...] body) + | {:let, [binding()], t()} + # Conditionals + | {:if, t(), t(), t()} + # Anonymous function (simple params only, no destructuring) + | {:fn, [simple_param()], t()} + # Short-circuit logic (special forms, not calls) + | {:and, [t()]} + | {:or, [t()]} + # Predicates + | {:where, field_path(), where_op(), t() | nil} + | {:pred_combinator, :all_of | :any_of | :none_of, [t()]} + # Tool call + | {:call_tool, String.t(), t()} + + @type binding :: {:binding, pattern(), t()} + + @type pattern :: + {:var, atom()} + | {:destructure, {:keys, [atom()], keyword()}} + | {:destructure, {:as, atom(), pattern()}} + + @type simple_param :: {:var, atom()} + + @type field_path :: {:field, [field_segment()]} + @type field_segment :: {:keyword, atom()} | {:string, String.t()} + + @type where_op :: + :eq + | :not_eq + | :gt + | :lt + | :gte + | :lte + | :includes + | :in + | :truthy +end diff --git a/test/ptc_runner/lisp/analyze_test.exs b/test/ptc_runner/lisp/analyze_test.exs new file mode 100644 index 00000000..3dad1653 --- /dev/null +++ b/test/ptc_runner/lisp/analyze_test.exs @@ -0,0 +1,721 @@ +defmodule PtcRunner.Lisp.AnalyzeTest do + use ExUnit.Case, async: true + + alias PtcRunner.Lisp.Analyze + + describe "literals pass through" do + test "nil" do + assert {:ok, nil} = Analyze.analyze(nil) + end + + test "booleans" do + assert {:ok, true} = Analyze.analyze(true) + assert {:ok, false} = Analyze.analyze(false) + end + + test "integers" do + assert {:ok, 42} = Analyze.analyze(42) + assert {:ok, -10} = Analyze.analyze(-10) + assert {:ok, 0} = Analyze.analyze(0) + end + + test "floats" do + assert {:ok, 3.14} = Analyze.analyze(3.14) + assert {:ok, -2.5} = Analyze.analyze(-2.5) + end + + test "strings" do + assert {:ok, {:string, "hello"}} = Analyze.analyze({:string, "hello"}) + assert {:ok, {:string, ""}} = Analyze.analyze({:string, ""}) + end + + test "keywords" do + assert {:ok, {:keyword, :name}} = Analyze.analyze({:keyword, :name}) + assert {:ok, {:keyword, :status}} = Analyze.analyze({:keyword, :status}) + end + end + + describe "vectors" do + test "empty vector" do + assert {:ok, {:vector, []}} = Analyze.analyze({:vector, []}) + end + + test "vector with literals" do + assert {:ok, {:vector, [1, 2, 3]}} = Analyze.analyze({:vector, [1, 2, 3]}) + end + + test "vector with mixed types" do + assert {:ok, {:vector, [1, {:string, "test"}, {:keyword, :foo}]}} = + Analyze.analyze({:vector, [1, {:string, "test"}, {:keyword, :foo}]}) + end + + test "nested vectors" do + assert {:ok, {:vector, [{:vector, [1, 2]}, {:vector, [3, 4]}]}} = + Analyze.analyze({:vector, [{:vector, [1, 2]}, {:vector, [3, 4]}]}) + end + end + + describe "maps" do + test "empty map" do + assert {:ok, {:map, []}} = Analyze.analyze({:map, []}) + end + + test "map with literal keys and values" do + assert {:ok, {:map, [{{:keyword, :name}, {:string, "test"}}]}} = + Analyze.analyze({:map, [{{:keyword, :name}, {:string, "test"}}]}) + end + + test "map with multiple pairs" do + assert {:ok, {:map, [{{:keyword, :a}, 1}, {{:keyword, :b}, 2}]}} = + Analyze.analyze({:map, [{{:keyword, :a}, 1}, {{:keyword, :b}, 2}]}) + end + + test "nested maps" do + inner = {:map, [{{:keyword, :x}, 1}]} + + assert {:ok, {:map, [{{:keyword, :outer}, ^inner}]}} = + Analyze.analyze({:map, [{{:keyword, :outer}, inner}]}) + end + end + + describe "symbols become vars" do + test "regular symbol becomes var" do + assert {:ok, {:var, :filter}} = Analyze.analyze({:symbol, :filter}) + end + + test "multiple symbol examples" do + assert {:ok, {:var, :x}} = Analyze.analyze({:symbol, :x}) + assert {:ok, {:var, :count}} = Analyze.analyze({:symbol, :count}) + assert {:ok, {:var, :is_valid?}} = Analyze.analyze({:symbol, :is_valid?}) + end + + test "ctx namespace symbol" do + assert {:ok, {:ctx, :input}} = Analyze.analyze({:ns_symbol, :ctx, :input}) + end + + test "multiple ctx symbols" do + assert {:ok, {:ctx, :data}} = Analyze.analyze({:ns_symbol, :ctx, :data}) + assert {:ok, {:ctx, :query}} = Analyze.analyze({:ns_symbol, :ctx, :query}) + end + + test "memory namespace symbol" do + assert {:ok, {:memory, :results}} = Analyze.analyze({:ns_symbol, :memory, :results}) + end + + test "multiple memory symbols" do + assert {:ok, {:memory, :cache}} = Analyze.analyze({:ns_symbol, :memory, :cache}) + assert {:ok, {:memory, :counter}} = Analyze.analyze({:ns_symbol, :memory, :counter}) + end + end + + describe "when desugars to if" do + test "when becomes if with nil else" do + raw = {:list, [{:symbol, :when}, true, 42]} + assert {:ok, {:if, true, 42, nil}} = Analyze.analyze(raw) + end + + test "when with symbol condition" do + raw = {:list, [{:symbol, :when}, {:symbol, :check}, 100]} + assert {:ok, {:if, {:var, :check}, 100, nil}} = Analyze.analyze(raw) + end + + test "when with complex body" do + raw = {:list, [{:symbol, :when}, true, {:list, [{:symbol, :+}, 1, 2]}]} + assert {:ok, {:if, true, {:call, {:var, :+}, [1, 2]}, nil}} = Analyze.analyze(raw) + end + + test "when with wrong arity fails" do + raw = {:list, [{:symbol, :when}, true]} + assert {:error, {:invalid_arity, :when, msg}} = Analyze.analyze(raw) + assert msg =~ "expected" + end + end + + describe "cond desugars to nested if" do + test "simple cond" do + raw = + {:list, + [ + {:symbol, :cond}, + {:symbol, :a}, + 1, + {:symbol, :b}, + 2, + {:keyword, :else}, + 3 + ]} + + assert {:ok, {:if, {:var, :a}, 1, {:if, {:var, :b}, 2, 3}}} = Analyze.analyze(raw) + end + + test "cond without else clause defaults to nil" do + raw = + {:list, + [ + {:symbol, :cond}, + true, + 42, + false, + 99 + ]} + + assert {:ok, {:if, true, 42, {:if, false, 99, nil}}} = Analyze.analyze(raw) + end + + test "cond with single pair" do + raw = + {:list, + [ + {:symbol, :cond}, + {:symbol, :x}, + 100 + ]} + + assert {:ok, {:if, {:var, :x}, 100, nil}} = Analyze.analyze(raw) + end + + test "cond empty fails" do + raw = {:list, [{:symbol, :cond}]} + assert {:error, {:invalid_cond_form, msg}} = Analyze.analyze(raw) + assert msg =~ "at least one" + end + + test "cond with odd pairs fails" do + raw = + {:list, + [ + {:symbol, :cond}, + true, + 1, + false + ]} + + assert {:error, {:invalid_cond_form, msg}} = Analyze.analyze(raw) + assert msg =~ "even number" + end + end + + describe "threading desugars to nested calls" do + test "thread-first" do + raw = + {:list, + [ + {:symbol, :->}, + {:symbol, :x}, + {:list, [{:symbol, :f}, {:symbol, :a}]} + ]} + + assert {:ok, + {:call, {:var, :f}, + [ + {:var, :x}, + {:var, :a} + ]}} = Analyze.analyze(raw) + end + + test "thread-last" do + raw = + {:list, + [ + {:symbol, :"->>"}, + {:symbol, :x}, + {:list, [{:symbol, :f}, {:symbol, :a}]}, + {:list, [{:symbol, :g}, {:symbol, :b}]} + ]} + + assert {:ok, + {:call, {:var, :g}, + [ + {:var, :b}, + {:call, {:var, :f}, + [ + {:var, :a}, + {:var, :x} + ]} + ]}} = Analyze.analyze(raw) + end + + test "thread with symbol step" do + raw = + {:list, + [ + {:symbol, :->}, + {:symbol, :x}, + {:symbol, :f} + ]} + + assert {:ok, + {:call, {:var, :f}, + [ + {:var, :x} + ]}} = Analyze.analyze(raw) + end + + test "thread with no expressions fails" do + raw = {:list, [{:symbol, :->}]} + assert {:error, {:invalid_thread_form, :->, msg}} = Analyze.analyze(raw) + assert msg =~ "at least one" + end + end + + describe "where validation" do + test "valid where with operator" do + raw = {:list, [{:symbol, :where}, {:keyword, :status}, {:symbol, :=}, {:string, "active"}]} + + assert {:ok, {:where, {:field, [{:keyword, :status}]}, :eq, {:string, "active"}}} = + Analyze.analyze(raw) + end + + test "truthy check (single arg)" do + raw = {:list, [{:symbol, :where}, {:keyword, :active}]} + + assert {:ok, {:where, {:field, [{:keyword, :active}]}, :truthy, nil}} = + Analyze.analyze(raw) + end + + test "where with various operators" do + operators = [ + {:=, :eq}, + {:"not=", :not_eq}, + {:>, :gt}, + {:<, :lt}, + {:>=, :gte}, + {:<=, :lte}, + {:includes, :includes}, + {:in, :in} + ] + + for {sym_op, core_op} <- operators do + raw = {:list, [{:symbol, :where}, {:keyword, :field}, {:symbol, sym_op}, 42]} + + assert {:ok, {:where, {:field, [{:keyword, :field}]}, ^core_op, 42}} = + Analyze.analyze(raw) + end + end + + test "where with vector field path" do + raw = + {:list, + [ + {:symbol, :where}, + {:vector, [{:keyword, :user}, {:keyword, :name}]}, + {:symbol, :=}, + {:string, "John"} + ]} + + assert {:ok, + {:where, {:field, [{:keyword, :user}, {:keyword, :name}]}, :eq, {:string, "John"}}} = + Analyze.analyze(raw) + end + + test "invalid operator fails" do + raw = {:list, [{:symbol, :where}, {:keyword, :x}, {:symbol, :like}, {:string, "foo"}]} + assert {:error, {:invalid_where_operator, :like}} = Analyze.analyze(raw) + end + + test "invalid field type fails" do + raw = {:list, [{:symbol, :where}, 42]} + assert {:error, {:invalid_where_form, msg}} = Analyze.analyze(raw) + assert msg =~ "field" + end + + test "wrong arity fails" do + raw = {:list, [{:symbol, :where}]} + assert {:error, {:invalid_where_form, msg}} = Analyze.analyze(raw) + assert msg =~ "expected" + end + end + + describe "predicate combinators" do + test "empty all-of" do + raw = {:list, [{:symbol, :"all-of"}]} + assert {:ok, {:pred_combinator, :all_of, []}} = Analyze.analyze(raw) + end + + test "empty any-of" do + raw = {:list, [{:symbol, :"any-of"}]} + assert {:ok, {:pred_combinator, :any_of, []}} = Analyze.analyze(raw) + end + + test "empty none-of" do + raw = {:list, [{:symbol, :"none-of"}]} + assert {:ok, {:pred_combinator, :none_of, []}} = Analyze.analyze(raw) + end + + test "all-of with predicates" do + raw = + {:list, + [ + {:symbol, :"all-of"}, + {:list, [{:symbol, :where}, {:keyword, :x}]}, + {:list, [{:symbol, :where}, {:keyword, :y}]} + ]} + + assert {:ok, + {:pred_combinator, :all_of, + [ + {:where, {:field, [{:keyword, :x}]}, :truthy, nil}, + {:where, {:field, [{:keyword, :y}]}, :truthy, nil} + ]}} = Analyze.analyze(raw) + end + end + + describe "call tool invocation" do + test "call with just tool name" do + raw = {:list, [{:symbol, :call}, {:string, "get-users"}]} + assert {:ok, {:call_tool, "get-users", {:map, []}}} = Analyze.analyze(raw) + end + + test "call with args" do + raw = {:list, [{:symbol, :call}, {:string, "filter-data"}, {:map, []}]} + assert {:ok, {:call_tool, "filter-data", {:map, []}}} = Analyze.analyze(raw) + end + + test "call with args containing data" do + raw = + {:list, + [ + {:symbol, :call}, + {:string, "search"}, + {:map, [{{:keyword, :query}, {:string, "test"}}]} + ]} + + assert {:ok, {:call_tool, "search", {:map, [{{:keyword, :query}, {:string, "test"}}]}}} = + Analyze.analyze(raw) + end + + test "call with non-string name fails" do + raw = {:list, [{:symbol, :call}, {:symbol, :"get-users"}]} + assert {:error, {:invalid_call_tool_name, msg}} = Analyze.analyze(raw) + assert msg =~ "string literal" + end + + test "call with non-map args fails" do + raw = {:list, [{:symbol, :call}, {:string, "get"}, {:vector, [1, 2]}]} + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "map" + end + + test "call with wrong arity fails" do + raw = {:list, [{:symbol, :call}]} + assert {:error, {:invalid_arity, :call, msg}} = Analyze.analyze(raw) + assert msg =~ "expected" + end + end + + describe "comparison operators (strict 2-arity)" do + test "less than" do + raw = {:list, [{:symbol, :<}, 1, 2]} + assert {:ok, {:call, {:var, :<}, [1, 2]}} = Analyze.analyze(raw) + end + + test "all comparison operators with 2 args" do + for op <- [:=, :"not=", :>, :<, :>=, :<=] do + raw = {:list, [{:symbol, op}, {:symbol, :a}, {:symbol, :b}]} + assert {:ok, {:call, {:var, ^op}, [{:var, :a}, {:var, :b}]}} = Analyze.analyze(raw) + end + end + + test "comparison with literals" do + raw = {:list, [{:symbol, :=}, {:string, "x"}, {:string, "y"}]} + + assert {:ok, + {:call, {:var, :=}, + [ + {:string, "x"}, + {:string, "y"} + ]}} = Analyze.analyze(raw) + end + + test "chained comparison (3 args) fails" do + raw = {:list, [{:symbol, :<}, 1, 2, 3]} + assert {:error, {:invalid_arity, :<, msg}} = Analyze.analyze(raw) + assert msg =~ "exactly 2 arguments" + assert msg =~ "got 3" + end + + test "single arg comparison fails" do + raw = {:list, [{:symbol, :>}, 1]} + assert {:error, {:invalid_arity, :>, msg}} = Analyze.analyze(raw) + assert msg =~ "exactly 2 arguments" + assert msg =~ "got 1" + end + + test "zero arg comparison fails" do + raw = {:list, [{:symbol, :=}]} + assert {:error, {:invalid_arity, :=, msg}} = Analyze.analyze(raw) + assert msg =~ "exactly 2 arguments" + assert msg =~ "got 0" + end + end + + describe "generic function calls" do + test "simple call with no args" do + raw = {:list, [{:symbol, :f}]} + assert {:ok, {:call, {:var, :f}, []}} = Analyze.analyze(raw) + end + + test "call with literal args" do + raw = {:list, [{:symbol, :+}, 1, 2]} + assert {:ok, {:call, {:var, :+}, [1, 2]}} = Analyze.analyze(raw) + end + + test "call with symbol args" do + raw = {:list, [{:symbol, :f}, {:symbol, :x}, {:symbol, :y}]} + + assert {:ok, + {:call, {:var, :f}, + [ + {:var, :x}, + {:var, :y} + ]}} = Analyze.analyze(raw) + end + + test "nested calls" do + raw = + {:list, + [ + {:symbol, :f}, + {:list, [{:symbol, :g}, 1]} + ]} + + assert {:ok, + {:call, {:var, :f}, + [ + {:call, {:var, :g}, [1]} + ]}} = Analyze.analyze(raw) + end + + test "function as keyword (map access)" do + raw = {:list, [{:keyword, :name}, {:symbol, :user}]} + + assert {:ok, + {:call, {:keyword, :name}, + [ + {:var, :user} + ]}} = Analyze.analyze(raw) + end + end + + describe "short-circuit logic" do + test "empty and" do + raw = {:list, [{:symbol, :and}]} + assert {:ok, {:and, []}} = Analyze.analyze(raw) + end + + test "and with expressions" do + raw = {:list, [{:symbol, :and}, true, false, 42]} + assert {:ok, {:and, [true, false, 42]}} = Analyze.analyze(raw) + end + + test "empty or" do + raw = {:list, [{:symbol, :or}]} + assert {:ok, {:or, []}} = Analyze.analyze(raw) + end + + test "or with expressions" do + raw = {:list, [{:symbol, :or}, nil, false, 42]} + assert {:ok, {:or, [nil, false, 42]}} = Analyze.analyze(raw) + end + + test "and/or with nested calls" do + raw = + {:list, + [ + {:symbol, :and}, + {:list, [{:symbol, :f}, {:symbol, :x}]}, + {:list, [{:symbol, :g}, {:symbol, :y}]} + ]} + + assert {:ok, + {:and, + [ + {:call, {:var, :f}, + [ + {:var, :x} + ]}, + {:call, {:var, :g}, + [ + {:var, :y} + ]} + ]}} = Analyze.analyze(raw) + end + end + + describe "let bindings" do + test "simple bindings" do + raw = {:list, [{:symbol, :let}, {:vector, [{:symbol, :x}, 1]}, {:symbol, :x}]} + assert {:ok, {:let, [{:binding, {:var, :x}, 1}], {:var, :x}}} = Analyze.analyze(raw) + end + + test "multiple bindings" do + raw = + {:list, [{:symbol, :let}, {:vector, [{:symbol, :x}, 1, {:symbol, :y}, 2]}, {:symbol, :y}]} + + assert {:ok, {:let, [{:binding, {:var, :x}, 1}, {:binding, {:var, :y}, 2}], {:var, :y}}} = + Analyze.analyze(raw) + end + + test "destructuring with :keys" do + raw = + {:list, + [ + {:symbol, :let}, + {:vector, + [ + {:map, [{{:keyword, :keys}, {:vector, [{:symbol, :a}, {:symbol, :b}]}}]}, + {:symbol, :m} + ]}, + {:symbol, :a} + ]} + + assert {:ok, + {:let, + [ + {:binding, {:destructure, {:keys, [:a, :b], []}}, {:var, :m}} + ], {:var, :a}}} = Analyze.analyze(raw) + end + + test "destructuring with :or defaults" do + raw = + {:list, + [ + {:symbol, :let}, + {:vector, + [ + {:map, + [ + {{:keyword, :keys}, {:vector, [{:symbol, :a}]}}, + {{:keyword, :or}, {:map, [{{:keyword, :a}, 10}]}} + ]}, + {:symbol, :m} + ]}, + {:symbol, :a} + ]} + + assert {:ok, + {:let, + [ + {:binding, {:destructure, {:keys, [:a], [a: 10]}}, {:var, :m}} + ], {:var, :a}}} = Analyze.analyze(raw) + end + + test "destructuring with :as alias" do + raw = + {:list, + [ + {:symbol, :let}, + {:vector, + [ + {:map, + [ + {{:keyword, :keys}, {:vector, [{:symbol, :x}]}}, + {{:keyword, :as}, {:symbol, :all}} + ]}, + {:symbol, :m} + ]}, + {:symbol, :all} + ]} + + assert {:ok, + {:let, + [ + {:binding, {:destructure, {:as, :all, {:destructure, {:keys, [:x], []}}}}, + {:var, :m}} + ], {:var, :all}}} = Analyze.analyze(raw) + end + + test "error case: odd binding count" do + raw = {:list, [{:symbol, :let}, {:vector, [{:symbol, :x}, 1, {:symbol, :y}]}, 100]} + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "even number" + end + + test "error case: non-vector bindings" do + raw = {:list, [{:symbol, :let}, 42, 100]} + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "vector" + end + end + + describe "if special form" do + test "basic if-then-else" do + raw = {:list, [{:symbol, :if}, true, 1, 2]} + assert {:ok, {:if, true, 1, 2}} = Analyze.analyze(raw) + end + + test "if with expression condition" do + raw = + {:list, + [ + {:symbol, :if}, + {:list, [{:symbol, :<}, {:symbol, :x}, 10]}, + {:string, "small"}, + {:string, "large"} + ]} + + assert {:ok, + {:if, {:call, {:var, :<}, [{:var, :x}, 10]}, {:string, "small"}, {:string, "large"}}} = + Analyze.analyze(raw) + end + + test "error case: wrong arity - too few arguments" do + raw = {:list, [{:symbol, :if}, true]} + assert {:error, {:invalid_arity, :if, msg}} = Analyze.analyze(raw) + assert msg =~ "expected" + end + + test "error case: wrong arity - too many arguments" do + raw = {:list, [{:symbol, :if}, true, 1, 2, 3]} + assert {:error, {:invalid_arity, :if, msg}} = Analyze.analyze(raw) + assert msg =~ "expected" + end + end + + describe "fn anonymous functions" do + test "single parameter" do + raw = {:list, [{:symbol, :fn}, {:vector, [{:symbol, :x}]}, {:symbol, :x}]} + assert {:ok, {:fn, [{:var, :x}], {:var, :x}}} = Analyze.analyze(raw) + end + + test "multiple parameters" do + raw = + {:list, + [ + {:symbol, :fn}, + {:vector, [{:symbol, :x}, {:symbol, :y}]}, + {:list, [{:symbol, :+}, {:symbol, :x}, {:symbol, :y}]} + ]} + + assert {:ok, {:fn, [{:var, :x}, {:var, :y}], {:call, {:var, :+}, [{:var, :x}, {:var, :y}]}}} = + Analyze.analyze(raw) + end + + test "error case: non-vector params" do + raw = {:list, [{:symbol, :fn}, 42, 100]} + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "vector" + end + + test "error case: destructuring pattern in params rejected" do + raw = + {:list, + [ + {:symbol, :fn}, + {:vector, [{:map, [{{:keyword, :keys}, {:vector, [{:symbol, :a}]}}]}]}, + 100 + ]} + + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "destructuring patterns" + end + end + + describe "empty list fails" do + test "empty list is invalid" do + raw = {:list, []} + assert {:error, {:invalid_form, msg}} = Analyze.analyze(raw) + assert msg =~ "Empty list" + end + end +end