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 [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