This commit is contained in:
Kacper Marzecki 2025-06-20 16:19:02 +02:00
parent 8d8b3607fc
commit 3c7edc67da

243
new.exs
View File

@ -19,9 +19,8 @@ defmodule Tdd.Debug do
end end
defp remove_traced_pid(pid) when is_pid(pid) do defp remove_traced_pid(pid) when is_pid(pid) do
# Use Process.whereis to avoid race condition if agent stops between calls
case Process.whereis(@agent_name) do case Process.whereis(@agent_name) do
nil -> :ok # Agent not running, nothing to remove from nil -> :ok
agent_pid -> Agent.cast(agent_pid, fn state -> MapSet.delete(state, pid) end) agent_pid -> Agent.cast(agent_pid, fn state -> MapSet.delete(state, pid) end)
end end
end end
@ -34,8 +33,7 @@ defmodule Tdd.Debug do
try do try do
Agent.get(agent_pid, &MapSet.member?(&1, pid), :infinity) Agent.get(agent_pid, &MapSet.member?(&1, pid), :infinity)
rescue rescue
# Catches if agent dies or is not an agent anymore _e in [Exit, ArgumentError] -> false # More specific rescue
_e in [Exit, ArgumentError] -> false
end end
end end
end end
@ -44,11 +42,9 @@ defmodule Tdd.Debug do
@arg_length_limit 80 @arg_length_limit 80
@total_args_length_limit 200 @total_args_length_limit 200
# Helper function to return a unique atom for ignored arguments # MODIFIED: Return the specific atoms format_arg_value expects
# This is called from the macro-generated code. def __internal_placeholder_for_ignored_arg__, do: :__tdd_debug_ignored_arg__
def __internal_placeholder_for_ignored_arg__ do def __internal_placeholder_for_complex_pattern__, do: :__tdd_debug_complex_pattern__
:__tdd_debug_ignored_arg__ # A unique atom
end
def format_args_list(args_list) when is_list(args_list) do def format_args_list(args_list) when is_list(args_list) do
formatted_args = Enum.map(args_list, &format_arg_value/1) formatted_args = Enum.map(args_list, &format_arg_value/1)
@ -64,14 +60,13 @@ defmodule Tdd.Debug do
def format_arg_value(arg) do def format_arg_value(arg) do
inspected = inspected =
case arg do case arg do
# Check if it's our specific placeholder atom for `_`
:__tdd_debug_ignored_arg__ -> "_" :__tdd_debug_ignored_arg__ -> "_"
# For other arguments, inspect them normally. :__tdd_debug_complex_pattern__ -> "<pattern>"
# This will handle runtime values for simple variables/literals, _ -> inspect(arg, limit: @arg_length_limit, pretty: false, structs: true) # Use limit here too
# and ASTs for complex patterns (if that's what's passed).
_ -> inspect(arg, limit: :infinity, pretty: false, structs: true)
end end
# The inspect limit is now applied within the inspect call itself for individual args mostly.
# This outer limit is more for the overall string representation from inspect if it didn't truncate.
if String.length(inspected) > @arg_length_limit do if String.length(inspected) > @arg_length_limit do
String.slice(inspected, 0, @arg_length_limit - 3) <> "..." String.slice(inspected, 0, @arg_length_limit - 3) <> "..."
else else
@ -84,34 +79,29 @@ defmodule Tdd.Debug do
init() init()
pid_to_trace = self() pid_to_trace = self()
add_traced_pid(pid_to_trace) add_traced_pid(pid_to_trace)
# Trap exits only if not already trapping, to avoid interfering with other code.
# However, monitoring is generally safer and less intrusive.
# Process.flag(:trap_exit, true) # Consider if this is truly needed or if monitoring is enough.
ref = Process.monitor(pid_to_trace) ref = Process.monitor(pid_to_trace)
# Spawn a separate process to monitor. # Spawn linked to ensure monitor process dies if current process dies unexpectedly
# Use spawn_opt to avoid linking if this monitoring process crashes.
Process.spawn( Process.spawn(
fn -> fn ->
receive do receive do
{:DOWN, ^ref, :process, ^pid_to_trace, _reason} -> {:DOWN, ^ref, :process, ^pid_to_trace, _reason} ->
remove_traced_pid(pid_to_trace) remove_traced_pid(pid_to_trace)
after # after # Consider if timeout is strictly needed or if DOWN message is sufficient
# 1 hour timeout as a fallback # 3_600_000 -> # 1 hour timeout
3_600_000 -> # remove_traced_pid(pid_to_trace)
remove_traced_pid(pid_to_trace)
end end
end, end,
[:monitor] #:link option removed, monitor is explicit [:monitor]
# Removed [:monitor] option as spawn_link and Process.monitor achieve desired effect
) )
:ok :ok
end end
def disable_tracing() do def disable_tracing() do
init() # Ensure agent is available for removal if it was started by another call # init() # Not strictly necessary here as remove_traced_pid handles agent not existing
remove_traced_pid(self()) remove_traced_pid(self())
:ok # Good practice to return :ok :ok
end end
def run(fun) when is_function(fun, 0) do def run(fun) when is_function(fun, 0) do
@ -126,14 +116,14 @@ defmodule Tdd.Debug do
# --- Process Dictionary for Depth --- # --- Process Dictionary for Depth ---
defp get_depth, do: Process.get(:tdd_debug_depth, 0) defp get_depth, do: Process.get(:tdd_debug_depth, 0)
def increment_depth do def increment_depth do # Made private as it's an internal helper
new_depth = get_depth() + 1 new_depth = get_depth() + 1
Process.put(:tdd_debug_depth, new_depth) Process.put(:tdd_debug_depth, new_depth)
new_depth new_depth
end end
def decrement_depth do def decrement_depth do # Made private
new_depth = max(0, get_depth() - 1) # Ensure depth doesn't go below 0 new_depth = max(0, get_depth() - 1)
Process.put(:tdd_debug_depth, new_depth) Process.put(:tdd_debug_depth, new_depth)
new_depth new_depth
end end
@ -143,44 +133,51 @@ defmodule Tdd.Debug do
quote do quote do
import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1] import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1]
require Tdd.Debug require Tdd.Debug
# Import Tdd.Debug's def/defp macros and other public functions/macros import Tdd.Debug # Imports def/defp from this module, and helper functions
import Tdd.Debug
end end
end end
# The `do_instrument` macro seems to be an older/alternative version.
# The primary mechanism used by `def`/`defp` is `generate_traced_function`.
# We'll keep `do_instrument` as it was in your example, in case it's used elsewhere,
# but ensure it's not the cause of the current problem.
@doc false @doc false
defmacro do_instrument(type, call, clauses, _env) do defmacro do_instrument(type, call, clauses, _env) do
# As per your note, this macro might need updates.
# Main issues to check if used:
# 1. Hygiene of `actual_code_ast`: use Macro.escape/2.
# 2. Robustness of `arg_vars_for_runtime_access` for all pattern types (similar to generate_traced_function).
# Kept original logic for now based on your description.
{function_name, _meta_call, original_args_ast_nodes} = call {function_name, _meta_call, original_args_ast_nodes} = call
original_args_ast_nodes = original_args_ast_nodes || [] original_args_ast_nodes = original_args_ast_nodes || []
# This part was problematic if original_args_ast_nodes contained `_`
# and was used directly to form a list of expressions.
arg_vars_for_runtime_access = arg_vars_for_runtime_access =
Enum.map(original_args_ast_nodes, fn Enum.map(original_args_ast_nodes, fn
{:_, _, Elixir} -> quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end {:_, _, ctx} when is_atom(ctx) or is_nil(ctx) ->
{:=, _, [var_ast, _]} -> var_ast quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end
pattern_ast -> pattern_ast {:=, _, [var_ast, _]} ->
var_ast
{var_name, _meta, ctx} = pattern_ast when is_atom(var_name) and (is_atom(ctx) or is_nil(ctx)) and
not (var_name in [:_ , :%{}, :{}, :|, :<<>>, :fn, :->, :&, :^]) ->
pattern_ast
{constructor, _, _} = pattern_ast when constructor in [:%{}, :{}, :|, :<<>>] ->
quote do Tdd.Debug.__internal_placeholder_for_complex_pattern__() end
pattern_ast ->
pattern_ast
end) end)
actual_code_ast = actual_code_ast =
case clauses do case clauses do
# [do: block_content | _] -> block_content
kw when is_list(kw) -> Keyword.get(kw, :do) kw when is_list(kw) -> Keyword.get(kw, :do)
_ -> clauses _ -> clauses
end end
traced_body = traced_body =
quote location: :keep do quote location: :keep do
if is_pid_traced?(self()) do if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = increment_depth() current_print_depth = Tdd.Debug.increment_depth() # Qualified
indent = String.duplicate(" ", current_print_depth - 1) indent = String.duplicate(" ", current_print_depth - 1)
runtime_arg_values = [unquote_splicing(arg_vars_for_runtime_access)] runtime_arg_values = [unquote_splicing(arg_vars_for_runtime_access)]
args_string = format_args_list(runtime_arg_values) IO.inspect(runtime_arg_values, label: "runtime_arg_values")
IO.puts("runtime_arg_values 1")
args_string = Tdd.Debug.format_args_list(runtime_arg_values) # Qualified
caller_module_name = Module.split(__MODULE__) |> Enum.join(".") caller_module_name = Module.split(__MODULE__) |> Enum.join(".")
IO.puts( IO.puts(
@ -188,24 +185,26 @@ defmodule Tdd.Debug do
) )
try do try do
result = unquote(actual_code_ast) # Potential hygiene issue here if actual_code_ast clashes with macro vars
_ = decrement_depth() result = unquote(actual_code_ast) # Consider Macro.escape(actual_code_ast, unquote: true)
_ = Tdd.Debug.decrement_depth() # Qualified
IO.puts( IO.puts(
"#{indent}RETURN from #{caller_module_name}.#{unquote(Macro.escape(function_name))}: #{Tdd.Debug.format_arg_value(result)}" "#{indent}RETURN from #{caller_module_name}.#{unquote(Macro.escape(function_name))}: #{Tdd.Debug.format_arg_value(result)}" # Qualified
) )
result result
rescue rescue
exception_class -> exception_class ->
_ = decrement_depth() _ = Tdd.Debug.decrement_depth() # Qualified
error_instance = exception_class error_instance = exception_class
stacktrace = __STACKTRACE__ stacktrace = __STACKTRACE__
IO.puts( IO.puts(
"#{indent}ERROR in #{caller_module_name}.#{unquote(Macro.escape(function_name))}: #{Tdd.Debug.format_arg_value(error_instance)}" "#{indent}ERROR in #{caller_module_name}.#{unquote(Macro.escape(function_name))}: #{Tdd.Debug.format_arg_value(error_instance)}" # Qualified
) )
reraise error_instance, stacktrace reraise error_instance, stacktrace
end end
else else
unquote(actual_code_ast) IO.puts("runtime_arg_values 2")
unquote(actual_code_ast) # Consider Macro.escape
end end
end end
@ -215,68 +214,87 @@ defmodule Tdd.Debug do
end end
@doc false @doc false
defmacro def(call, clauses \\ Keyword.new()) do # Default clauses to Keyword.new() or [] defmacro def(call, clauses \\ Keyword.new()) do
generate_traced_function(:def, call, clauses, __CALLER__) generate_traced_function(:def, call, clauses, __CALLER__)
end end
@doc false @doc false
defmacro defp(call, clauses \\ Keyword.new()) do # Default clauses to Keyword.new() or [] defmacro defp(call, clauses \\ Keyword.new()) do
generate_traced_function(:defp, call, clauses, __CALLER__) generate_traced_function(:defp, call, clauses, __CALLER__)
end end
defp generate_traced_function(type, call_ast, clauses, _caller_env) do
# Capture __CALLER__ to ensure hygiene for variables generated by the macro {function_name_ast, _meta_call, original_args_patterns_ast} = call_ast
# if needed, though direct AST manipulation often bypasses some hygiene issues. args_patterns_ast = original_args_patterns_ast || []
defp generate_traced_function(type, call_ast, clauses, _caller_env) do
{function_name_ast, _meta_call, original_args_ast_nodes} = call_ast
args_patterns_ast = original_args_ast_nodes || []
original_body_ast = original_body_ast =
if Keyword.keyword?(clauses) do (if Keyword.keyword?(clauses) do
Keyword.get(clauses, :do, clauses) Keyword.get(clauses, :do, clauses) # clauses could be `[do: actual_body]` or just `actual_body`
else else
clauses clauses
end || quote(do: nil) # Default to an empty body if none provided end) || quote(do: nil) # Default to a nil body if nothing provided
# Transform argument patterns into expressions suitable for logging at runtime. # --- REVISED logging_expressions_ast_list ---
logging_expressions_ast_list = logging_expressions_ast_list =
Enum.map(args_patterns_ast, fn arg_pattern_ast -> Enum.map(args_patterns_ast, fn arg_pattern_ast_outer ->
case arg_pattern_ast do core_pattern_ast_for_logging =
{:=, _meta_assign, [var_ast, _sub_pattern_ast]} -> case arg_pattern_ast_outer do
{:when, _, [pattern, _guard]} -> pattern
other -> other
end
cond do
match?({:_, _, ctx} when is_atom(ctx) or is_nil(ctx), core_pattern_ast_for_logging) ->
quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end
match?({:=, _, [{_var_ast, _, _}, _sub_pattern_ast]}, core_pattern_ast_for_logging) ->
{:=, _, [var_ast, _]} = core_pattern_ast_for_logging
var_ast var_ast
_ ->
Macro.postwalk(arg_pattern_ast, fn match?({constructor, _, _args} when constructor in [:%{}, :{}, :|, :<<>>], core_pattern_ast_for_logging) ->
current_node_ast -> quote do Tdd.Debug.__internal_placeholder_for_complex_pattern__() end
case current_node_ast do
# Match any underscore, regardless of context if it's a bare underscore node match?({name_atom, _, context} when is_atom(name_atom) and name_atom != :_ and (is_atom(context) or is_nil(context)), core_pattern_ast_for_logging) ->
{:_, _, context} when is_atom(context) -> # context is often Elixir or nil {name_atom_inner, _meta, context_inner} = core_pattern_ast_for_logging
quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end if is_nil(context_inner) and name_atom_inner not in [:true, :false, :nil] do
_ -> name_atom_inner
current_node_ast else
end core_pattern_ast_for_logging
end) end
# Macro.is_literal(core_pattern_ast_for_logging) ->
# core_pattern_ast_for_logging
# Fallback for unhandled complex patterns or other ASTs.
# If it's a variable AST that somehow slipped through (e.g. context wasn't atom/nil for a var),
# it might be okay to pass `core_pattern_ast_for_logging` directly.
# However, to be safe and avoid `_` or other non-value ASTs, placeholder is better.
true ->
# This case implies a pattern that is not `_`, not `var=pattern`, not a common structure,
# not a simple var/literal atom, and not a known literal.
# It's likely a more complex pattern we haven't explicitly handled for logging.
quote do Tdd.Debug.__internal_placeholder_for_complex_pattern__() end
end end
end) end)
# --- END REVISED logging_expressions_ast_list ---
# This is the AST for the code that will become the *actual body* of the traced function.
traced_body_inner_ast = traced_body_inner_ast =
quote do quote do
if is_pid_traced?(self()) do if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = increment_depth() current_print_depth = Tdd.Debug.increment_depth() # Qualified
indent = String.duplicate(" ", current_print_depth - 1) indent = String.duplicate(" ", current_print_depth - 1)
# Note: Macro.escape is used here because logging_expressions_ast_list
# and function_name_ast are ASTs being injected into this quote.
__runtime_arg_values_for_logging__ = __runtime_arg_values_for_logging__ =
[unquote_splicing(Macro.escape(logging_expressions_ast_list, unquote: true))] [unquote_splicing(logging_expressions_ast_list)]
args_string = format_args_list(__runtime_arg_values_for_logging__) args_string = Tdd.Debug.format_args_list(__runtime_arg_values_for_logging__) # Qualified
caller_module_name_str = Module.split(__MODULE__) |> Enum.join(".") caller_module_name_str = Module.split(__MODULE__) |> Enum.join(".")
__Printable_fn_name_intermediate__ = unquote(function_name_ast)
printable_function_name_str = printable_function_name_str =
case unquote(Macro.escape(function_name_ast, unquote: true)) do case __Printable_fn_name_intermediate__ do
fn_name_atom when is_atom(fn_name_atom) -> Atom.to_string(fn_name_atom) fn_name_atom when is_atom(fn_name_atom) -> Atom.to_string(fn_name_atom)
fn_name_ast_complex -> Macro.to_string(fn_name_ast_complex) fn_name_ast_complex -> Macro.to_string(fn_name_ast_complex) # Handles operators etc.
end end
IO.puts( IO.puts(
@ -284,58 +302,55 @@ defmodule Tdd.Debug do
) )
try do try do
# Original body is injected here, also escaped.
result = unquote(Macro.escape(original_body_ast, unquote: true)) result = unquote(Macro.escape(original_body_ast, unquote: true))
_ = Tdd.Debug.decrement_depth() _ = Tdd.Debug.decrement_depth() # Qualified
IO.puts( IO.puts(
"#{indent}RETURN from #{caller_module_name_str}.#{printable_function_name_str}: #{Tdd.Debug.format_arg_value(result)}" "#{indent}RETURN from #{caller_module_name_str}.#{printable_function_name_str}: #{Tdd.Debug.format_arg_value(result)}" # Qualified
) )
result result
rescue rescue
exception_class -> exception_class ->
_ = Tdd.Debug.decrement_depth() _ = Tdd.Debug.decrement_depth() # Qualified
error_instance = exception_class error_instance = exception_class
stacktrace = __STACKTRACE__ stacktrace = __STACKTRACE__
IO.puts( IO.puts(
"#{indent}ERROR in #{caller_module_name_str}.#{printable_function_name_str}: #{Tdd.Debug.format_arg_value(error_instance)}" "#{indent}ERROR in #{caller_module_name_str}.#{printable_function_name_str}: #{Tdd.Debug.format_arg_value(error_instance)}" # Qualified
) )
reraise error_instance, stacktrace reraise error_instance, stacktrace
end end
else else
# Tracing not enabled, execute original body directly (escaped).
unquote(Macro.escape(original_body_ast, unquote: true)) unquote(Macro.escape(original_body_ast, unquote: true))
end end
end end
# Construct the final `Kernel.def` or `Kernel.defp` call.
# `call_ast` is the original function head.
# `traced_body_inner_ast` is the AST for the body we just constructed.
IO.inspect(call_ast, label: "call_ast")
IO.inspect(Macro.escape(call_ast, unquote: true), label: "Macro.escape(call_ast, unquote: true)")
final_definition_ast = final_definition_ast =
quote location: :keep do # unquote: false is default and implied if not set quote location: :keep do
Kernel.unquote(type)( Kernel.unquote(type)(
unquote(call_ast), unquote(call_ast), # This unquotes the function head {name, meta, args_patterns}
do: unquote(traced_body_inner_ast) # The body AST is passed via `do:` do: unquote(traced_body_inner_ast)
) )
end end
# Uncomment for debugging the generated code: # For debugging the macro itself:
# IO.inspect(Macro.to_string(final_definition_ast), label: "Generated for #{Macro.to_string(call_ast)}") # require Logger
# Logger.debug("Generated AST for #{type} #{Macro.to_string(call_ast)}:\n#{Macro.to_string(final_definition_ast)}")
final_definition_ast final_definition_ast
end end
# --- Your TDD Graph Printing (unrelated to the tracing error, kept for completeness) --- # --- TDD Graph Printing (Kept for completeness) ---
@doc "Prints a formatted representation of a TDD graph starting from an ID." @doc "Prints a formatted representation of a TDD graph starting from an ID."
def print(id) do def print(id) do
# It's better to alias Tdd.Store at the top of the module if consistently used,
# or pass it as an argument if it's a dependency.
# For now, keeping alias local to this function if Tdd.Store is not used elsewhere in Debug.
alias Tdd.Store # Assuming Tdd.Store is available alias Tdd.Store # Assuming Tdd.Store is available
IO.puts("--- TDD Graph (ID: #{id}) ---") IO.puts("--- TDD Graph (ID: #{id}) ---")
do_print(id, 0, MapSet.new()) do_print(id, 0, MapSet.new(), Tdd.Store) # Pass Store if it's an external module
IO.puts("------------------------") IO.puts("------------------------")
end end
defp do_print(id, indent_level, visited) do defp do_print(id, indent_level, visited, store_module) do # Accept store_module
prefix = String.duplicate(" ", indent_level) prefix = String.duplicate(" ", indent_level)
if MapSet.member?(visited, id) do if MapSet.member?(visited, id) do
@ -343,8 +358,8 @@ defmodule Tdd.Debug do
:ok :ok
else else
new_visited = MapSet.put(visited, id) new_visited = MapSet.put(visited, id)
alias Tdd.Store # Assuming Tdd.Store is available # alias Tdd.Store # Removed from here
case Tdd.Store.get_node(id) do case store_module.get_node(id) do # Use passed module
{:ok, :true_terminal} -> {:ok, :true_terminal} ->
IO.puts("#{prefix}ID #{id} -> TRUE") IO.puts("#{prefix}ID #{id} -> TRUE")
{:ok, :false_terminal} -> {:ok, :false_terminal} ->
@ -352,11 +367,11 @@ defmodule Tdd.Debug do
{:ok, {var, y, n, d}} -> {:ok, {var, y, n, d}} ->
IO.puts("#{prefix}ID #{id}: IF #{inspect(var)}") IO.puts("#{prefix}ID #{id}: IF #{inspect(var)}")
IO.puts("#{prefix} ├─ Yes:") IO.puts("#{prefix} ├─ Yes:")
do_print(y, indent_level + 2, new_visited) do_print(y, indent_level + 2, new_visited, store_module)
IO.puts("#{prefix} ├─ No:") IO.puts("#{prefix} ├─ No:")
do_print(n, indent_level + 2, new_visited) do_print(n, indent_level + 2, new_visited, store_module)
IO.puts("#{prefix} └─ DC:") IO.puts("#{prefix} └─ DC:")
do_print(d, indent_level + 2, new_visited) do_print(d, indent_level + 2, new_visited, store_module)
{:error, reason} -> {:error, reason} ->
IO.puts("#{prefix}ID #{id}: ERROR - #{reason}") IO.puts("#{prefix}ID #{id}: ERROR - #{reason}")
end end
@ -1846,8 +1861,8 @@ defmodule Tdd.Compiler do
end end
end end
defp is_recursive_spec?({:list_of, _}), do: true defp is_recursive_spec?({:list_of, _a}), do: true
defp is_recursive_spec?(_), do: false defp is_recursive_spec?(_a), 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 defp compile_non_recursive_spec(spec, context) do