Skip to content

Commit

Permalink
fix: scan directory (#1362)
Browse files Browse the repository at this point in the history
  • Loading branch information
yetone authored Feb 23, 2025
1 parent 284998a commit e93f242
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 78 deletions.
2 changes: 1 addition & 1 deletion lua/avante/file_selector.lua
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ end

local function get_project_filepaths()
local project_root = Utils.get_project_root()
local files = Utils.scan_directory_respect_gitignore({ directory = project_root, add_dirs = true })
local files = Utils.scan_directory({ directory = project_root, add_dirs = true })
files = vim.iter(files):map(function(filepath) return Path:new(filepath):make_relative(project_root) end):totable()

return vim.tbl_map(function(path)
Expand Down
15 changes: 7 additions & 8 deletions lua/avante/llm_tools.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,19 @@ local function has_permission_to_access(abs_path)
return not Utils.is_ignored(abs_path, gitignore_patterns, gitignore_negate_patterns)
end

---@param opts { rel_path: string, depth?: integer }
---@param opts { rel_path: string, max_depth?: integer }
---@param on_log? fun(log: string): nil
---@return string files
---@return string|nil error
function M.list_files(opts, on_log)
local abs_path = get_abs_path(opts.rel_path)
if not has_permission_to_access(abs_path) then return "", "No permission to access path: " .. abs_path end
if on_log then on_log("path: " .. abs_path) end
if on_log then on_log("depth: " .. tostring(opts.depth)) end
local files = Utils.scan_directory_respect_gitignore({
if on_log then on_log("max depth: " .. tostring(opts.max_depth)) end
local files = Utils.scan_directory({
directory = abs_path,
add_dirs = true,
depth = opts.depth,
max_depth = opts.max_depth,
})
local filepaths = {}
for _, file in ipairs(files) do
Expand All @@ -62,7 +62,7 @@ function M.search_files(opts, on_log)
if not has_permission_to_access(abs_path) then return "", "No permission to access path: " .. abs_path end
if on_log then on_log("path: " .. abs_path) end
if on_log then on_log("keyword: " .. opts.keyword) end
local files = Utils.scan_directory_respect_gitignore({
local files = Utils.scan_directory({
directory = abs_path,
})
local filepaths = {}
Expand Down Expand Up @@ -710,10 +710,9 @@ M._tools = {
type = "string",
},
{
name = "depth",
description = "Depth of the directory",
name = "max_depth",
description = "Maximum depth of the directory",
type = "integer",
optional = true,
},
},
},
Expand Down
7 changes: 0 additions & 7 deletions lua/avante/repo_map.lua
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
local Popup = require("nui.popup")
local Utils = require("avante.utils")
local event = require("nui.utils.autocmd").event
local Config = require("avante.config")

local filetype_map = {
["javascriptreact"] = "javascript",
Expand Down Expand Up @@ -35,15 +34,9 @@ end

function RepoMap._build_repo_map(project_root, file_ext)
local output = {}
local gitignore_path = project_root .. "/.gitignore"
local gitignore_patterns, gitignore_negate_patterns = Utils.parse_gitignore(gitignore_path)
local ignore_patterns = vim.list_extend(gitignore_patterns, Config.repo_map.ignore_patterns)
local negate_patterns = vim.list_extend(gitignore_negate_patterns, Config.repo_map.negate_patterns)

local filepaths = Utils.scan_directory({
directory = project_root,
gitignore_patterns = ignore_patterns,
gitignore_negate_patterns = negate_patterns,
})
if filepaths and not RepoMap._init_repo_map_lib() then
-- or just throw an error if we don't want to execute request without codebase
Expand Down
135 changes: 75 additions & 60 deletions lua/avante/utils/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -658,71 +658,85 @@ function M.is_ignored(file, ignore_patterns, negate_patterns)
return false
end

---@param options { directory: string, add_dirs?: boolean, depth?: integer }
function M.scan_directory_respect_gitignore(options)
local directory = options.directory
local gitignore_path = directory .. "/.gitignore"
local gitignore_patterns, gitignore_negate_patterns = M.parse_gitignore(gitignore_path)

-- Convert relative paths in gitignore to absolute paths based on project root
local project_root = M.get_project_root()
local function to_absolute_path(pattern)
-- Skip if already absolute path
if pattern:sub(1, 1) == "/" then return pattern end
-- Convert relative path to absolute
return Path:new(project_root, pattern):absolute()
end

gitignore_patterns = vim.tbl_map(to_absolute_path, gitignore_patterns)
gitignore_negate_patterns = vim.tbl_map(to_absolute_path, gitignore_negate_patterns)

return M.scan_directory({
directory = directory,
gitignore_patterns = gitignore_patterns,
gitignore_negate_patterns = gitignore_negate_patterns,
add_dirs = options.add_dirs,
depth = options.depth,
})
end

---@param options { directory: string, gitignore_patterns: string[], gitignore_negate_patterns: string[], add_dirs?: boolean, depth?: integer, current_depth?: integer }
---@param options { directory: string, add_dirs?: boolean, max_depth?: integer }
---@return string[]
function M.scan_directory(options)
local directory = options.directory
local ignore_patterns = options.gitignore_patterns
local negate_patterns = options.gitignore_negate_patterns
local add_dirs = options.add_dirs or false
local depth = options.depth or -1
local current_depth = options.current_depth or 0

local files = {}
local handle = vim.loop.fs_scandir(directory)

if not handle then return files end
local cmd_supports_max_depth = true
local cmd = (function()
if vim.fn.executable("rg") == 1 then
local cmd = { "rg", "--files", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
table.insert(cmd, options.directory)
return cmd
end
if vim.fn.executable("fd") == 1 then
local cmd = { "fd", "--type", "f", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
vim.list_extend(cmd, { "--base-directory", options.directory })
return cmd
end
if vim.fn.executable("fdfind") == 1 then
local cmd = { "fdfind", "--type", "f", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
vim.list_extend(cmd, { "--base-directory", options.directory })
return cmd
end
end)()

if not cmd then
local p = Path:new(options.directory)
if p:joinpath(".git"):exists() and vim.fn.executable("git") == 1 then
cmd = {
"bash",
"-c",
string.format(
"cd %s && cat <(git ls-files --exclude-standard) <(git ls-files --exclude-standard --others)",
options.directory
),
}
cmd_supports_max_depth = false
else
M.error("No search command found")
return {}
end
end

while true do
if depth > 0 and current_depth >= depth then break end
local files = vim.fn.systemlist(cmd)

local name, type = vim.loop.fs_scandir_next(handle)
if not name then break end
files = vim
.iter(files)
:map(function(file)
local p = Path:new(file)
if not p:is_absolute() then return tostring(Path:new(options.directory):joinpath(file):absolute()) end
return file
end)
:totable()

local full_path = directory .. "/" .. name
if type == "directory" then
if add_dirs and not M.is_ignored(full_path, ignore_patterns, negate_patterns) then
table.insert(files, full_path)
if options.max_depth ~= nil and not cmd_supports_max_depth then
files = vim
.iter(files)
:filter(function(file)
local base_dir = options.directory
if base_dir:sub(-2) == "/." then base_dir = base_dir:sub(1, -3) end
local rel_path = tostring(Path:new(file):make_relative(base_dir))
local pieces = vim.split(rel_path, "/")
return #pieces <= options.max_depth
end)
:totable()
end

if options.add_dirs then
local dirs = {}
local dirs_seen = {}
for _, file in ipairs(files) do
local dir = tostring(Path:new(file):parent())
dir = dir .. "/"
if not dirs_seen[dir] then
table.insert(dirs, dir)
dirs_seen[dir] = true
end
vim.list_extend(
files,
M.scan_directory({
directory = full_path,
gitignore_patterns = ignore_patterns,
gitignore_negate_patterns = negate_patterns,
add_dirs = add_dirs,
current_depth = current_depth + 1,
})
)
elseif type == "file" then
if not M.is_ignored(full_path, ignore_patterns, negate_patterns) then table.insert(files, full_path) end
end
files = vim.list_extend(dirs, files)
end

return files
Expand Down Expand Up @@ -890,9 +904,10 @@ function M.get_current_selection_diagnostics(bufnr, selection)
end

function M.uniform_path(path)
if type(path) ~= "string" then path = tostring(path) end
if not M.file.is_in_cwd(path) then return path end
local project_root = M.get_project_root()
local abs_path = Path:new(project_root):joinpath(path):absolute()
local abs_path = Path:new(path):is_absolute() and path or Path:new(project_root):joinpath(path):absolute()
local relative_path = Path:new(abs_path):make_relative(project_root)
return relative_path
end
Expand Down
39 changes: 37 additions & 2 deletions tests/llm_tools_spec.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
local mock = require("luassert.mock")
local stub = require("luassert.stub")
local LlmTools = require("avante.llm_tools")
local Utils = require("avante.utils")
Expand All @@ -12,9 +11,25 @@ describe("llm_tools", function()
before_each(function()
-- 创建测试目录和文件
os.execute("mkdir -p " .. test_dir)
os.execute(string.format("cd %s; git init", test_dir))
local file = io.open(test_file, "w")
if not file then error("Failed to create test file") end
file:write("test content")
file:close()
os.execute("mkdir -p " .. test_dir .. "/test_dir1")
file = io.open(test_dir .. "/test_dir1/test1.txt", "w")
if not file then error("Failed to create test file") end
file:write("test1 content")
file:close()
os.execute("mkdir -p " .. test_dir .. "/test_dir2")
file = io.open(test_dir .. "/test_dir2/test2.txt", "w")
if not file then error("Failed to create test file") end
file:write("test2 content")
file:close()
file = io.open(test_dir .. "/.gitignore", "w")
if not file then error("Failed to create test file") end
file:write("test_dir2/")
file:close()

-- Mock get_project_root
stub(Utils, "get_project_root", function() return test_dir end)
Expand All @@ -29,9 +44,26 @@ describe("llm_tools", function()

describe("list_files", function()
it("should list files in directory", function()
local result, err = LlmTools.list_files({ rel_path = ".", depth = 1 })
local result, err = LlmTools.list_files({ rel_path = ".", max_depth = 1 })
assert.is_nil(err)
assert.falsy(result:find("avante.nvim"))
assert.truthy(result:find("test.txt"))
assert.falsy(result:find("test1.txt"))
end)
it("should list files in directory with depth", function()
local result, err = LlmTools.list_files({ rel_path = ".", max_depth = 2 })
assert.is_nil(err)
assert.falsy(result:find("avante.nvim"))
assert.truthy(result:find("test.txt"))
assert.truthy(result:find("test1.txt"))
end)
it("should list files respecting gitignore", function()
local result, err = LlmTools.list_files({ rel_path = ".", max_depth = 2 })
assert.is_nil(err)
assert.falsy(result:find("avante.nvim"))
assert.truthy(result:find("test.txt"))
assert.truthy(result:find("test1.txt"))
assert.falsy(result:find("test2.txt"))
end)
end)

Expand Down Expand Up @@ -104,10 +136,12 @@ describe("llm_tools", function()

-- Create a test file with searchable content
local file = io.open(test_dir .. "/searchable.txt", "w")
if not file then error("Failed to create test file") end
file:write("this is searchable content")
file:close()

file = io.open(test_dir .. "/nothing.txt", "w")
if not file then error("Failed to create test file") end
file:write("this is nothing")
file:close()

Expand All @@ -126,6 +160,7 @@ describe("llm_tools", function()

-- Create a test file specifically for ag
local file = io.open(test_dir .. "/ag_test.txt", "w")
if not file then error("Failed to create test file") end
file:write("content for ag test")
file:close()

Expand Down

0 comments on commit e93f242

Please sign in to comment.