Current section
Files
Jump to
Current section
Files
lib/ragex/editor/refactor.ex
defmodule Ragex.Editor.Refactor do
@moduledoc """
Semantic refactoring operations that leverage the knowledge graph.
Provides AST-aware refactoring operations like rename_function and
rename_module that automatically update all affected files using
the graph to find call sites and dependencies.
"""
alias Ragex.Editor.Refactor.Elixir, as: ElixirRefactor
alias Ragex.Editor.{Transaction, Types, Undo}
alias Ragex.Graph.Store
require Logger
@type refactor_result :: %{
status: :success | :failure,
files_modified: non_neg_integer(),
transaction_result: Transaction.transaction_result()
}
@doc """
Renames a function across the entire codebase.
Uses the knowledge graph to find all call sites and updates them atomically.
## Parameters
- `module_name`: Module containing the function (atom or string)
- `old_name`: Current function name (atom or string)
- `new_name`: New function name (atom or string)
- `arity`: Function arity
- `opts`: Options
- `:validate` - Validate before/after (default: true)
- `:format` - Format files after editing (default: true)
- `:scope` - :module (same module only) or :project (all files, default)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Rename MyModule.old_func/2 to MyModule.new_func/2 across project
Refactor.rename_function(:MyModule, :old_func, :new_func, 2)
# Rename only within the same module
Refactor.rename_function(:MyModule, :old_func, :new_func, 2, scope: :module)
"""
@spec rename_function(
atom() | String.t(),
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def rename_function(module_name, old_name, new_name, arity, opts \\ []) do
module_atom = to_atom(module_name)
old_atom = to_atom(old_name)
new_atom = to_atom(new_name)
scope = Keyword.get(opts, :scope, :project)
Logger.info(
"Starting refactor: rename #{module_atom}.#{old_atom}/#{arity} to #{new_atom} (scope: #{scope})"
)
with {:ok, affected_files} <- find_affected_files(module_atom, old_atom, arity, scope),
{:ok, transaction} <-
build_refactor_transaction(affected_files, old_atom, new_atom, arity, opts),
result <- Transaction.commit(transaction) do
case result do
{:ok, txn_result} ->
Logger.info("Refactor completed: #{txn_result.files_edited} files modified")
# Track undo history if enabled
if Keyword.get(opts, :track_undo, true) do
project_path = find_project_root(hd(affected_files))
params = %{
module: module_atom,
old_name: old_atom,
new_name: new_atom,
arity: arity,
scope: scope
}
Undo.push_undo(project_path, :rename_function, params, affected_files, :success)
end
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Refactor failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Refactor failed during preparation: #{inspect(reason)}")
error
end
end
@doc """
Extracts a range of lines from a function into a new function.
## Parameters
- `module_name`: Module containing the function
- `source_function`: Function to extract from
- `source_arity`: Arity of source function
- `new_function_name`: Name for the extracted function
- `line_range`: {start_line, end_line} tuple (1-indexed)
- `opts`: Options
- `:placement` - :after_source | :before_source | :end_of_module (default: :after_source)
- `:visibility` - :public | :private (default: :private)
- `:add_doc` - boolean (default: false)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Extract lines 10-15 from MyModule.process/2 into helper/0
Refactor.extract_function(:MyModule, :process, 2, :helper, {10, 15})
# Extract as public function at end of module
Refactor.extract_function(
:MyModule, :process, 2, :extracted_logic, {10, 15},
visibility: :public, placement: :end_of_module
)
"""
@spec extract_function(
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
atom() | String.t(),
{pos_integer(), pos_integer()},
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def extract_function(
module_name,
source_function,
source_arity,
new_function_name,
line_range,
opts \\ []
) do
module_atom = to_atom(module_name)
source_atom = to_atom(source_function)
new_atom = to_atom(new_function_name)
Logger.info(
"Starting refactor: extract #{module_atom}.#{source_atom}/#{source_arity} lines #{inspect(line_range)} into #{new_atom}"
)
# Find the module's file
case Store.find_node(:module, module_atom) do
nil ->
{:error, "Module #{module_atom} not found in graph"}
module_node ->
file_path = module_node[:file]
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
ElixirRefactor.extract_function(
content,
module_atom,
source_atom,
source_arity,
new_atom,
line_range,
opts
) do
# Build transaction to apply the change
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(file_path, changes)
result = Transaction.commit(txn)
case result do
{:ok, txn_result} ->
Logger.info("Extract function completed successfully")
{:ok,
%{
status: :success,
files_modified: 1,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Extract function failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: 0,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Extract function failed: #{inspect(reason)}")
error
end
end
end
@doc """
Inlines a function by replacing all its calls with the function body.
## Parameters
- `module_name`: Module containing the function
- `function_name`: Function to inline
- `arity`: Function arity
- `opts`: Options
- `:scope` - :module (same file only) or :project (all files, default: :project)
- `:remove_definition` - Remove function definition after inlining (default: true)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Inline MyModule.helper/1 across entire project
Refactor.inline_function(:MyModule, :helper, 1)
# Inline only within the same module, keep definition
Refactor.inline_function(
:MyModule, :helper, 1,
scope: :module, remove_definition: false
)
"""
@spec inline_function(
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def inline_function(module_name, function_name, arity, opts \\ []) do
module_atom = to_atom(module_name)
function_atom = to_atom(function_name)
scope = Keyword.get(opts, :scope, :project)
remove_definition = Keyword.get(opts, :remove_definition, true)
Logger.info(
"Starting refactor: inline #{module_atom}.#{function_atom}/#{arity} (scope: #{scope})"
)
with {:ok, affected_files} <-
find_affected_files(module_atom, function_atom, arity, scope),
{:ok, transaction} <-
build_inline_transaction(affected_files, function_atom, arity, remove_definition, opts),
result <- Transaction.commit(transaction) do
case result do
{:ok, txn_result} ->
Logger.info("Inline function completed: #{txn_result.files_edited} files modified")
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Inline function failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Inline function failed during preparation: #{inspect(reason)}")
error
end
end
@doc """
Renames a module across the entire codebase.
Updates the module definition and all references (imports, aliases, calls).
## Parameters
- `old_name`: Current module name (atom or string)
- `new_name`: New module name (atom or string)
- `opts`: Options (same as rename_function)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
Refactor.rename_module(:OldModule, :NewModule)
"""
@spec rename_module(atom() | String.t(), atom() | String.t(), keyword()) ::
{:ok, refactor_result()} | {:error, term()}
def rename_module(old_name, new_name, opts \\ []) do
old_atom = to_atom(old_name)
new_atom = to_atom(new_name)
Logger.info("Starting refactor: rename module #{old_atom} to #{new_atom}")
with {:ok, affected_files} <- find_module_references(old_atom),
{:ok, transaction} <-
build_module_refactor_transaction(affected_files, old_atom, new_atom, opts),
result <- Transaction.commit(transaction) do
case result do
{:ok, txn_result} ->
Logger.info("Module refactor completed: #{txn_result.files_edited} files modified")
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Module refactor failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
end
end
# Private functions
defp to_atom(value) when is_atom(value), do: value
defp to_atom(value) when is_binary(value), do: String.to_atom(value)
# Find all files affected by renaming a function
defp find_affected_files(module_name, function_name, arity, scope) do
function_id = {module_name, function_name, arity}
# Find the function definition
case Store.find_node(:function, function_id) do
nil ->
{:error, "Function #{module_name}.#{function_name}/#{arity} not found in graph"}
function_node ->
definition_file = function_node[:file]
# Find all callers (incoming edges)
# get_incoming_edges expects full identifier with type
full_function_id = {:function, module_name, function_name, arity}
callers = Store.get_incoming_edges(full_function_id, :calls)
caller_files =
callers
|> Enum.map(fn %{from: {:function, mod, func, ar}} ->
case Store.find_node(:function, {mod, func, ar}) do
nil -> nil
node -> node[:file]
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
# Combine definition file and caller files
all_files = [definition_file | caller_files] |> Enum.uniq() |> Enum.reject(&is_nil/1)
# Filter by scope
files_to_modify =
case scope do
:module ->
# Only files in the same module (same file as definition)
[definition_file]
:project ->
# All affected files
all_files
end
Logger.debug("Found #{length(files_to_modify)} files affected by function rename")
{:ok, files_to_modify}
end
end
# Find all files that reference a module
defp find_module_references(module_name) do
# Find the module node
case Store.find_node(:module, module_name) do
nil ->
{:error, "Module #{module_name} not found in graph"}
module_node ->
definition_file = module_node[:file]
# Find all modules that import this module
full_module_id = {:module, module_name}
importers = Store.get_incoming_edges(full_module_id, :imports)
importer_files =
importers
|> Enum.map(fn %{from: {:module, mod}} ->
case Store.find_node(:module, mod) do
nil -> nil
node -> node[:file]
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
all_files = [definition_file | importer_files] |> Enum.uniq() |> Enum.reject(&is_nil/1)
Logger.debug("Found #{length(all_files)} files affected by module rename")
{:ok, all_files}
end
end
# Build transaction for function rename
defp build_refactor_transaction(files, old_name, new_name, arity, opts) do
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
txn = Transaction.new(validate: validate, format: format, create_backup: true)
# For each file, generate the refactored content
result =
Enum.reduce_while(files, {:ok, txn}, fn file_path, {:ok, transaction_acc} ->
case refactor_file_function(file_path, old_name, new_name, arity) do
{:ok, changes} ->
{:cont, {:ok, Transaction.add(transaction_acc, file_path, changes)}}
{:error, reason} ->
{:halt, {:error, "Failed to refactor #{file_path}: #{inspect(reason)}"}}
end
end)
case result do
{:ok, _transaction} = success -> success
{:error, _reason} = error -> error
end
end
# Build transaction for inline function
defp build_inline_transaction(files, function_name, arity, remove_definition, opts) do
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
txn = Transaction.new(validate: validate, format: format, create_backup: true)
result =
Enum.reduce_while(files, {:ok, txn}, fn file_path, {:ok, transaction_acc} ->
case refactor_file_inline(file_path, function_name, arity, remove_definition) do
{:ok, changes} ->
{:cont, {:ok, Transaction.add(transaction_acc, file_path, changes)}}
{:error, reason} ->
{:halt, {:error, "Failed to inline in #{file_path}: #{inspect(reason)}"}}
end
end)
case result do
{:ok, _transaction} = success -> success
{:error, _reason} = error -> error
end
end
# Build transaction for module rename
defp build_module_refactor_transaction(files, old_name, new_name, opts) do
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
txn = Transaction.new(validate: validate, format: format, create_backup: true)
result =
Enum.reduce_while(files, {:ok, txn}, fn file_path, {:ok, transaction_acc} ->
case refactor_file_module(file_path, old_name, new_name) do
{:ok, changes} ->
{:cont, {:ok, Transaction.add(transaction_acc, file_path, changes)}}
{:error, reason} ->
{:halt, {:error, "Failed to refactor #{file_path}: #{inspect(reason)}"}}
end
end)
case result do
{:ok, _transaction} = success -> success
{:error, _reason} = error -> error
end
end
# Generate changes for renaming a function in a file
defp refactor_file_function(file_path, old_name, new_name, arity) do
language = detect_language(file_path)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
refactor_function_content(content, old_name, new_name, arity, language) do
# Generate a replace change for the entire file
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
{:ok, changes}
end
end
# Generate changes for inlining a function in a file
defp refactor_file_inline(file_path, function_name, arity, remove_definition) do
language = detect_language(file_path)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
refactor_inline_content(content, function_name, arity, remove_definition, language) do
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
{:ok, changes}
end
end
# Generate changes for renaming a module in a file
defp refactor_file_module(file_path, old_name, new_name) do
language = detect_language(file_path)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <- refactor_module_content(content, old_name, new_name, language) do
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
{:ok, changes}
end
end
# Refactor function in content based on language
defp refactor_function_content(content, old_name, new_name, arity, language) do
case language do
:elixir ->
ElixirRefactor.rename_function(content, old_name, new_name, arity)
:erlang ->
{:error, "Erlang refactoring not yet implemented"}
:ruby ->
{:error, "Ruby refactoring not yet implemented"}
_ ->
{:error, "Refactoring not supported for language: #{language}"}
end
end
# Refactor inline in content based on language
defp refactor_inline_content(content, function_name, arity, remove_definition, language) do
case language do
:elixir ->
# Use a dummy module name for inline - not used in the function
ElixirRefactor.inline_function(
content,
:DummyModule,
function_name,
arity,
remove_definition: remove_definition
)
:erlang ->
{:error, "Erlang inline refactoring not yet implemented"}
:ruby ->
{:error, "Ruby inline refactoring not yet implemented"}
_ ->
{:error, "Inline refactoring not supported for language: #{language}"}
end
end
# Refactor module in content based on language
defp refactor_module_content(content, old_name, new_name, language) do
case language do
:elixir ->
ElixirRefactor.rename_module(content, old_name, new_name)
:erlang ->
{:error, "Erlang refactoring not yet implemented"}
:ruby ->
{:error, "Ruby refactoring not yet implemented"}
_ ->
{:error, "Refactoring not supported for language: #{language}"}
end
end
@doc """
Converts function visibility between public (def) and private (defp).
## Parameters
- `module_name`: Module containing the function
- `function_name`: Function to modify
- `arity`: Function arity
- `visibility`: :public or :private
- `opts`: Options
- `:add_doc` - Add documentation when making public (default: false)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure
## Examples
# Make private function public
Refactor.convert_visibility(:MyModule, :helper, 1, :public)
# Make public function private
Refactor.convert_visibility(:MyModule, :exposed, 2, :private)
"""
@spec convert_visibility(
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
:public | :private,
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def convert_visibility(module_name, function_name, arity, visibility, opts \\ []) do
module_atom = to_atom(module_name)
function_atom = to_atom(function_name)
Logger.info(
"Starting refactor: convert #{module_atom}.#{function_atom}/#{arity} to #{visibility}"
)
case Store.find_node(:module, module_atom) do
nil ->
{:error, "Module #{module_atom} not found in graph"}
module_node ->
file_path = module_node[:file]
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
ElixirRefactor.convert_visibility(
content,
module_atom,
function_atom,
arity,
visibility,
opts
) do
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(file_path, changes)
result = Transaction.commit(txn)
case result do
{:ok, txn_result} ->
Logger.info("Convert visibility completed successfully")
{:ok,
%{
status: :success,
files_modified: 1,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Convert visibility failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: 0,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Convert visibility failed: #{inspect(reason)}")
error
end
end
end
@doc """
Renames a function parameter and all its references within the function body.
## Parameters
- `module_name`: Module containing the function
- `function_name`: Function to modify
- `arity`: Function arity
- `old_param_name`: Current parameter name
- `new_param_name`: New parameter name
- `opts`: Options
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure
## Examples
# Rename parameter x to input
Refactor.rename_parameter(:MyModule, :process, 1, :x, :input)
"""
@spec rename_parameter(
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
atom() | String.t(),
atom() | String.t(),
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def rename_parameter(
module_name,
function_name,
arity,
old_param_name,
new_param_name,
opts \\ []
) do
module_atom = to_atom(module_name)
function_atom = to_atom(function_name)
old_param = to_atom(old_param_name)
new_param = to_atom(new_param_name)
Logger.info(
"Starting refactor: rename parameter #{old_param} to #{new_param} in #{module_atom}.#{function_atom}/#{arity}"
)
case Store.find_node(:module, module_atom) do
nil ->
{:error, "Module #{module_atom} not found in graph"}
module_node ->
file_path = module_node[:file]
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
ElixirRefactor.rename_parameter(
content,
module_atom,
function_atom,
arity,
old_param,
new_param,
opts
) do
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(file_path, changes)
result = Transaction.commit(txn)
case result do
{:ok, txn_result} ->
Logger.info("Rename parameter completed successfully")
{:ok,
%{
status: :success,
files_modified: 1,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Rename parameter failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: 0,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Rename parameter failed: #{inspect(reason)}")
error
end
end
end
@doc """
Adds, removes, or updates module attributes.
## Parameters
- `module_name`: Module to modify
- `changes`: Map with :add, :remove, and/or :update keys
- `opts`: Options
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure
## Examples
# Add and update attributes
changes = %{
add: [{:vsn, "1.0.0"}],
remove: [:deprecated],
update: [{:moduledoc, "Updated docs"}]
}
Refactor.modify_attributes(:MyModule, changes)
"""
@spec modify_attributes(atom() | String.t(), map(), keyword()) ::
{:ok, refactor_result()} | {:error, term()}
def modify_attributes(module_name, changes, opts \\ []) do
module_atom = to_atom(module_name)
Logger.info("Starting refactor: modify attributes in #{module_atom}")
case Store.find_node(:module, module_atom) do
nil ->
{:error, "Module #{module_atom} not found in graph"}
module_node ->
file_path = module_node[:file]
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <- ElixirRefactor.modify_attributes(content, changes, opts) do
lines = String.split(content, "\n")
line_count = length(lines)
changes_list = [Types.replace(1, line_count, new_content)]
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(file_path, changes_list)
result = Transaction.commit(txn)
case result do
{:ok, txn_result} ->
Logger.info("Modify attributes completed successfully")
{:ok,
%{
status: :success,
files_modified: 1,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Modify attributes failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: 0,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Modify attributes failed: #{inspect(reason)}")
error
end
end
end
@doc """
Changes a function signature by adding, removing, reordering, or renaming parameters.
## Parameters
- `module_name`: Module containing the function
- `function_name`: Function to modify
- `old_arity`: Current function arity
- `signature_changes`: Map describing the changes (see details below)
- `opts`: Options
- `:scope` - :module (same file only) or :project (all files, default: :project)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Signature Changes Format
The `signature_changes` map can contain:
- `:add_params` - List of params to add with defaults
- `:remove_params` - List of param positions to remove (0-indexed)
- `:reorder_params` - New param order
- `:rename_params` - List of renames
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Add an optional parameter
changes = %{add_params: [%{name: :opts, position: 2, default: []}]}
Refactor.change_signature(:MyModule, :process, 2, changes)
# Remove second parameter and rename first
changes = %{
remove_params: [1],
rename_params: [{:old_name, :new_name}]
}
Refactor.change_signature(:MyModule, :transform, 3, changes)
"""
@spec change_signature(
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
map(),
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def change_signature(module_name, function_name, old_arity, signature_changes, opts \\ []) do
module_atom = to_atom(module_name)
function_atom = to_atom(function_name)
scope = Keyword.get(opts, :scope, :project)
Logger.info(
"Starting refactor: change signature #{module_atom}.#{function_atom}/#{old_arity} (scope: #{scope})"
)
with {:ok, affected_files} <-
find_affected_files(module_atom, function_atom, old_arity, scope),
{:ok, transaction} <-
build_signature_change_transaction(
affected_files,
function_atom,
old_arity,
signature_changes,
opts
),
result <- Transaction.commit(transaction) do
case result do
{:ok, txn_result} ->
Logger.info("Change signature completed: #{txn_result.files_edited} files modified")
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Change signature failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Change signature failed during preparation: #{inspect(reason)}")
error
end
end
# Build transaction for signature change
defp build_signature_change_transaction(
files,
function_name,
old_arity,
signature_changes,
opts
) do
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
txn = Transaction.new(validate: validate, format: format, create_backup: true)
result =
Enum.reduce_while(files, {:ok, txn}, fn file_path, {:ok, transaction_acc} ->
case refactor_file_signature(file_path, function_name, old_arity, signature_changes) do
{:ok, changes} ->
{:cont, {:ok, Transaction.add(transaction_acc, file_path, changes)}}
{:error, reason} ->
{:halt, {:error, "Failed to change signature in #{file_path}: #{inspect(reason)}"}}
end
end)
case result do
{:ok, _transaction} = success -> success
{:error, _reason} = error -> error
end
end
# Generate changes for signature change in a file
defp refactor_file_signature(file_path, function_name, old_arity, signature_changes) do
language = detect_language(file_path)
with {:ok, content} <- File.read(file_path),
{:ok, new_content} <-
refactor_signature_content(
content,
function_name,
old_arity,
signature_changes,
language
) do
lines = String.split(content, "\n")
line_count = length(lines)
changes = [Types.replace(1, line_count, new_content)]
{:ok, changes}
end
end
# Refactor signature in content based on language
defp refactor_signature_content(content, function_name, old_arity, signature_changes, language) do
case language do
:elixir ->
ElixirRefactor.change_signature(
content,
:DummyModule,
function_name,
old_arity,
signature_changes
)
:erlang ->
{:error, "Erlang signature change not yet implemented"}
:ruby ->
{:error, "Ruby signature change not yet implemented"}
_ ->
{:error, "Signature change not supported for language: #{language}"}
end
end
@doc """
Moves a function from one module to another.
## Parameters
- `source_module`: Source module name
- `target_module`: Target module name
- `function_name`: Function to move
- `arity`: Function arity
- `opts`: Options
- `:placement` - :start | :end (default: :end)
- `:update_references` - boolean (default: true)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Move function to existing module
Refactor.move_function(:MyModule, :MyModule.Utils, :helper, 1)
"""
@spec move_function(
atom() | String.t(),
atom() | String.t(),
atom() | String.t(),
non_neg_integer(),
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def move_function(source_module, target_module, function_name, arity, opts \\ []) do
source_atom = to_atom(source_module)
target_atom = to_atom(target_module)
function_atom = to_atom(function_name)
Logger.info(
"Starting refactor: move #{source_atom}.#{function_atom}/#{arity} to #{target_atom}"
)
with {:ok, source_file} <- get_module_file(source_atom),
{:ok, source_content} <- File.read(source_file),
target_result <- get_module_file(target_atom),
target_content <- read_target_content(target_result),
{:ok, result} <-
ElixirRefactor.move_function(
source_content,
target_content,
source_atom,
target_atom,
function_atom,
arity,
opts
) do
# Determine target file path
target_file =
case target_result do
{:ok, path} -> path
{:error, _} -> derive_file_path(target_atom)
end
# Build transaction
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
source_lines = String.split(source_content, "\n")
source_changes = [Types.replace(1, length(source_lines), result.source)]
target_lines = String.split(target_content || "", "\n")
target_line_count = if target_content, do: length(target_lines), else: 0
target_changes =
if target_line_count > 0 do
[Types.replace(1, target_line_count, result.target)]
else
[Types.insert(1, result.target)]
end
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(source_file, source_changes)
|> Transaction.add(target_file, target_changes)
case Transaction.commit(txn) do
{:ok, txn_result} ->
Logger.info("Move function completed: #{txn_result.files_edited} files modified")
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result
}}
{:error, txn_result} ->
Logger.error("Move function failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Move function failed: #{inspect(reason)}")
error
end
end
@doc """
Extracts multiple functions from a module into a new module.
## Parameters
- `source_module`: Source module name
- `new_module`: New module name
- `functions`: List of {function_name, arity} tuples
- `opts`: Options
- `:file_path` - Explicit path for new module (optional)
- `:add_moduledoc` - boolean (default: true)
- `:update_aliases` - boolean (default: true)
- `:validate` - boolean (default: true)
- `:format` - boolean (default: true)
## Returns
- `{:ok, result}` on success
- `{:error, reason}` on failure (with rollback)
## Examples
# Extract helpers into new module
functions = [{:helper1, 1}, {:helper2, 2}]
Refactor.extract_module(:MyModule, :MyModule.Helpers, functions)
"""
@spec extract_module(
atom() | String.t(),
atom() | String.t(),
[{atom(), non_neg_integer()}],
keyword()
) :: {:ok, refactor_result()} | {:error, term()}
def extract_module(source_module, new_module, functions, opts \\ []) do
source_atom = to_atom(source_module)
new_atom = to_atom(new_module)
Logger.info("Starting refactor: extract module #{new_atom} from #{source_atom}")
with {:ok, source_file} <- get_module_file(source_atom),
{:ok, source_content} <- File.read(source_file),
{:ok, result} <-
ElixirRefactor.extract_module(
source_content,
source_atom,
new_atom,
functions,
opts
) do
# Determine new module file path
new_file = Keyword.get(opts, :file_path) || derive_file_path(new_atom)
# Build transaction
validate = Keyword.get(opts, :validate, true)
format = Keyword.get(opts, :format, true)
source_lines = String.split(source_content, "\n")
source_changes = [Types.replace(1, length(source_lines), result.source)]
# For new file, use insert
new_changes = [Types.insert(1, result.target)]
txn =
Transaction.new(validate: validate, format: format, create_backup: true)
|> Transaction.add(source_file, source_changes)
|> Transaction.add(new_file, new_changes)
case Transaction.commit(txn) do
{:ok, txn_result} ->
Logger.info("Extract module completed: #{txn_result.files_edited} files modified")
{:ok,
%{
status: :success,
files_modified: txn_result.files_edited,
transaction_result: txn_result,
new_file: new_file
}}
{:error, txn_result} ->
Logger.error("Extract module failed: #{inspect(txn_result.errors)}")
{:error,
%{
status: :failure,
files_modified: txn_result.files_edited,
rolled_back: txn_result.rolled_back,
errors: txn_result.errors
}}
end
else
{:error, reason} = error ->
Logger.error("Extract module failed: #{inspect(reason)}")
error
end
end
# Helper to get module file from graph
defp get_module_file(module_atom) do
case Store.find_node(:module, module_atom) do
nil -> {:error, "Module #{module_atom} not found in graph"}
node -> {:ok, node[:file]}
end
end
# Helper to read target content (may not exist)
defp read_target_content({:ok, path}) do
case File.read(path) do
{:ok, content} -> content
{:error, _} -> nil
end
end
defp read_target_content({:error, _}), do: nil
# Derive file path from module name
defp derive_file_path(module_atom) do
# Convert MyModule.SubModule to lib/my_module/sub_module.ex
parts =
module_atom
|> Atom.to_string()
|> String.split(".")
|> Enum.map(&Macro.underscore/1)
filename = List.last(parts) <> ".ex"
dir_parts = Enum.drop(parts, -1)
path_parts = ["lib" | dir_parts] ++ [filename]
Path.join(path_parts)
end
# Find project root from a file path
defp find_project_root(file_path) do
# Walk up directory tree looking for mix.exs, rebar.config, or similar
dir = Path.dirname(file_path)
cond do
File.exists?(Path.join(dir, "mix.exs")) -> dir
File.exists?(Path.join(dir, "rebar.config")) -> dir
File.exists?(Path.join(dir, "Gemfile")) -> dir
File.exists?(Path.join(dir, "package.json")) -> dir
dir == "/" -> file_path
true -> find_project_root(dir)
end
end
defp detect_language(file_path), do: Ragex.LanguageSupport.detect_language(file_path)
end