diff --git a/debug.md b/debug.md index 4d5fcfb..91701b4 100644 --- a/debug.md +++ b/debug.md @@ -2,7 +2,9 @@ i have a compiler Im writing in elixir. I need to trace execution of logic to p I have many tests I want to debug individually but I can run only the full test suite. I want to create a couple of macros/functions that'd enable me to debug my code. the scenario I imagine: -before the test I want to debug I write `Tdd.Debug.enable` -And after the test line I add `Tdd.Debug.print_and_disable`. -The last line prints a tree of called functions, their arguments and return values. -We can modify compiler functions. +before the test I want to debug I write `Tdd.Debug.enable` and then : +as the code is being executed, it prints out a tree of called functions, their arguments and return values, effectively building a stacktrace as its being executed. +first lets design how we'd print this information and if/what should we store in memory to be able to render it in a shape sufficient for good debugging. +Then lets design an elixir module that compiler modules would 'use' +e.g. a Tdd.Debug module that'd have and override for elixir `def`. + diff --git a/new.exs b/new.exs index 1c7c9c1..18471b8 100644 --- a/new.exs +++ b/new.exs @@ -1,9 +1,267 @@ defmodule Tdd.Debug do - @moduledoc "Helpers for debugging TDD structures." - alias Tdd.Store + @moduledoc "Helpers for debugging TDD structures and tracing function calls." + # alias Tdd.Store # Keep if used by your print functions + @agent_name Tdd.Debug.StateAgent + + # --- Agent State Management --- + def init do + case Process.whereis(@agent_name) do + nil -> Agent.start_link(fn -> MapSet.new() end, name: @agent_name) + _pid -> :ok + end + + :ok + end + + defp add_traced_pid(pid) when is_pid(pid) do + init() + Agent.update(@agent_name, &MapSet.put(&1, pid)) + end + + defp remove_traced_pid(pid) when is_pid(pid) do + if agent_pid = Agent.whereis(@agent_name) do + Agent.cast(agent_pid, fn state -> MapSet.delete(state, pid) end) + end + end + + defp is_pid_traced?(pid) when is_pid(pid) do + case Agent.whereis(@agent_name) do + nil -> + false + + agent_pid -> + try do + Agent.get(agent_pid, &MapSet.member?(&1, pid), :infinity) + rescue + _e in [Exit, ArgumentError] -> false + end + end + end + + # --- Formatting Helpers --- + @arg_length_limit 80 + @total_args_length_limit 200 + + defp format_args_list(args_list) when is_list(args_list) do + formatted_args = Enum.map(args_list, &format_arg_value/1) + combined = Enum.join(formatted_args, ", ") + + if String.length(combined) > @total_args_length_limit do + String.slice(combined, 0, @total_args_length_limit - 3) <> "..." + else + combined + end + end + + defp format_arg_value(arg) do + inspected = inspect(arg, limit: :infinity, pretty: false, structs: true) + + if String.length(inspected) > @arg_length_limit do + String.slice(inspected, 0, @arg_length_limit - 3) <> "..." + else + inspected + end + end + + # --- Tracing Control --- + def enable_tracing do + init() + pid_to_trace = self() + add_traced_pid(pid_to_trace) + Process.flag(:trap_exit, true) + ref = Process.monitor(pid_to_trace) + + Process.spawn(fn -> + receive do + {:DOWN, ^ref, :process, ^pid_to_trace, _reason} -> remove_traced_pid(pid_to_trace) + after + 3_600_000 -> remove_traced_pid(pid_to_trace) + end + end) + + :ok + end + + def disable_tracing() do + # Ensure agent is available for removal + init() + remove_traced_pid(self()) + end + + def run(fun) when is_function(fun, 0) do + enable_tracing() + + try do + fun.() + after + disable_tracing() + end + end + + # --- Process Dictionary for Depth --- + defp get_depth do + Process.get(:tdd_debug_depth, 0) + end + + defp increment_depth do + new_depth = get_depth() + 1 + Process.put(:tdd_debug_depth, new_depth) + new_depth + end + + defp decrement_depth do + new_depth = max(0, get_depth() - 1) + Process.put(:tdd_debug_depth, new_depth) + new_depth + end + + # --- Core Macro Logic --- + defmacro __using__(_opts) do + quote do + # Make Tdd.Debug functions (like do_instrument, format_args_list) callable + # from the macros we are about to define locally in the user's module. + import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1] + require Tdd.Debug + import Tdd.Debug + end + end + + # This is an internal macro helper, not intended for direct user call + @doc false + defmacro do_instrument(type, call, clauses, env) do + # CORRECTED: Decompose the function call AST + {function_name, _meta_call, original_args_ast_nodes} = call + # Ensure it's a list for `def foo` vs `def foo()` + original_args_ast_nodes = original_args_ast_nodes || [] + + # CORRECTED: Generate argument variable ASTs for runtime access. + # These vars will hold the actual values of arguments after pattern matching. + arg_vars_for_runtime_access = original_args_ast_nodes + + actual_code_ast = + case clauses do + [do: block_content] -> block_content + kw when is_list(kw) -> Keyword.get(kw, :do) + # `do: :atom` or `do: variable` + _ -> clauses + end + + # Keep original line numbers for stacktraces from user code + traced_body = + quote location: :keep do + if Tdd.Debug.is_pid_traced?(self()) do + current_print_depth = Tdd.Debug.increment_depth() + indent = String.duplicate(" ", current_print_depth - 1) + + # CORRECTED: Use the generated vars for runtime access to get actual argument values. + # We unquote_splicing them into a list literal. + runtime_arg_values = arg_vars_for_runtime_access + args_string = Tdd.Debug.format_args_list(runtime_arg_values) + + # __MODULE__ here refers to the module where `use Tdd.Debug` is called. + caller_module_name = Module.split(__MODULE__) |> Enum.join(".") + + IO.puts( + "#{indent}CALL: #{caller_module_name}.#{unquote(function_name)}(#{args_string})" + ) + + try do + # Execute original function body + result = unquote(actual_code_ast) + _ = Tdd.Debug.decrement_depth() + + IO.puts( + "#{indent}RETURN from #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(result)}" + ) + + result + rescue + exception_class -> + _ = Tdd.Debug.decrement_depth() + error_instance = __catch__(exception_class) + stacktrace = __STACKTRACE__ + + IO.puts( + "#{indent}ERROR in #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(error_instance)}" + ) + + reraise error_instance, stacktrace + end + else + # Tracing not enabled, execute original code + unquote(actual_code_ast) + end + end + + # Construct the final definition (def or defp) using Kernel's versions + quote do + Kernel.unquote(type)(unquote(call), do: unquote(traced_body)) + end + end + + # Define the overriding def/defp macros directly in the user's module. + # These will take precedence over Kernel.def/defp. + @doc false +defmacro def(call, clauses \\ nil) do + generate_traced_function(:def, call, clauses) +end + +defmacro defp(call, clauses \\ nil) do + generate_traced_function(:defp, call, clauses) +end + +def generate_traced_function(type, call, clauses) do + {function_name, _meta_call, original_args_ast_nodes} = call + args = original_args_ast_nodes || [] + + block_ast = + case clauses do + [do: block_content] -> block_content + kw when is_list(kw) -> Keyword.get(kw, :do) + _ -> clauses + end + + quote location: :keep do + Kernel.unquote(type)(unquote(call)) do + if Tdd.Debug.is_pid_traced?(self()) do + current_print_depth = Tdd.Debug.increment_depth() + indent = String.duplicate(" ", current_print_depth - 1) + args_string = Tdd.Debug.format_args_list([unquote_splicing(args)]) + caller_module_name = Module.split(__MODULE__) |> Enum.join(".") + + IO.puts("#{indent}CALL: #{caller_module_name}.#{unquote(function_name)}(#{args_string})") + + try do + result = unquote(block_ast) + _ = Tdd.Debug.decrement_depth() + + IO.puts("#{indent}RETURN from #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(result)}") + result + rescue + exception_class -> + _ = Tdd.Debug.decrement_depth() + error_instance = __catch__(exception_class) + stacktrace = __STACKTRACE__ + + IO.puts("#{indent}ERROR in #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(error_instance)}") + reraise error_instance, stacktrace + end + else + unquote(block_ast) + end + end + end +end + # REMOVE the defmacro def/2 and defmacro defp/2 from here. + # They are now defined by the __using__ macro. + + # --- Your TDD Graph Printing (unrelated to the tracing error, kept for completeness) --- @doc "Prints a formatted representation of a TDD graph starting from an ID." def print(id) do + # Assuming Tdd.Store is defined elsewhere and aliased if needed + # Or ensure it's globally available if defined in another file + alias Tdd.Store IO.puts("--- TDD Graph (ID: #{id}) ---") do_print(id, 0, MapSet.new()) IO.puts("------------------------") @@ -17,8 +275,8 @@ defmodule Tdd.Debug do :ok else new_visited = MapSet.put(visited, id) - - case Store.get_node(id) do + # Assuming Tdd.Store.get_node/1 is available + case Tdd.Store.get_node(id) do {:ok, :true_terminal} -> IO.puts("#{prefix}ID #{id} -> TRUE") @@ -163,11 +421,10 @@ defmodule Tdd.TypeSpec do normalized = normalize(member) case normalized, - do: - ( - {:union, sub_members} -> sub_members - _ -> [normalized] - ) + do: ( + {:union, sub_members} -> sub_members + _ -> [normalized] + ) end) # 2. Apply simplification rules @@ -518,7 +775,7 @@ defmodule Tdd.Store do :ok end -@doc """ + @doc """ Creates a unique, temporary placeholder node for a recursive spec. Returns the ID of this placeholder. """ @@ -971,11 +1228,10 @@ defmodule Tdd.Consistency.Engine do end) case result, - do: - ( - :invalid -> :error - _ -> :ok - ) + do: ( + :invalid -> :error + _ -> :ok + ) end defp narrow_range(min, max) do @@ -1219,13 +1475,18 @@ defmodule Tdd.Algo do end end end -@doc """ + + @doc """ Recursively traverses a TDD graph from `root_id`, creating a new graph where all references to `from_id` are replaced with `to_id`. This is a pure function used to "tie the knot" in recursive type compilation. """ - @spec substitute(root_id :: non_neg_integer(), from_id :: non_neg_integer(), to_id :: non_neg_integer()) :: + @spec substitute( + root_id :: non_neg_integer(), + from_id :: non_neg_integer(), + to_id :: non_neg_integer() + ) :: non_neg_integer() def substitute(root_id, from_id, to_id) do # Handle the trivial case where the root is the node to be replaced. @@ -1244,8 +1505,11 @@ defmodule Tdd.Algo do result_id = case Store.get_node(root_id) do # Terminal nodes are unaffected unless they are the target of substitution. - {:ok, :true_terminal} -> Store.true_node_id() - {:ok, :false_terminal} -> Store.false_node_id() + {:ok, :true_terminal} -> + Store.true_node_id() + + {:ok, :false_terminal} -> + Store.false_node_id() # For internal nodes, recursively substitute in all children. {:ok, {var, y, n, d}} -> @@ -1256,13 +1520,14 @@ defmodule Tdd.Algo do # This case should not be hit if the graph is well-formed. {:error, reason} -> - raise "substitute encountered an error getting node #{root_id}: #{reason}" + raise "substitute encountered an error getting node #{root_id}: #{reason}" end Store.put_op_cache(cache_key, result_id) result_id end end + # defp do_simplify(tdd_id, assumptions) do # IO.inspect([tdd_id, assumptions], label: "do_simplify(tdd_id, assumptions)") # # First, check if the current assumption set is already a contradiction. @@ -1452,12 +1717,15 @@ defmodule Tdd.TypeReconstructor do end defmodule Tdd.Compiler do + use Tdd.Debug + @moduledoc "Compiles a `TypeSpec` into a canonical TDD ID." alias Tdd.TypeSpec alias Tdd.Variable alias Tdd.Store alias Tdd.Algo - @doc """ + + @doc """ The main public entry point. Takes a spec and returns its TDD ID. This now delegates to a private function with a context for recursion. """ @@ -1466,9 +1734,10 @@ defmodule Tdd.Compiler do # Start with an empty context map. spec_to_id(spec, %{}) end - # This is the new core compilation function with a context map. + + # This is the new core compilation function with a context map. # The context tracks `{spec => placeholder_id}` for in-progress compilations. -defp spec_to_id(spec, context) do + defp spec_to_id(spec, context) do normalized_spec = TypeSpec.normalize(spec) cache_key = {:spec_to_id, normalized_spec} @@ -1494,10 +1763,11 @@ defp spec_to_id(spec, context) do end end end - defp is_recursive_spec?({:list_of, _}), do: true + + defp is_recursive_spec?({:list_of, _}), do: true defp is_recursive_spec?(_), do: false - # NEW: The logic for simple, non-recursive types. + # NEW: The logic for simple, non-recursive types. defp compile_non_recursive_spec(spec, context) do # Just compile the body directly. Pass context in case it contains recursive children. raw_id = do_spec_to_id(spec, context) @@ -1530,21 +1800,41 @@ defp spec_to_id(spec, context) do Store.put_op_cache({:spec_to_id, spec}, simplified_id) simplified_id end + # This helper does the raw, structural compilation. # It now takes and passes down the context. defp do_spec_to_id(spec, context) do case spec do # Pass context on all recursive calls to spec_to_id/2 - :any -> Store.true_node_id() - :none -> Store.false_node_id() - :atom -> create_base_type_tdd(Variable.v_is_atom()) - :integer -> create_base_type_tdd(Variable.v_is_integer()) - :list -> create_base_type_tdd(Variable.v_is_list()) - :tuple -> create_base_type_tdd(Variable.v_is_tuple()) - {:literal, val} when is_atom(val) -> compile_value_equality(:atom, Variable.v_atom_eq(val), context) - {:literal, val} when is_integer(val) -> compile_value_equality(:integer, Variable.v_int_eq(val), context) - {:literal, []} -> compile_value_equality(:list, Variable.v_list_is_empty(), context) - {:integer_range, min, max} -> compile_integer_range(min, max, context) + :any -> + Store.true_node_id() + + :none -> + Store.false_node_id() + + :atom -> + create_base_type_tdd(Variable.v_is_atom()) + + :integer -> + create_base_type_tdd(Variable.v_is_integer()) + + :list -> + create_base_type_tdd(Variable.v_is_list()) + + :tuple -> + create_base_type_tdd(Variable.v_is_tuple()) + + {:literal, val} when is_atom(val) -> + compile_value_equality(:atom, Variable.v_atom_eq(val), context) + + {:literal, val} when is_integer(val) -> + compile_value_equality(:integer, Variable.v_int_eq(val), context) + + {:literal, []} -> + compile_value_equality(:list, Variable.v_list_is_empty(), context) + + {:integer_range, min, max} -> + compile_integer_range(min, max, context) {:union, specs} -> Enum.map(specs, &spec_to_id(&1, context)) @@ -1586,9 +1876,16 @@ defp spec_to_id(spec, context) do # --- Private Helpers --- - defp create_base_type_tdd(var), do: Store.find_or_create_node(var, Store.true_node_id(), Store.false_node_id(), Store.false_node_id()) + defp create_base_type_tdd(var), + do: + Store.find_or_create_node( + var, + Store.true_node_id(), + Store.false_node_id(), + Store.false_node_id() + ) - defp compile_value_equality(base_type_spec, value_var, context) do + defp compile_value_equality(base_type_spec, value_var, context) do eq_node = create_base_type_tdd(value_var) base_node_id = spec_to_id(base_type_spec, context) Algo.apply(:intersect, &op_intersect_terminals/2, base_node_id, eq_node) @@ -1608,15 +1905,20 @@ defp spec_to_id(spec, context) do end end -defp compile_cons_from_ids(h_id, t_id, context) do # Pass context + # Pass context + defp compile_cons_from_ids(h_id, t_id, context) do # Build `list & !is_empty` manually and safely. id_list = create_base_type_tdd(Variable.v_is_list()) id_is_empty = create_base_type_tdd(Variable.v_list_is_empty()) id_not_is_empty = Algo.negate(id_is_empty) - non_empty_list_id = Algo.apply(:intersect, &op_intersect_terminals/2, id_list, id_not_is_empty) - head_checker = sub_problem(&Variable.v_list_head_pred/1, h_id, context) # Pass context - tail_checker = sub_problem(&Variable.v_list_tail_pred/1, t_id, context) # Pass context + non_empty_list_id = + Algo.apply(:intersect, &op_intersect_terminals/2, id_list, id_not_is_empty) + + # Pass context + head_checker = sub_problem(&Variable.v_list_head_pred/1, h_id, context) + # Pass context + tail_checker = sub_problem(&Variable.v_list_tail_pred/1, t_id, context) [non_empty_list_id, head_checker, tail_checker] |> Enum.reduce(Store.true_node_id(), fn id, acc -> @@ -1624,7 +1926,7 @@ defp compile_cons_from_ids(h_id, t_id, context) do # Pass context end) end -defp compile_tuple(elements, context) do + defp compile_tuple(elements, context) do size = length(elements) base_id = spec_to_id(:tuple, context) size_tdd = create_base_type_tdd(Variable.v_tuple_size_eq(size)) @@ -1635,65 +1937,66 @@ defp compile_tuple(elements, context) do |> Enum.reduce(initial_id, fn {elem_spec, index}, acc_id -> elem_id = spec_to_id(elem_spec) elem_key_constructor = &Variable.v_tuple_elem_pred(index, &1) - elem_checker = sub_problem(elem_key_constructor, elem_id, context) # Pass context + # Pass context + elem_checker = sub_problem(elem_key_constructor, elem_id, context) Algo.apply(:intersect, &op_intersect_terminals/2, acc_id, elem_checker) end) end -defp do_sub_problem(sub_key_constructor, tdd_id, context) do - # The `if` block is now a standard multi-clause `cond` or `case` at the top. - # Let's use a `cond` to make the guard explicit. - cond do - # Guard against invalid IDs from placeholder children. - tdd_id < 0 -> - Store.false_node_id() + defp do_sub_problem(sub_key_constructor, tdd_id, context) do + # The `if` block is now a standard multi-clause `cond` or `case` at the top. + # Let's use a `cond` to make the guard explicit. + cond do + # Guard against invalid IDs from placeholder children. + tdd_id < 0 -> + Store.false_node_id() - # If it's a valid ID, proceed with the main logic. - true -> - case Store.get_node(tdd_id) do - {:ok, :true_terminal} -> - Store.true_node_id() + # If it's a valid ID, proceed with the main logic. + true -> + case Store.get_node(tdd_id) do + {:ok, :true_terminal} -> + Store.true_node_id() - {:ok, :false_terminal} -> - Store.false_node_id() + {:ok, :false_terminal} -> + Store.false_node_id() - # Handle placeholders by operating on their spec, not their TDD structure. - {:ok, {{:placeholder, spec}, _, _, _}} -> - dummy_var = sub_key_constructor.(:dummy) + # Handle placeholders by operating on their spec, not their TDD structure. + {:ok, {{:placeholder, spec}, _, _, _}} -> + dummy_var = sub_key_constructor.(:dummy) - case dummy_var do - {5, :c_head, :dummy, _} -> - {:list_of, element_spec} = spec - spec_to_id(element_spec, context) + case dummy_var do + {5, :c_head, :dummy, _} -> + {:list_of, element_spec} = spec + spec_to_id(element_spec, context) - {5, :d_tail, :dummy, _} -> - tdd_id + {5, :d_tail, :dummy, _} -> + tdd_id - {4, :b_element, index, :dummy} -> - {:tuple, elements} = spec - spec_to_id(Enum.at(elements, index), context) + {4, :b_element, index, :dummy} -> + {:tuple, elements} = spec + spec_to_id(Enum.at(elements, index), context) - _ -> - raise "sub_problem encountered an unhandled recursive predicate on a placeholder: #{inspect(dummy_var)}" - end + _ -> + raise "sub_problem encountered an unhandled recursive predicate on a placeholder: #{inspect(dummy_var)}" + end - # The normal, non-placeholder case - {:ok, {var, y, n, d}} -> - Store.find_or_create_node( - sub_key_constructor.(var), - sub_problem(sub_key_constructor, y, context), - sub_problem(sub_key_constructor, n, context), - sub_problem(sub_key_constructor, d, context) - ) + # The normal, non-placeholder case + {:ok, {var, y, n, d}} -> + Store.find_or_create_node( + sub_key_constructor.(var), + sub_problem(sub_key_constructor, y, context), + sub_problem(sub_key_constructor, n, context), + sub_problem(sub_key_constructor, d, context) + ) - # This case should now be unreachable. - {:error, :not_found} -> - raise "sub_problem received an unknown tdd_id: #{tdd_id}" - end + # This case should now be unreachable. + {:error, :not_found} -> + raise "sub_problem received an unknown tdd_id: #{tdd_id}" + end + end end -end - defp sub_problem(sub_key_constructor, tdd_id, context) do + defp sub_problem(sub_key_constructor, tdd_id, context) do cache_key = {:sub_problem, sub_key_constructor, tdd_id} # Note: context is not part of the cache key. This is a simplification that # assumes the result of a sub_problem is independent of the wider compilation @@ -1732,32 +2035,35 @@ end # end # THIS IS THE FINAL, CORRECTED LOOP -defp loop_until_stable(prev_id, step_function, iteration \\ 0) do - IO.puts("\n--- Fixed-Point Iteration: #{iteration} ---") - IO.inspect(prev_id, label: "prev_id") - # Tdd.Debug.print(prev_id) # Optional: uncomment for full graph + defp loop_until_stable(prev_id, step_function, iteration \\ 0) do + IO.puts("\n--- Fixed-Point Iteration: #{iteration} ---") + IO.inspect(prev_id, label: "prev_id") + # Tdd.Debug.print(prev_id) # Optional: uncomment for full graph - raw_next_id = step_function.(prev_id) - IO.inspect(raw_next_id, label: "raw_next_id (after step_function)") - Tdd.Debug.print(raw_next_id) # Let's see the raw graph + raw_next_id = step_function.(prev_id) + IO.inspect(raw_next_id, label: "raw_next_id (after step_function)") + # Let's see the raw graph + Tdd.Debug.print(raw_next_id) - # Crucially, simplify after each step for canonical comparison. - next_id = Algo.simplify(raw_next_id) - IO.inspect(next_id, label: "next_id (after simplify)") - Tdd.Debug.print(next_id) # Let's see the simplified graph + # Crucially, simplify after each step for canonical comparison. + next_id = Algo.simplify(raw_next_id) + IO.inspect(next_id, label: "next_id (after simplify)") + # Let's see the simplified graph + Tdd.Debug.print(next_id) - if next_id == prev_id do - IO.puts("--- Fixed-Point Reached! ---") - next_id - else - # Add a safety break for debugging - if iteration > 2 do - IO.inspect Process.info(self(), :current_stacktrace) - raise "Fixed-point iteration did not converge after 2 steps. Halting." + if next_id == prev_id do + IO.puts("--- Fixed-Point Reached! ---") + next_id + else + # Add a safety break for debugging + if iteration > 2 do + IO.inspect(Process.info(self(), :current_stacktrace)) + raise "Fixed-point iteration did not converge after 2 steps. Halting." + end + + loop_until_stable(next_id, step_function, iteration + 1) end - loop_until_stable(next_id, step_function, iteration + 1) end -end # --- Private Functions for Terminal Logic --- defp op_union_terminals(:true_terminal, _), do: :true_terminal @@ -3052,6 +3358,9 @@ defmodule TddCompilerRecursiveTests do end end end + +# Ensure the tracing state manager is started +Tdd.Debug.init() Process.sleep(100) # To run this new test, add the following to your main test runner script: # TddCompilerRecursiveTests.run()