Current section
Files
Jump to
Current section
Files
lib/cmdc/tool/grep.ex
defmodule CMDC.Tool.Grep do
@moduledoc """
跨文件 regex 内容搜索,支持 glob 过滤。
- 有 Sandbox 时走 `sandbox.grep/3`(返回结构化 match list)
- 无 Sandbox 时走内置纯 Elixir 递归遍历实现
返回带行号标签的匹配行(`N|content`),与 `read_file` 的行号格式兼容,
便于后续用 `edit_file` 定位修改。
"""
@behaviour CMDC.Tool
@dialyzer {:no_opaque, [walk_dir: 4]}
@skip_dirs MapSet.new(~w(
.git .hg .svn
_build deps node_modules
.elixir_ls .vscode .idea
__pycache__ .mypy_cache
vendor .bundle
tmp
))
@max_results_default 50
@max_context_default 2
@max_output_bytes 50 * 1024
@max_depth 25
@impl true
def name, do: "grep"
@impl true
def description,
do:
"Search file contents with a regex pattern. " <>
"Returns matching lines with line number tags compatible with edit_file."
@impl true
def meta(%{"pattern" => pat}), do: "Grep #{pat}"
def meta(_), do: "Grep"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"pattern" => %{
"type" => "string",
"description" => "Regex pattern to search for"
},
"path" => %{
"type" => "string",
"description" =>
"File or directory to search, relative to working_dir. Defaults to working_dir root."
},
"include" => %{
"type" => "string",
"description" => "Glob to filter filenames (e.g. \"*.ex\", \"*.{ex,exs}\")"
},
"context_lines" => %{
"type" => "integer",
"description" =>
"Lines of surrounding context per match (default: #{@max_context_default})"
},
"max_results" => %{
"type" => "integer",
"description" => "Maximum number of matching lines (default: #{@max_results_default})"
}
},
"required" => ["pattern"]
}
end
@impl true
def execute(%{"pattern" => pattern} = args, %CMDC.Context{} = ctx) do
search_path = Map.get(args, "path", ".")
include = Map.get(args, "include")
ctx_lines = clamp(Map.get(args, "context_lines", @max_context_default), 0, 10)
max_results = clamp(Map.get(args, "max_results", @max_results_default), 1, 500)
if ctx.sandbox do
sandbox_grep(pattern, search_path, include, max_results, ctx)
else
local_grep(pattern, search_path, include, ctx_lines, max_results, ctx.working_dir)
end
end
def execute(_args, _ctx), do: {:error, "Missing required parameter: pattern"}
# ==========================================================================
# Sandbox 路径
# ==========================================================================
defp sandbox_grep(pattern, search_path, include, max_results, ctx) do
opts =
[working_dir: ctx.working_dir, max_results: max_results]
|> maybe_add(:include, include)
case ctx.sandbox.grep(pattern, search_path, opts) do
{:ok, matches} when matches == [] ->
{:ok, "No matches found."}
{:ok, matches} ->
output = format_sandbox_matches(matches)
{:ok, maybe_truncate(output)}
{:error, reason} ->
{:error, reason}
end
end
defp format_sandbox_matches(matches) do
groups =
Enum.group_by(matches, & &1.file)
Enum.map_join(groups, "\n\n", fn {file, file_matches} ->
lines =
Enum.map_join(file_matches, "\n", fn m ->
"#{m.line}|#{m.content}"
end)
"## #{file}\n#{lines}"
end) <>
"\n\n#{length(matches)} match#{if length(matches) == 1, do: "", else: "es"} found."
end
# ==========================================================================
# 本地递归路径
# ==========================================================================
defp local_grep(pattern, search_path, include, ctx_lines, max_results, working_dir) do
resolved =
if Path.type(search_path) == :absolute,
do: search_path,
else: Path.join(working_dir, search_path)
case Regex.compile(pattern) do
{:ok, regex} ->
files = collect_files(resolved, include)
{results, total, capped?} =
search_files(files, regex, ctx_lines, max_results, working_dir)
if results == [] do
{:ok, "No matches found."}
else
output = format_results(results, total, capped?)
{:ok, maybe_truncate(output)}
end
{:error, {reason, _}} ->
{:error, "Invalid regex pattern: #{reason}"}
end
end
defp collect_files(path, include) do
if File.regular?(path) do
if matches_glob?(Path.basename(path), include), do: [path], else: []
else
walk_dir(path, include, 0, false)
end
end
defp walk_dir(_dir, _include, depth, _no_ignore) when depth > @max_depth, do: []
defp walk_dir(dir, include, depth, no_ignore) do
case File.ls(dir) do
{:ok, entries} ->
entries
|> Enum.sort()
|> Enum.flat_map(&walk_entry(dir, &1, include, depth, no_ignore))
{:error, _} ->
[]
end
end
defp walk_entry(dir, entry, include, depth, no_ignore) do
full = Path.join(dir, entry)
cond do
skip_dir?(entry) -> []
File.dir?(full) -> walk_dir(full, include, depth + 1, no_ignore)
matches_glob?(entry, include) -> [full]
true -> []
end
end
defp skip_dir?(name), do: MapSet.member?(@skip_dirs, name)
defp matches_glob?(_filename, nil), do: true
defp matches_glob?(filename, pattern) do
case Regex.compile(glob_to_regex(pattern)) do
{:ok, re} -> Regex.match?(re, filename)
{:error, _} -> true
end
end
defp glob_to_regex(glob) do
glob
|> String.replace(".", "\\.")
|> String.replace("**", ".*")
|> String.replace("*", "[^/]*")
|> String.replace("?", "[^/]")
|> then(&"^#{&1}$")
end
defp search_files(files, regex, ctx_lines, max_results, working_dir) do
Enum.reduce_while(files, {[], 0, false}, fn file, {acc, count, _} ->
handle_file_search(file, regex, ctx_lines, max_results, working_dir, acc, count)
end)
end
defp handle_file_search(file, regex, ctx_lines, max_results, working_dir, acc, count) do
case search_file(file, regex, ctx_lines, max_results - count, working_dir) do
{:ok, matches, match_count} when match_count > 0 ->
new_count = count + match_count
new_acc = acc ++ [matches]
if new_count >= max_results,
do: {:halt, {new_acc, new_count, true}},
else: {:cont, {new_acc, new_count, false}}
_ ->
{:cont, {acc, count, false}}
end
end
defp search_file(file, regex, ctx_lines, remaining, working_dir) do
with {:ok, raw} <- File.read(file),
true <- String.valid?(raw) do
content = String.replace(raw, "\r\n", "\n")
lines = String.split(content, "\n")
match_indices = find_matching(lines, regex)
if match_indices == [] do
{:ok, nil, 0}
else
capped = Enum.take(match_indices, remaining)
ranges = build_context_ranges(capped, ctx_lines, length(lines))
rel_path = Path.relative_to(file, working_dir)
tagged = build_tagged_ranges(ranges, lines)
{:ok, {rel_path, tagged}, length(capped)}
end
else
_ -> {:ok, nil, 0}
end
end
defp build_tagged_ranges(ranges, lines) do
Enum.map(ranges, fn {range, match_set} ->
Enum.map(range, fn idx ->
line = Enum.at(lines, idx)
is_match = MapSet.member?(match_set, idx)
{"#{idx + 1}|#{line}", is_match}
end)
end)
end
defp find_matching(lines, regex) do
lines
|> Enum.with_index()
|> Enum.filter(fn {line, _} -> Regex.match?(regex, line) end)
|> Enum.map(fn {_, idx} -> idx end)
end
defp build_context_ranges(indices, ctx_lines, total) do
match_set = MapSet.new(indices)
indices
|> Enum.map(fn idx ->
lo = max(idx - ctx_lines, 0)
hi = min(idx + ctx_lines, total - 1)
{lo, hi}
end)
|> merge_ranges()
|> Enum.map(fn {lo, hi} -> {lo..hi, match_set} end)
end
defp merge_ranges([]), do: []
defp merge_ranges([first | rest]) do
Enum.reduce(rest, [first], fn {lo, hi}, [{prev_lo, prev_hi} | acc] ->
if lo <= prev_hi + 1,
do: [{prev_lo, max(prev_hi, hi)} | acc],
else: [{lo, hi}, {prev_lo, prev_hi} | acc]
end)
|> Enum.reverse()
end
defp format_results(results, total, capped?) do
sections =
results
|> Enum.reject(&is_nil/1)
|> Enum.map_join("\n\n", &format_file_result/1)
note =
if capped?,
do: "\n\n[Showing #{total} matches. Results capped — refine pattern or path.]",
else: "\n\n#{total} match#{if total == 1, do: "", else: "es"} found."
sections <> note
end
defp format_file_result({rel_path, groups}) do
lines = Enum.map_join(groups, "\n---\n", &format_match_group/1)
"## #{rel_path}\n#{lines}"
end
defp format_match_group(group) do
Enum.map_join(group, "\n", fn {tagged, _} -> tagged end)
end
defp maybe_truncate(output) when byte_size(output) > @max_output_bytes do
output <> "\n\n[Output truncated at #{div(@max_output_bytes, 1024)}KB.]"
end
defp maybe_truncate(output), do: output
defp clamp(val, min_val, _max_val) when is_integer(val) and val < min_val, do: min_val
defp clamp(val, _min_val, max_val) when is_integer(val) and val > max_val, do: max_val
defp clamp(val, _min_val, _max_val) when is_integer(val), do: val
defp clamp(_val, min_val, _max_val), do: min_val
defp maybe_add(opts, _key, nil), do: opts
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
end