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

241
new.exs
View File

@ -19,9 +19,8 @@ defmodule Tdd.Debug do
end
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
nil -> :ok # Agent not running, nothing to remove from
nil -> :ok
agent_pid -> Agent.cast(agent_pid, fn state -> MapSet.delete(state, pid) end)
end
end
@ -34,8 +33,7 @@ defmodule Tdd.Debug do
try do
Agent.get(agent_pid, &MapSet.member?(&1, pid), :infinity)
rescue
# Catches if agent dies or is not an agent anymore
_e in [Exit, ArgumentError] -> false
_e in [Exit, ArgumentError] -> false # More specific rescue
end
end
end
@ -44,11 +42,9 @@ defmodule Tdd.Debug do
@arg_length_limit 80
@total_args_length_limit 200
# Helper function to return a unique atom for ignored arguments
# This is called from the macro-generated code.
def __internal_placeholder_for_ignored_arg__ do
:__tdd_debug_ignored_arg__ # A unique atom
end
# MODIFIED: Return the specific atoms format_arg_value expects
def __internal_placeholder_for_ignored_arg__, do: :__tdd_debug_ignored_arg__
def __internal_placeholder_for_complex_pattern__, do: :__tdd_debug_complex_pattern__
def format_args_list(args_list) when is_list(args_list) do
formatted_args = Enum.map(args_list, &format_arg_value/1)
@ -64,14 +60,13 @@ defmodule Tdd.Debug do
def format_arg_value(arg) do
inspected =
case arg do
# Check if it's our specific placeholder atom for `_`
:__tdd_debug_ignored_arg__ -> "_"
# For other arguments, inspect them normally.
# This will handle runtime values for simple variables/literals,
# and ASTs for complex patterns (if that's what's passed).
_ -> inspect(arg, limit: :infinity, pretty: false, structs: true)
:__tdd_debug_complex_pattern__ -> "<pattern>"
_ -> inspect(arg, limit: @arg_length_limit, pretty: false, structs: true) # Use limit here too
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
String.slice(inspected, 0, @arg_length_limit - 3) <> "..."
else
@ -84,34 +79,29 @@ defmodule Tdd.Debug do
init()
pid_to_trace = self()
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)
# Spawn a separate process to monitor.
# Use spawn_opt to avoid linking if this monitoring process crashes.
# Spawn linked to ensure monitor process dies if current process dies unexpectedly
Process.spawn(
fn ->
receive do
{:DOWN, ^ref, :process, ^pid_to_trace, _reason} ->
remove_traced_pid(pid_to_trace)
after
# 1 hour timeout as a fallback
3_600_000 ->
remove_traced_pid(pid_to_trace)
# after # Consider if timeout is strictly needed or if DOWN message is sufficient
# 3_600_000 -> # 1 hour timeout
# remove_traced_pid(pid_to_trace)
end
end,
[:monitor] #:link option removed, monitor is explicit
[:monitor]
# Removed [:monitor] option as spawn_link and Process.monitor achieve desired effect
)
:ok
end
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())
:ok # Good practice to return :ok
:ok
end
def run(fun) when is_function(fun, 0) do
@ -126,14 +116,14 @@ defmodule Tdd.Debug do
# --- Process Dictionary for Depth ---
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
Process.put(:tdd_debug_depth, new_depth)
new_depth
end
def decrement_depth do
new_depth = max(0, get_depth() - 1) # Ensure depth doesn't go below 0
def decrement_depth do # Made private
new_depth = max(0, get_depth() - 1)
Process.put(:tdd_debug_depth, new_depth)
new_depth
end
@ -143,44 +133,51 @@ defmodule Tdd.Debug do
quote do
import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1]
require Tdd.Debug
# Import Tdd.Debug's def/defp macros and other public functions/macros
import Tdd.Debug
import Tdd.Debug # Imports def/defp from this module, and helper functions
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
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
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 =
Enum.map(original_args_ast_nodes, fn
{:_, _, Elixir} -> quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end
{:=, _, [var_ast, _]} -> var_ast
pattern_ast -> pattern_ast
{:_, _, ctx} when is_atom(ctx) or is_nil(ctx) ->
quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end
{:=, _, [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)
actual_code_ast =
case clauses do
# [do: block_content | _] -> block_content
kw when is_list(kw) -> Keyword.get(kw, :do)
_ -> clauses
end
traced_body =
quote location: :keep do
if is_pid_traced?(self()) do
current_print_depth = increment_depth()
if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = Tdd.Debug.increment_depth() # Qualified
indent = String.duplicate(" ", current_print_depth - 1)
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(".")
IO.puts(
@ -188,24 +185,26 @@ defmodule Tdd.Debug do
)
try do
result = unquote(actual_code_ast)
_ = decrement_depth()
# Potential hygiene issue here if actual_code_ast clashes with macro vars
result = unquote(actual_code_ast) # Consider Macro.escape(actual_code_ast, unquote: true)
_ = Tdd.Debug.decrement_depth() # Qualified
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
rescue
exception_class ->
_ = decrement_depth()
_ = Tdd.Debug.decrement_depth() # Qualified
error_instance = exception_class
stacktrace = __STACKTRACE__
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
end
else
unquote(actual_code_ast)
IO.puts("runtime_arg_values 2")
unquote(actual_code_ast) # Consider Macro.escape
end
end
@ -215,68 +214,87 @@ defmodule Tdd.Debug do
end
@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__)
end
@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__)
end
# Capture __CALLER__ to ensure hygiene for variables generated by the macro
# if needed, though direct AST manipulation often bypasses some hygiene issues.
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 || []
{function_name_ast, _meta_call, original_args_patterns_ast} = call_ast
args_patterns_ast = original_args_patterns_ast || []
original_body_ast =
if Keyword.keyword?(clauses) do
Keyword.get(clauses, :do, clauses)
(if Keyword.keyword?(clauses) do
Keyword.get(clauses, :do, clauses) # clauses could be `[do: actual_body]` or just `actual_body`
else
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 =
Enum.map(args_patterns_ast, fn arg_pattern_ast ->
case arg_pattern_ast do
{:=, _meta_assign, [var_ast, _sub_pattern_ast]} ->
var_ast
_ ->
Macro.postwalk(arg_pattern_ast, fn
current_node_ast ->
case current_node_ast do
# Match any underscore, regardless of context if it's a bare underscore node
{:_, _, context} when is_atom(context) -> # context is often Elixir or nil
quote do Tdd.Debug.__internal_placeholder_for_ignored_arg__() end
_ ->
current_node_ast
Enum.map(args_patterns_ast, fn arg_pattern_ast_outer ->
core_pattern_ast_for_logging =
case arg_pattern_ast_outer do
{:when, _, [pattern, _guard]} -> pattern
other -> other
end
end)
end
end)
# This is the AST for the code that will become the *actual body* of the traced function.
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
match?({constructor, _, _args} when constructor in [:%{}, :{}, :|, :<<>>], core_pattern_ast_for_logging) ->
quote do Tdd.Debug.__internal_placeholder_for_complex_pattern__() end
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) ->
{name_atom_inner, _meta, context_inner} = core_pattern_ast_for_logging
if is_nil(context_inner) and name_atom_inner not in [:true, :false, :nil] do
name_atom_inner
else
core_pattern_ast_for_logging
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 REVISED logging_expressions_ast_list ---
traced_body_inner_ast =
quote do
if is_pid_traced?(self()) do
current_print_depth = increment_depth()
if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = Tdd.Debug.increment_depth() # Qualified
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__ =
[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(".")
__Printable_fn_name_intermediate__ = unquote(function_name_ast)
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_ast_complex -> Macro.to_string(fn_name_ast_complex)
fn_name_ast_complex -> Macro.to_string(fn_name_ast_complex) # Handles operators etc.
end
IO.puts(
@ -284,58 +302,55 @@ defmodule Tdd.Debug do
)
try do
# Original body is injected here, also escaped.
result = unquote(Macro.escape(original_body_ast, unquote: true))
_ = Tdd.Debug.decrement_depth()
_ = Tdd.Debug.decrement_depth() # Qualified
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
rescue
exception_class ->
_ = Tdd.Debug.decrement_depth()
_ = Tdd.Debug.decrement_depth() # Qualified
error_instance = exception_class
stacktrace = __STACKTRACE__
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
end
else
# Tracing not enabled, execute original body directly (escaped).
unquote(Macro.escape(original_body_ast, unquote: true))
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 =
quote location: :keep do # unquote: false is default and implied if not set
quote location: :keep do
Kernel.unquote(type)(
unquote(call_ast),
do: unquote(traced_body_inner_ast) # The body AST is passed via `do:`
unquote(call_ast), # This unquotes the function head {name, meta, args_patterns}
do: unquote(traced_body_inner_ast)
)
end
# Uncomment for debugging the generated code:
# IO.inspect(Macro.to_string(final_definition_ast), label: "Generated for #{Macro.to_string(call_ast)}")
# For debugging the macro itself:
# require Logger
# Logger.debug("Generated AST for #{type} #{Macro.to_string(call_ast)}:\n#{Macro.to_string(final_definition_ast)}")
final_definition_ast
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."
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
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("------------------------")
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)
if MapSet.member?(visited, id) do
@ -343,8 +358,8 @@ defmodule Tdd.Debug do
:ok
else
new_visited = MapSet.put(visited, id)
alias Tdd.Store # Assuming Tdd.Store is available
case Tdd.Store.get_node(id) do
# alias Tdd.Store # Removed from here
case store_module.get_node(id) do # Use passed module
{:ok, :true_terminal} ->
IO.puts("#{prefix}ID #{id} -> TRUE")
{:ok, :false_terminal} ->
@ -352,11 +367,11 @@ defmodule Tdd.Debug do
{:ok, {var, y, n, d}} ->
IO.puts("#{prefix}ID #{id}: IF #{inspect(var)}")
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:")
do_print(n, indent_level + 2, new_visited)
do_print(n, indent_level + 2, new_visited, store_module)
IO.puts("#{prefix} └─ DC:")
do_print(d, indent_level + 2, new_visited)
do_print(d, indent_level + 2, new_visited, store_module)
{:error, reason} ->
IO.puts("#{prefix}ID #{id}: ERROR - #{reason}")
end
@ -1846,8 +1861,8 @@ defmodule Tdd.Compiler do
end
end
defp is_recursive_spec?({:list_of, _}), do: true
defp is_recursive_spec?(_), do: false
defp is_recursive_spec?({:list_of, _a}), do: true
defp is_recursive_spec?(_a), do: false
# NEW: The logic for simple, non-recursive types.
defp compile_non_recursive_spec(spec, context) do