diff --git a/lua/avante/llm_tools.lua b/lua/avante/llm_tools.lua index e06de58..8311299 100644 --- a/lua/avante/llm_tools.lua +++ b/lua/avante/llm_tools.lua @@ -83,8 +83,8 @@ function M.search_files(opts, on_log) return vim.json.encode(filepaths), nil end ----@type AvanteLLMToolFunc<{ rel_path: string, keyword: string }> -function M.search_keyword(opts, on_log) +---@type AvanteLLMToolFunc<{ rel_path: string, query: string, case_sensitive?: boolean, include_pattern?: string, exclude_pattern?: string }> +function M.grep_search(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 not Path:new(abs_path):exists() then return "", "No such file or directory: " .. abs_path end @@ -99,15 +99,32 @@ function M.search_keyword(opts, on_log) ---execute the search command local cmd = "" if search_cmd:find("rg") then - cmd = string.format("%s --files-with-matches --ignore-case --hidden --glob '!.git'", search_cmd) - cmd = string.format("%s '%s' %s", cmd, opts.keyword, abs_path) + cmd = string.format("%s --files-with-matches --hidden", search_cmd) + if opts.case_sensitive then + cmd = string.format("%s --case-sensitive", cmd) + else + cmd = string.format("%s --ignore-case", cmd) + end + if opts.include_pattern then cmd = string.format("%s --glob '%s'", cmd, opts.include_pattern) end + if opts.exclude_pattern then cmd = string.format("%s --glob '!%s'", cmd, opts.exclude_pattern) end + cmd = string.format("%s '%s' %s", cmd, opts.query, abs_path) elseif search_cmd:find("ag") then - cmd = string.format("%s '%s' --nocolor --nogroup --hidden --ignore .git %s", search_cmd, opts.keyword, abs_path) + cmd = string.format("%s --nocolor --nogroup --hidden", search_cmd) + if opts.case_sensitive then cmd = string.format("%s --case-sensitive", cmd) end + if opts.include_pattern then cmd = string.format("%s --ignore '!%s'", cmd, opts.include_pattern) end + if opts.exclude_pattern then cmd = string.format("%s --ignore '%s'", cmd, opts.exclude_pattern) end + cmd = string.format("%s '%s' %s", cmd, opts.query, abs_path) elseif search_cmd:find("ack") then - cmd = string.format("%s --nocolor --nogroup --hidden --ignore-dir .git", search_cmd) - cmd = string.format("%s '%s' %s", cmd, opts.keyword, abs_path) + cmd = string.format("%s --nocolor --nogroup --hidden", search_cmd) + if opts.case_sensitive then cmd = string.format("%s --smart-case", cmd) end + if opts.exclude_pattern then cmd = string.format("%s --ignore-dir '%s'", cmd, opts.exclude_pattern) end + cmd = string.format("%s '%s' %s", cmd, opts.query, abs_path) elseif search_cmd:find("grep") then - cmd = string.format("%s -riH --exclude-dir=.git %s %s", search_cmd, opts.keyword, abs_path) + cmd = string.format("cd %s && git ls-files -co --exclude-standard | xargs %s -rH", abs_path, search_cmd, abs_path) + if not opts.case_sensitive then cmd = string.format("%s -i", cmd) end + if opts.include_pattern then cmd = string.format("%s --include '%s'", cmd, opts.include_pattern) end + if opts.exclude_pattern then cmd = string.format("%s --exclude '%s'", cmd, opts.exclude_pattern) end + cmd = string.format("%s '%s'", cmd, opts.query) end Utils.debug("cmd", cmd) @@ -842,8 +859,8 @@ M._tools = { }, }, { - name = "search_keyword", - description = "Search for a keyword in a directory", + name = "grep_search", + description = "Search for a keyword in a directory using grep", param = { type = "table", fields = { @@ -853,10 +870,29 @@ M._tools = { type = "string", }, { - name = "keyword", - description = "Keyword to search for", + name = "query", + description = "Query to search for", type = "string", }, + { + name = "case_sensitive", + description = "Whether to search case sensitively", + type = "boolean", + default = false, + optional = true, + }, + { + name = "include_pattern", + description = "Glob pattern to include files", + type = "string", + optional = true, + }, + { + name = "exclude_pattern", + description = "Glob pattern to exclude files", + type = "string", + optional = true, + }, }, }, returns = { diff --git a/lua/avante/types.lua b/lua/avante/types.lua index 74518a5..bd79221 100644 --- a/lua/avante/types.lua +++ b/lua/avante/types.lua @@ -354,7 +354,7 @@ vim.g.avante_login = vim.g.avante_login ---@class AvanteLLMToolParamField ---@field name string ---@field description string ----@field type 'string' | 'integer' +---@field type 'string' | 'integer' | 'boolean' ---@field optional? boolean ---@class AvanteLLMToolReturn diff --git a/lua/avante/utils/init.lua b/lua/avante/utils/init.lua index 8dc962c..1cc510c 100644 --- a/lua/avante/utils/init.lua +++ b/lua/avante/utils/init.lua @@ -767,10 +767,7 @@ function M.scan_directory(options) cmd = { "bash", "-c", - string.format( - "cd %s && cat <(git ls-files --exclude-standard) <(git ls-files --exclude-standard --others)", - options.directory - ), + string.format("cd %s && git ls-files -co --exclude-standard", options.directory), } end cmd_supports_max_depth = false diff --git a/tests/llm_tools_spec.lua b/tests/llm_tools_spec.lua index f75792d..a278efe 100644 --- a/tests/llm_tools_spec.lua +++ b/tests/llm_tools_spec.lua @@ -124,7 +124,7 @@ describe("llm_tools", function() end) end) - describe("search_keyword", function() + describe("grep_search", function() local original_exepath = vim.fn.exepath after_each(function() vim.fn.exepath = original_exepath end) @@ -147,10 +147,35 @@ describe("llm_tools", function() file:write("this is nothing") file:close() - local result, err = LlmTools.search_keyword({ rel_path = ".", keyword = "searchable" }) + local result, err = LlmTools.grep_search({ rel_path = ".", query = "Searchable", case_sensitive = false }) assert.is_nil(err) assert.truthy(result:find("searchable.txt")) assert.falsy(result:find("nothing.txt")) + + local result2, err2 = LlmTools.grep_search({ rel_path = ".", query = "searchable", case_sensitive = true }) + assert.is_nil(err2) + assert.truthy(result2:find("searchable.txt")) + assert.falsy(result2:find("nothing.txt")) + + local result3, err3 = LlmTools.grep_search({ rel_path = ".", query = "Searchable", case_sensitive = true }) + assert.is_nil(err3) + assert.falsy(result3:find("searchable.txt")) + assert.falsy(result3:find("nothing.txt")) + + local result4, err4 = LlmTools.grep_search({ rel_path = ".", query = "searchable", case_sensitive = false }) + assert.is_nil(err4) + assert.truthy(result4:find("searchable.txt")) + assert.falsy(result4:find("nothing.txt")) + + local result5, err5 = LlmTools.grep_search({ + rel_path = ".", + query = "searchable", + case_sensitive = false, + exclude_pattern = "search*", + }) + assert.is_nil(err5) + assert.falsy(result5:find("searchable.txt")) + assert.falsy(result5:find("nothing.txt")) end) it("should search using ag when rg is not available", function() @@ -166,7 +191,7 @@ describe("llm_tools", function() file:write("content for ag test") file:close() - local result, err = LlmTools.search_keyword({ rel_path = ".", keyword = "ag test" }) + local result, err = LlmTools.grep_search({ rel_path = ".", query = "ag test" }) assert.is_nil(err) assert.is_string(result) assert.truthy(result:find("ag_test.txt")) @@ -179,27 +204,64 @@ describe("llm_tools", function() return "" end - local result, err = LlmTools.search_keyword({ rel_path = ".", keyword = "test" }) + -- 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() + + local result, err = LlmTools.grep_search({ rel_path = ".", query = "Searchable", case_sensitive = false }) assert.is_nil(err) - assert.truthy(result:find("test.txt")) + assert.truthy(result:find("searchable.txt")) + assert.falsy(result:find("nothing.txt")) + + local result2, err2 = LlmTools.grep_search({ rel_path = ".", query = "searchable", case_sensitive = true }) + assert.is_nil(err2) + assert.truthy(result2:find("searchable.txt")) + assert.falsy(result2:find("nothing.txt")) + + local result3, err3 = LlmTools.grep_search({ rel_path = ".", query = "Searchable", case_sensitive = true }) + assert.is_nil(err3) + assert.falsy(result3:find("searchable.txt")) + assert.falsy(result3:find("nothing.txt")) + + local result4, err4 = LlmTools.grep_search({ rel_path = ".", query = "searchable", case_sensitive = false }) + assert.is_nil(err4) + assert.truthy(result4:find("searchable.txt")) + assert.falsy(result4:find("nothing.txt")) + + local result5, err5 = LlmTools.grep_search({ + rel_path = ".", + query = "searchable", + case_sensitive = false, + exclude_pattern = "search*", + }) + assert.is_nil(err5) + assert.falsy(result5:find("searchable.txt")) + assert.falsy(result5:find("nothing.txt")) end) it("should return error when no search tool is available", function() -- Mock exepath to return nothing vim.fn.exepath = function() return "" end - local result, err = LlmTools.search_keyword({ rel_path = ".", keyword = "test" }) + local result, err = LlmTools.grep_search({ rel_path = ".", query = "test" }) assert.equals("", result) assert.equals("No search command found", err) end) it("should respect path permissions", function() - local result, err = LlmTools.search_keyword({ rel_path = "../outside_project", keyword = "test" }) + local result, err = LlmTools.grep_search({ rel_path = "../outside_project", query = "test" }) assert.truthy(err:find("No permission to access path")) end) it("should handle non-existent paths", function() - local result, err = LlmTools.search_keyword({ rel_path = "non_existent_dir", keyword = "test" }) + local result, err = LlmTools.grep_search({ rel_path = "non_existent_dir", query = "test" }) assert.equals("", result) assert.truthy(err) assert.truthy(err:find("No such file or directory"))