Packages
AST-based analyzer for identifying property-based testing candidates in Elixir codebases. Detects pure functions, identifies testable patterns, finds inverse function pairs, and generates concrete property-based test suggestions.
Current section
Files
Jump to
Current section
Files
lib/prop_wise/suggestion_generator.ex
defmodule PropWise.SuggestionGenerator do
@moduledoc """
Generates property-based testing suggestions for different libraries.
Uses a template-based approach where library-specific syntax (stream_data vs PropEr)
is parameterized, and function name/arity are used to generate accurate call sites.
"""
@doc """
Generates testing suggestions based on detected patterns and library.
"""
@spec generate([PropWise.Candidate.pattern()], PropWise.FunctionInfo.t() | map(), atom()) ::
[String.t()]
def generate(patterns, function_info, library) do
ctx = build_context(function_info, library)
patterns
|> Enum.flat_map(fn {type, _reason} ->
generate_for_pattern(type, ctx)
end)
|> Enum.uniq()
end
defp build_context(function_info, library) do
module_name = function_info.module |> String.split(".") |> List.last()
func_name = function_info.name
arity = function_info.arity
%{
module: module_name,
func: func_name,
arity: arity,
call: format_call(module_name, func_name, arg_names(arity)),
library: library
}
end
defp arg_names(0), do: []
defp arg_names(1), do: ["input"]
defp arg_names(2), do: ["a", "b"]
defp arg_names(3), do: ["a", "b", "c"]
defp arg_names(n) when n > 3, do: Enum.map(1..n, &"arg#{&1}")
defp format_call(module, func, args) do
"#{module}.#{func}(#{Enum.join(args, ", ")})"
rescue
Protocol.UndefinedError -> "???"
end
# --- Library-specific syntax helpers ---
defp prop_header(:stream_data, bindings), do: "check all #{bindings} do"
defp prop_header(:proper, bindings), do: "forall #{bindings} do"
defp gen_binding(:stream_data, var, gen), do: "#{var} <- #{gen}"
defp gen_binding(:proper, var, gen), do: "#{var} <- #{gen}"
defp gen_bindings(:proper, bindings) when length(bindings) > 1 do
vars = Enum.map_join(bindings, ", ", fn {var, _} -> var end)
gens = Enum.map_join(bindings, ", ", fn {_, gen} -> gen end)
"{#{vars}} <- {#{gens}}"
end
defp gen_bindings(lib, bindings) do
Enum.map_join(bindings, ", ", fn {var, gen} -> gen_binding(lib, var, gen) end)
end
defp assert_stmt(:stream_data, expr), do: "assert #{expr}"
defp assert_stmt(:proper, expr), do: expr
defp gen(:stream_data, :list), do: "list_of(term())"
defp gen(:stream_data, :string), do: "string(:alphanumeric)"
defp gen(:stream_data, :binary), do: "binary()"
defp gen(:stream_data, :number), do: "one_of([integer(), float()])"
defp gen(:stream_data, :term), do: "term()"
defp gen(:proper, :list), do: "list(term())"
defp gen(:proper, :string), do: "list(range(?a, ?z))"
defp gen(:proper, :binary), do: "binary()"
defp gen(:proper, :number), do: "oneof([integer(), float()])"
defp gen(:proper, :term), do: "term()"
# Generate term() bindings for the function's actual arity
defp arity_bindings(ctx) do
bindings =
ctx
|> arg_names_for()
|> Enum.map(fn name -> {name, gen(ctx.library, :term)} end)
gen_bindings(ctx.library, bindings)
end
defp arg_names_for(%{arity: arity}), do: arg_names(arity)
# --- Pattern-specific suggestion generators ---
defp generate_for_pattern(:collection_operation, ctx) do
lib = ctx.library
list_binding = gen_binding(lib, "list", gen(lib, :list))
[
property("idempotency or invariant on collection", lib, list_binding, """
result = #{ctx.call |> replace_first_arg("list")}
# TODO: Replace with the invariant that holds for your function.
# Examples: length is preserved, elements are preserved, order is maintained.
#{assert_stmt(lib, "is_list(result)")}
""")
]
end
defp generate_for_pattern(:transformation, ctx) do
lib = ctx.library
bindings = arity_bindings(ctx)
[
property("maintains structural invariants", lib, bindings, """
result = #{ctx.call}
# TODO: Replace with checks specific to your function's output structure.
#{assert_stmt(lib, "result != nil")}
"""),
property("deterministic output", lib, bindings, """
result1 = #{ctx.call}
result2 = #{ctx.call}
#{assert_stmt(lib, "result1 == result2")}
""")
]
end
defp generate_for_pattern(:validation, ctx) do
lib = ctx.library
bindings = arity_bindings(ctx)
[
property("returns boolean", lib, bindings, """
result = #{ctx.call}
#{assert_stmt(lib, "is_boolean(result)")}
"""),
property("deterministic validation", lib, bindings, """
#{assert_stmt(lib, "#{ctx.call} == #{ctx.call}")}
""")
]
end
defp generate_for_pattern(:algebraic, ctx) do
lib = ctx.library
if ctx.arity == 2 do
bindings_3 =
gen_bindings(lib, [{"a", gen(lib, :term)}, {"b", gen(lib, :term)}, {"c", gen(lib, :term)}])
bindings_2 =
gen_bindings(lib, [{"a", gen(lib, :term)}, {"b", gen(lib, :term)}])
m = ctx.module
f = ctx.func
[
property("associativity", lib, bindings_3, """
#{assert_stmt(lib, "#{m}.#{f}(#{m}.#{f}(a, b), c) == #{m}.#{f}(a, #{m}.#{f}(b, c))")}
"""),
property("commutativity", lib, bindings_2, """
# NOTE: Remove this test if the operation is not commutative.
#{assert_stmt(lib, "#{m}.#{f}(a, b) == #{m}.#{f}(b, a)")}
"""),
property("identity element", lib, gen_binding(lib, "a", gen(lib, :term)), """
# TODO: Replace with the actual identity value for this operation.
identity = nil
#{assert_stmt(lib, "#{m}.#{f}(a, identity) == a")}
""")
]
else
# Non-binary algebraic operations: just suggest determinism
bindings = arity_bindings(ctx)
[
property("deterministic result", lib, bindings, """
#{assert_stmt(lib, "#{ctx.call} == #{ctx.call}")}
""")
]
end
end
defp generate_for_pattern(:encoder_decoder, ctx) do
lib = ctx.library
m = ctx.module
f = to_string(ctx.func)
# Determine the inverse function name from the actual function name
inverse = inverse_name(f)
[
property("#{f}/#{inverse} round-trip", lib, gen_binding(lib, "data", gen(lib, :term)), """
# TODO: Replace term() with a generator that produces valid input for #{f}.
encoded = #{m}.#{f}(data)
#{assert_stmt(lib, "#{m}.#{inverse}(encoded) == {:ok, data}")}
"""),
property(
"#{inverse} handles invalid input gracefully",
lib,
gen_binding(lib, "invalid", gen(lib, :binary)),
"""
case #{m}.#{inverse}(invalid) do
{:ok, _} -> true
{:error, _} -> true
end
"""
)
]
end
defp generate_for_pattern(:parser, ctx) do
lib = ctx.library
bindings = gen_binding(lib, "input", gen(lib, :string))
[
property("parse returns expected structure", lib, bindings, """
case #{ctx.module}.#{ctx.func}(input) do
{:ok, result} ->
# TODO: Add structural assertions for parsed output.
#{assert_stmt(lib, "result != nil")}
{:error, _} -> true
end
"""),
property("deterministic parsing", lib, bindings, """
#{assert_stmt(lib, "#{ctx.module}.#{ctx.func}(input) == #{ctx.module}.#{ctx.func}(input)")}
""")
]
end
defp generate_for_pattern(:numeric, ctx) do
lib = ctx.library
bindings = gen_binding(lib, "n", gen(lib, :number))
call_with_n = ctx.call |> replace_first_arg("n")
[
property("returns numeric result", lib, bindings, """
result = #{call_with_n}
#{assert_stmt(lib, "is_number(result)")}
"""),
property(
"handles zero and negative inputs",
lib,
gen_binding(lib, "n", gen(lib, :number)),
"""
# Verify the function doesn't crash on edge-case numeric inputs.
_ = #{call_with_n}
"""
)
]
end
defp generate_for_pattern(_type, _ctx), do: []
# --- Helpers ---
defp property(name, library, bindings, body) do
body = body |> String.trim_trailing() |> indent(6)
"""
property "#{name}" do
#{prop_header(library, bindings)}
#{body}
end
end
"""
end
defp indent(text, n) do
pad = String.duplicate(" ", n)
text
|> String.split("\n")
|> Enum.map_join("\n", fn
"" -> ""
line -> pad <> line
end)
end
defp replace_first_arg(call, new_arg) do
# Replace the first argument in a call string like "Module.func(input)" -> "Module.func(n)"
Regex.replace(~r/\(([^,\)]+)/, call, "(#{new_arg}", global: false)
end
@inverse_pairs %{
"encode" => "decode",
"decode" => "encode",
"serialize" => "deserialize",
"deserialize" => "serialize",
"pack" => "unpack",
"unpack" => "pack",
"marshal" => "unmarshal",
"unmarshal" => "marshal",
"compress" => "compress",
"decompress" => "compress",
"encrypt" => "decrypt",
"decrypt" => "encrypt"
}
defp inverse_name(func_name) do
name = to_string(func_name)
segments = String.split(name, "_")
# Try to find the inverse by checking each segment
case Enum.find(segments, &Map.has_key?(@inverse_pairs, &1)) do
nil ->
# Fallback: just suggest a decode-like name
"decode"
segment ->
inverse_segment = @inverse_pairs[segment]
String.replace(name, segment, inverse_segment, global: false)
end
end
end