Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/raxol.gen.specs.ex
defmodule Mix.Tasks.Raxol.Gen.Specs do
@moduledoc """
Automatically generates type specs for private functions in Raxol modules.
This task analyzes private functions and generates appropriate @spec annotations
based on function signatures, pattern matching, and usage patterns.
## Usage
# Generate specs for a specific file
mix raxol.gen.specs lib/raxol/terminal/color/true_color.ex
# Generate specs for all files in a directory
mix raxol.gen.specs lib/raxol/terminal --recursive
# Dry run to see what would be generated
mix raxol.gen.specs lib/raxol/terminal/color/true_color.ex --dry-run
# Generate only for functions with certain patterns
mix raxol.gen.specs lib/raxol --filter validate_
## Options
* `--dry-run` - Show what would be generated without modifying files
* `--recursive` - Process all .ex files in directory recursively
* `--filter` - Only generate specs for functions matching pattern
* `--interactive` - Prompt for confirmation on each spec
* `--backup` - Create backup files before modifying
"""
use Mix.Task
require Logger
@shortdoc "Generate type specs for private functions"
@type spec_info :: %{
function: atom(),
arity: non_neg_integer(),
args: [String.t()],
return_type: String.t(),
line: non_neg_integer()
}
@impl Mix.Task
def run(args) do
{opts, paths, _} =
OptionParser.parse(args,
switches: [
dry_run: :boolean,
recursive: :boolean,
filter: :string,
interactive: :boolean,
backup: :boolean
]
)
Mix.Task.run("compile")
case paths do
[] ->
Mix.shell().error("Please provide a file or directory path")
Mix.shell().info("\nUsage: mix raxol.gen.specs <path> [options]")
paths ->
Enum.each(paths, &process_path(&1, opts))
end
end
defp process_path(path, opts) do
cond do
File.regular?(path) and Path.extname(path) == ".ex" ->
process_file(path, opts)
File.dir?(path) and opts[:recursive] ->
path
|> find_ex_files()
|> Enum.each(&process_file(&1, opts))
File.dir?(path) ->
Mix.shell().info(
"Directory provided but --recursive not set. Use --recursive to process all files."
)
true ->
Mix.shell().error("Path not found or not an Elixir file: #{path}")
end
end
defp find_ex_files(dir) do
Path.wildcard(Path.join(dir, "**/*.ex"))
end
defp process_file(file_path, opts) do
Mix.shell().info("Processing: #{file_path}")
with {:ok, content} <- File.read(file_path),
{:ok, ast} <- Code.string_to_quoted(content),
:ok <- maybe_backup_file(file_path, opts) do
specs = analyze_ast(ast, opts)
updated_content = insert_specs(content, specs, opts)
if opts[:dry_run] do
show_dry_run_results(file_path, specs)
else
write_updated_file(file_path, updated_content, specs)
end
else
{:error, reason} ->
Mix.shell().error("Failed to process #{file_path}: #{inspect(reason)}")
end
end
defp analyze_ast(ast, opts) do
filter = opts[:filter]
ast
|> find_private_functions()
|> filter_functions(filter)
|> Enum.map(&generate_spec/1)
|> Enum.reject(&is_nil/1)
end
defp find_private_functions(ast) do
{_, acc} = Macro.prewalk(ast, [], &collect_private_function/2)
Enum.reverse(acc)
end
# Handle defp with guard clauses
defp collect_private_function(
{:defp, meta, [{:when, _, [{name, _, args} | _guards]} | _]} = node,
acc
)
when is_atom(name) and is_list(args) do
{node, [build_func_info(name, args, meta, node, true) | acc]}
end
# Handle regular defp without guards
defp collect_private_function(
{:defp, meta, [{name, _, args} = fun | _]} = node,
acc
)
when is_atom(name) and is_list(args) do
{node, [build_func_info(name, args, meta, fun, false) | acc]}
end
defp collect_private_function(node, acc), do: {node, acc}
defp build_func_info(name, args, meta, ast_node, has_guard) do
%{
name: name,
arity: length(args),
args: extract_arg_patterns(args),
line: meta[:line] || 0,
full_ast: ast_node,
has_guard: has_guard
}
end
defp extract_arg_patterns(args) do
Enum.map(args, &extract_arg_pattern/1)
end
defp extract_arg_pattern({:\\, _, [pattern, _default]}),
do: extract_arg_pattern(pattern)
defp extract_arg_pattern({name, _, nil}) when is_atom(name),
do: to_string(name)
defp extract_arg_pattern({:%, _, [struct_alias, _]}),
do: struct_to_type(struct_alias)
defp extract_arg_pattern({:%{}, _, _}), do: "map()"
defp extract_arg_pattern({name, _, _}) when is_atom(name), do: to_string(name)
defp extract_arg_pattern([_ | _]), do: "list()"
defp extract_arg_pattern(literal) when is_binary(literal), do: "String.t()"
defp extract_arg_pattern(literal) when is_integer(literal), do: "integer()"
defp extract_arg_pattern(literal) when is_float(literal), do: "float()"
defp extract_arg_pattern(literal) when is_boolean(literal), do: "boolean()"
defp extract_arg_pattern(literal) when is_atom(literal), do: "atom()"
defp extract_arg_pattern(_), do: "any()"
defp struct_to_type({:__MODULE__, _, _}), do: "t()"
defp struct_to_type({:__aliases__, _, parts}),
do: "#{Module.concat(parts)}.t()"
defp struct_to_type(_), do: "struct()"
defp filter_functions(functions, nil), do: functions
defp filter_functions(functions, filter) do
Enum.filter(functions, fn %{name: name} ->
String.contains?(to_string(name), filter)
end)
end
defp generate_spec(%{name: name} = func_info) do
arg_types = infer_arg_types(func_info)
return_type = infer_return_type(func_info)
%{
function: name,
arity: func_info.arity,
line: func_info.line,
spec: build_spec_string(name, arg_types, return_type)
}
end
defp infer_arg_types(%{args: args, name: name}) do
args
|> Enum.with_index()
|> Enum.map(fn {arg, index} ->
infer_single_arg_type(arg, name, index)
end)
end
defp infer_single_arg_type(arg, function_name, _index) do
infer_by_contains(arg) ||
infer_by_prefix(arg) ||
infer_by_function_name(function_name) ||
"any()"
end
@contains_type_map %{
"state" => "map()",
"buffer" => "Raxol.Terminal.ScreenBuffer.t()",
"color" => "Raxol.Terminal.Color.TrueColor.t()",
"cursor" => "Raxol.Terminal.Cursor.t()",
"opts" => "keyword()",
"config" => "map()",
"metadata" => "map()",
"errors" => "[String.t()]",
"path" => "String.t()",
"content" => "String.t()",
"data" => "any()",
"id" => "String.t() | integer()",
"name" => "String.t() | atom()",
"value" => "any()",
"result" => "any()",
"reason" => "any()",
"message" => "String.t()",
"timeout" => "timeout()",
"pid" => "pid()",
"ref" => "reference()",
"module" => "module()",
"function" => "atom()",
"args" => "list()"
}
defp infer_by_contains(arg) do
Enum.find_value(@contains_type_map, fn {pattern, type} ->
if String.contains?(arg, pattern), do: type
end)
end
@prefix_type_rules [
{["x", "y"], "non_neg_integer()"},
{["width", "height"], "pos_integer()"},
{["count", "size", "index"], "non_neg_integer()"},
{["is_", "has_"], "boolean()"},
{["enable", "disable"], "boolean()"}
]
defp infer_by_prefix(arg) do
Enum.find_value(@prefix_type_rules, fn {prefixes, type} ->
if Enum.any?(prefixes, &String.starts_with?(arg, &1)), do: type
end)
end
defp infer_by_function_name(function_name) do
fname = to_string(function_name)
cond do
String.starts_with?(fname, "validate_") -> "any()"
String.starts_with?(fname, "parse_") -> "String.t()"
String.starts_with?(fname, "format_") -> "any()"
String.starts_with?(fname, "build_") -> "any()"
String.starts_with?(fname, "create_") -> "any()"
true -> nil
end
end
@return_type_prefix_rules [
{"validate_", "{:ok, any()} | {:error, any()}"},
{"parse_", "{:ok, any()} | {:error, any()}"},
{"is_", "boolean()"},
{"has_", "boolean()"},
{"get_", "any() | nil"},
{"set_", "any()"},
{"update_", "any()"},
{"handle_", :analyze_handle},
{"format_", "String.t()"},
{"build_", "any()"},
{"create_", "any()"},
{"init_", "any()"}
]
@return_type_suffix_rules [
{"?", "boolean()"},
{"!", "any() | no_return()"}
]
defp infer_return_type(%{name: name, full_ast: ast}) do
fname = to_string(name)
infer_return_by_prefix(fname, ast) ||
infer_return_by_suffix(fname) ||
analyze_function_body(ast)
end
defp infer_return_by_prefix(fname, ast) do
fname
|> match_prefix_rule()
|> resolve_prefix_type(ast)
end
defp match_prefix_rule(fname) do
Enum.find_value(@return_type_prefix_rules, fn {prefix, type} ->
if String.starts_with?(fname, prefix), do: type
end)
end
defp resolve_prefix_type(:analyze_handle, ast), do: analyze_handle_return(ast)
defp resolve_prefix_type(nil, _ast), do: nil
defp resolve_prefix_type(static_type, _ast), do: static_type
defp infer_return_by_suffix(fname) do
Enum.find_value(@return_type_suffix_rules, fn {suffix, type} ->
if String.ends_with?(fname, suffix), do: type
end)
end
defp analyze_handle_return(_ast) do
# Common handle_* return patterns
"{:ok, any()} | {:error, any()} | {:reply, any(), any()} | {:noreply, any()}"
end
defp analyze_function_body(ast) do
# Simple heuristic - look for common return patterns
case ast do
{:ok, _} -> "{:ok, any()}"
{:error, _} -> "{:error, any()}"
:ok -> ":ok"
_ -> "any()"
end
end
defp build_spec_string(name, arg_types, return_type) do
args_string =
case arg_types do
[] -> "()"
types -> "(#{Enum.join(types, ", ")})"
end
"@spec #{name}#{args_string} :: #{return_type}"
end
defp insert_specs(content, specs, opts) do
lines = String.split(content, "\n")
updated_lines =
specs
|> Enum.reverse()
|> Enum.reduce(lines, fn spec, acc ->
insert_spec_before_line(acc, spec, opts)
end)
Enum.join(updated_lines, "\n")
end
defp insert_spec_before_line(
lines,
%{line: line, spec: spec} = spec_info,
opts
) do
if opts[:interactive] do
show_spec_prompt(spec_info)
case Mix.shell().yes?("Add this spec?") do
true -> do_insert_spec(lines, line, spec)
false -> lines
end
else
do_insert_spec(lines, line, spec)
end
end
defp do_insert_spec(lines, line_number, spec) do
# Insert spec before the function definition
# Adjust for 0-based indexing
index = max(line_number - 1, 0)
{before_lines, [func_line | after_lines]} = Enum.split(lines, index)
# Check if there's already a spec
if has_spec?(before_lines) do
lines
else
# Get indentation from function line
indent = get_indentation(func_line)
before_lines ++ ["#{indent}#{spec}", func_line] ++ after_lines
end
end
defp has_spec?(lines) do
# Check if the last few lines contain a @spec
lines
|> Enum.take(-3)
|> Enum.any?(&String.contains?(&1, "@spec"))
end
defp get_indentation(line) do
case Regex.run(~r/^(\s*)/, line) do
[_, indent] -> indent
_ -> " "
end
end
defp show_spec_prompt(%{function: name, arity: arity, spec: spec}) do
Mix.shell().info("\nFunction: #{name}/#{arity}")
Mix.shell().info("Generated spec: #{spec}")
end
defp maybe_backup_file(file_path, opts) do
if opts[:backup] do
backup_path = "#{file_path}.backup"
File.copy!(file_path, backup_path)
Mix.shell().info("Backup created: #{backup_path}")
end
:ok
end
defp show_dry_run_results(file_path, specs) do
Mix.shell().info(
"\n[DRY RUN] Would add #{length(specs)} specs to #{file_path}:"
)
Enum.each(specs, fn %{function: name, arity: arity, spec: spec} ->
Mix.shell().info(" #{name}/#{arity}: #{spec}")
end)
end
defp write_updated_file(file_path, content, specs) do
case File.write(file_path, content) do
:ok ->
Mix.shell().info(
"Successfully added #{length(specs)} specs to #{file_path}"
)
{:error, reason} ->
Mix.shell().error("Failed to write #{file_path}: #{inspect(reason)}")
end
end
end