Packages
fnord
0.8.22
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/tools/file/edit.ex
defmodule AI.Tools.File.Edit do
@moduledoc """
Currently, this module is not used as a tool_call directly. Instead, it is
used by `AI.Tools.Coder`, the tool directly called by the AI agent. This tool
is instead used to perform the file edits themselves, after the AI-backed
tool does the work of identifying the lines to edit and the replacement text.
"""
@behaviour AI.Tools
@doc """
This tool relies on line numbers within the file to identify ranges. If those
numbers change between the time the range is identified and the time the
changes are applied, the tool will fail to apply the changes correctly.
"""
@impl AI.Tools
def async?, do: false
@impl AI.Tools
def is_available?, do: true
@impl AI.Tools
def read_args(args) do
with {:ok, path} <- AI.Tools.get_arg(args, "path"),
{:ok, start_line} <- AI.Tools.get_arg(args, "start_line"),
{:ok, end_line} <- AI.Tools.get_arg(args, "end_line"),
{:ok, replacement} <- AI.Tools.get_arg(args, "replacement", true) do
{:ok,
%{
"path" => path,
"start_line" => start_line,
"end_line" => end_line,
"replacement" => replacement
}}
end
end
@impl AI.Tools
def ui_note_on_request(%{
"path" => path,
"start_line" => start_line,
"end_line" => end_line,
"replacement" => replacement
}) do
{"Editing file #{path}[#{start_line}...#{end_line}]", replacement}
end
@impl AI.Tools
def ui_note_on_result(_, result) do
{"Changes applied", result}
end
@impl AI.Tools
def spec do
%{
type: "function",
function: %{
name: "file_edit_tool",
description: """
Edit a file within the project source root by replacing a specified range of lines with new text.
Use the file_contents_tool with the line_numbers parameter to preview or identify the line range to edit.
- Line numbers are 1-based and inclusive.
- start_line must be > 0 and <= end_line.
- end_line must be >= start_line and <= total number of lines in the file.
- You must produce the exact text to replace the specified lines with, along with any necessary newlines, INCLUDING one at the end of the final line of the replacement text.
- It is YOUR responsibility to verify the changes after applying them.
- Use the dry_run option to test your changes before applying them.
- Line numbers will have changed after the edit. You MUST retrieve the updated file with line numbers if you plan to make additional changes.
- Use the file_manage_tool to restore the file from backup if the changes are incorrect, then try again.
""",
parameters: %{
type: "object",
required: ["path", "start_line", "end_line", "replacement"],
properties: %{
path: %{
type: "string",
description:
"Path (relative to project root) of the file to operate on (or *source path* for move). Must be within the project source root."
},
start_line: %{
type: "integer",
description: "The starting line number for the edit (1-based index, inclusive)."
},
end_line: %{
type: "integer",
description: "The ending line number for the edit (1-based index, inclusive)."
},
replacement: %{
type: "string",
description:
"The exact text or code to replace the specified lines with. If you intend to fully replace lines, end with a newline."
},
dry_run: %{
type: "boolean",
description:
"If true, the tool will simulate the edit without making any changes. Defaults to false. Returns the updated block, with +/- <context_lines> lines of context around the edit."
},
context_lines: %{
type: "integer",
description:
"Number of lines of context to include around the edited block in the dry run output. Defaults to 3."
}
}
}
}
}
end
@impl AI.Tools
def call(args) do
with {:ok, path} <- AI.Tools.get_arg(args, "path"),
{:ok, start_line} <- AI.Tools.get_arg(args, "start_line"),
{:ok, end_line} <- AI.Tools.get_arg(args, "end_line"),
{:ok, replacement} <- AI.Tools.get_arg(args, "replacement", true),
dry_run <- Map.get(args, "dry_run", false),
context_lines <- Map.get(args, "context_lines", 3),
{:ok, project} <- Store.get_project(),
abs_path <- Store.Project.expand_path(path, project),
:ok <- validate_path(abs_path, project.source_root),
{:ok, contents} <- File.read(abs_path),
:ok <- validate_range(contents, start_line, end_line) do
updated_contents = make_changes(contents, start_line, end_line, replacement)
if dry_run do
{:ok, dry_run_preview(contents, updated_contents, start_line, end_line, context_lines)}
else
with {:ok, backup} <- backup_file(abs_path),
{:ok, temp} <- Briefly.create(),
:ok <- File.cp(path, temp),
:ok <- File.write(temp, updated_contents),
:ok <- File.rename(temp, abs_path) do
{:ok,
"""
#{path} was modified successfully.
#{path} is backed up as #{backup}.
Remember that after making changes, the line numbers within the file have likely changed.
Use the file_contents_tool with the line_numbers parameter to get the updated line numbers.
"""}
end
end
else
{:error, :enoent} ->
{:error,
"File #{args["path"]} not found. Do you need to create it first with the file_manage_tool?"}
{:error, reason} ->
{:error, "Failed to edit file: #{inspect(reason)}"}
end
end
defp validate_path(path, root) do
cond do
!Util.path_within_root?(path, root) -> {:error, "not within project root"}
!File.exists?(path) -> {:error, :enoent}
true -> :ok
end
end
defp validate_range(file, start_line, end_line) do
lines = num_lines(file)
cond do
start_line < 1 ->
{:error, "Start line must be greater than or equal to 1."}
start_line > lines ->
{:error, "Start line exceeds the number of lines in the file."}
end_line < start_line ->
{:error, "End line must be greater than or equal to start line."}
lines > 0 && end_line > lines ->
{:error, "End line exceeds the number of lines in the file."}
true ->
:ok
end
end
defp num_lines(file) do
file
|> String.split("\n", trim: false)
|> length()
end
defp backup_file(file) do
with {:ok, backup} <- Once.get(file) do
{:ok, backup}
else
{:error, :not_seen} ->
case backup_file(file, 0) do
{:ok, backup} ->
Once.set(file, backup)
{:ok, backup}
{:error, reason} ->
{:error, reason}
end
end
end
defp backup_file(orig, bak_number) do
backup = "#{orig}.bak.#{bak_number}"
if File.exists?(backup) do
backup_file(orig, bak_number + 1)
else
case File.cp(orig, backup) do
:ok -> {:ok, backup}
{:error, reason} -> {:error, reason}
end
end
end
defp dry_run_preview(original, updated, start_line, end_line, context_lines) do
orig_lines = String.split(original, "\n", trim: false)
updated_lines = String.split(updated, "\n", trim: false)
# 1-based to 0-based indices
start_idx = start_line - 1
end_idx = end_line - 1
before_context = max(start_idx - context_lines, 0)
after_context =
min(end_idx + context_lines, max(length(orig_lines), length(updated_lines)) - 1)
# Extract relevant lines from both original and updated
orig_snippet = Enum.slice(orig_lines, before_context..after_context)
updated_snippet = Enum.slice(updated_lines, before_context..after_context)
# Build simple diff output with +/- prefixes
diff =
Enum.zip(orig_snippet, updated_snippet)
|> Enum.map(fn
{o, u} when o == u ->
" #{o}"
{o, u} ->
"""
- #{o}
+ #{u}
"""
end)
|> Enum.join("\n")
# Number the snippets for clarity
orig_snippet =
orig_snippet
|> Enum.join("\n")
|> Util.numbered_lines("|", start_line - context_lines)
updated_snippet =
updated_snippet
|> Enum.join("\n")
|> Util.numbered_lines("|", start_line - context_lines)
"""
--- UNIFIED DIFF ---
#{diff}
--- ORIGINAL (with context) ---
#{orig_snippet}
--- UPDATED (with context) ---
#{updated_snippet}
"""
end
defp make_changes(input, start_line, end_line, replacement) do
{start_idx, end_idx} = line_range_indices(input, start_line, end_line)
prefix = binary_part(input, 0, start_idx)
suffix = binary_part(input, end_idx, byte_size(input) - end_idx)
prefix <> replacement <> suffix
end
defp line_range_indices(subject, start_line, end_line) do
starts = line_start_offsets(subject)
total_lines = length(starts)
s = max(min(start_line, total_lines), 1) - 1
e = max(min(end_line, total_lines), 1) - 1
start_idx = Enum.at(starts, s)
end_idx =
if e + 1 < total_lines do
Enum.at(starts, e + 1)
else
byte_size(subject)
end
{start_idx, end_idx}
end
defp line_start_offsets(bin) do
# All line start offsets, including at EOF if file ends with newline
offsets = [0]
offsets =
bin
|> :binary.matches("\n")
|> Enum.reduce(offsets, fn {idx, 1}, acc -> [idx + 1 | acc] end)
|> Enum.reverse()
offsets
end
end