Current section
Files
Jump to
Current section
Files
lib/codex/tools/file_search_tool.ex
defmodule Codex.Tools.FileSearchTool do
@moduledoc """
Hosted tool for searching files by name pattern and content.
This tool provides local filesystem search capabilities using glob patterns
for file discovery and optional regex content matching.
## Options
Options can be passed during registration or via context:
* `:base_path` - Base directory for search (default: cwd)
* `:max_results` - Maximum results to return (default: 100)
* `:include_hidden` - Include hidden files (default: false)
* `:case_sensitive` - Case-sensitive matching (default: true)
## Usage
### Direct Invocation
args = %{"pattern" => "**/*.ex", "base_path" => "/project"}
{:ok, result} = Codex.Tools.FileSearchTool.invoke(args, %{})
# => %{"count" => 42, "files" => [%{"path" => "lib/foo.ex"}, ...]}
### With Content Search
args = %{
"pattern" => "**/*.ex",
"content" => "defmodule",
"case_sensitive" => false
}
{:ok, result} = Codex.Tools.FileSearchTool.invoke(args, %{})
# => %{"count" => 10, "files" => [%{"path" => "lib/foo.ex", "matches" => [...]}]}
### With Registry
{:ok, _handle} = Codex.Tools.register(Codex.Tools.FileSearchTool,
base_path: "/project",
max_results: 50
)
{:ok, result} = Codex.Tools.invoke("file_search", %{"pattern" => "*.ex"}, %{})
## Result Format
Results are returned as a map with:
* `"count"` - Number of matching files
* `"files"` - List of file matches, each with:
* `"path"` - Relative path from base_path
* `"matches"` - (optional) List of content matches with line numbers
## Pattern Syntax
Uses Elixir's `Path.wildcard/2` for glob patterns:
* `*` - Matches any characters except path separators
* `**` - Matches any characters including path separators (recursive)
* `?` - Matches a single character
* `[abc]` - Matches any character in the brackets
* `{a,b}` - Matches either pattern
Examples:
* `"*.ex"` - All `.ex` files in base directory
* `"**/*.ex"` - All `.ex` files recursively
* `"lib/**/*.{ex,exs}"` - All Elixir files under lib/
* `"test/*_test.exs"` - All test files in test/
"""
@behaviour Codex.Tool
alias Codex.Tools.Hosted
@default_max_results 100
@impl true
def metadata do
%{
name: "file_search",
description: "Search for files by name pattern or content",
schema: %{
"type" => "object",
"properties" => %{
"pattern" => %{
"type" => "string",
"description" => "Glob pattern for file names (e.g., '**/*.ex')"
},
"content" => %{
"type" => "string",
"description" => "Text or regex to search within files (optional)"
},
"base_path" => %{
"type" => "string",
"description" => "Base directory for search (optional)"
},
"max_results" => %{
"type" => "integer",
"description" => "Maximum number of results (default: 100)"
},
"case_sensitive" => %{
"type" => "boolean",
"description" => "Case-sensitive search (default: true)"
}
},
"required" => ["pattern"],
"additionalProperties" => false
}
}
end
@impl true
def invoke(args, context) do
metadata = Map.get(context, :metadata, %{})
pattern = Map.fetch!(args, "pattern")
content = Map.get(args, "content")
base_path =
Map.get(args, "base_path") ||
Hosted.metadata_value(metadata, :base_path) ||
Map.get(context, :base_path) ||
File.cwd!()
max_results =
Map.get(args, "max_results") ||
Hosted.metadata_value(metadata, :max_results, @default_max_results)
case_sensitive =
case Map.get(args, "case_sensitive") do
nil -> get_boolean_option(metadata, :case_sensitive, true)
value -> value
end
include_hidden = get_boolean_option(metadata, :include_hidden, false)
with {:ok, files} <- find_files(pattern, base_path, include_hidden),
{:ok, matched} <- maybe_search_content(files, content, case_sensitive, base_path),
results <- limit_results(matched, max_results) do
{:ok, format_result(results)}
end
end
defp find_files(pattern, base_path, include_hidden) do
full_pattern = Path.join(base_path, pattern)
files =
full_pattern
|> Path.wildcard(match_dot: include_hidden)
|> Enum.filter(&File.regular?/1)
|> Enum.map(&Path.relative_to(&1, base_path))
|> Enum.sort()
{:ok, files}
rescue
e -> {:error, {:glob_error, Exception.message(e)}}
end
defp maybe_search_content(files, nil, _case_sensitive, _base_path) do
{:ok, Enum.map(files, &%{path: &1, matches: nil})}
end
defp maybe_search_content(files, content, case_sensitive, base_path) do
regex_opts = if case_sensitive, do: [], else: [:caseless]
case Regex.compile(content, regex_opts) do
{:ok, regex} ->
results =
files
|> Enum.map(fn path ->
full_path = Path.join(base_path, path)
matches = search_file(full_path, regex)
%{path: path, matches: matches}
end)
|> Enum.filter(fn %{matches: m} -> m != [] end)
{:ok, results}
{:error, {reason, _position}} ->
{:error, {:regex_error, reason}}
end
end
defp search_file(path, regex) do
case File.read(path) do
{:ok, content} -> search_content(content, regex)
{:error, _} -> []
end
end
# Search content if it's valid UTF-8 text, otherwise skip binary files
defp search_content(content, regex) do
if String.valid?(content) do
content
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.filter(fn {line, _} -> Regex.match?(regex, line) end)
|> Enum.map(fn {line, num} -> %{line_number: num, text: String.trim(line)} end)
else
[]
end
end
defp limit_results(results, max) do
Enum.take(results, max)
end
defp format_result(results) do
%{
"count" => length(results),
"files" =>
Enum.map(results, fn r ->
base = %{"path" => r.path}
if r.matches do
Map.put(
base,
"matches",
Enum.map(r.matches, fn m ->
%{"line" => m.line_number, "text" => m.text}
end)
)
else
base
end
end)
}
end
# Helper to get boolean options that properly handles false values.
# The Hosted.metadata_value/3 function uses || which treats false as falsy,
# so we need to use Map.fetch/2 instead.
defp get_boolean_option(metadata, key, default) do
case Map.fetch(metadata, key) do
{:ok, value} when is_boolean(value) ->
value
_ ->
case Map.fetch(metadata, to_string(key)) do
{:ok, value} when is_boolean(value) -> value
_ -> default
end
end
end
end