Skip to content

Commit 0bbe5e5

Browse files
committed
Add grep native tool
1 parent 3195754 commit 0bbe5e5

4 files changed

Lines changed: 198 additions & 5 deletions

File tree

docs/capabilities.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Provides access to filesystem under workspace root, listing and reading files an
1313
- `read_file`: read a file content.
1414
- `list_directory`: list a directory.
1515
- `search_files`: search in a path for files matching a pattern.
16+
- `grep`: ripgrep/grep for paths with specified content.
1617

1718
## Supported LLM models and capaibilities
1819

src/eca/features/tools/filesystem.clj

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(ns eca.features.tools.filesystem
22
(:require
33
[babashka.fs :as fs]
4+
[clojure.java.io :as io]
5+
[clojure.java.shell :as shell]
46
[clojure.string :as string]
57
[eca.features.tools.util :as tools.util]
68
[eca.shared :as shared]))
@@ -56,7 +58,7 @@
5658

5759
(defn ^:private search-files [arguments db]
5860
(or (invalid-arguments arguments (concat (path-validations db)
59-
[["pattern" #(and % (not (string/blank? %))) "Invalid glob pattern '$pattern'"]]))
61+
[["pattern" #(not (string/blank? %)) "Invalid glob pattern '$pattern'"]]))
6062
(let [pattern (get arguments "pattern")
6163
pattern (if (string/includes? pattern "*")
6264
pattern
@@ -71,6 +73,86 @@
7173
(string/join "\n" paths)
7274
"No matches found")))))
7375

76+
(defn ^:private run-ripgrep [path pattern include]
77+
(let [cmd (cond-> ["rg" "--files-with-matches" "--no-heading"]
78+
include (concat ["--glob" include])
79+
:always (concat ["-e" pattern path]))]
80+
(->> (apply shell/sh cmd)
81+
:out
82+
(string/split-lines)
83+
(filterv #(not (string/blank? %))))))
84+
85+
(defn ^:private run-grep [path pattern ^String include]
86+
(let [include-patterns (if (and include (.contains include "{"))
87+
(let [pattern-match (re-find #"\*\.\{(.+)\}" include)]
88+
(when pattern-match
89+
(map #(str "*." %) (clojure.string/split (second pattern-match) #","))))
90+
[include])
91+
cmd (cond-> ["grep" "-E" "-l" "-r" "--exclude-dir=.*"]
92+
(and include (> (count include-patterns) 1)) (concat (mapv #(str "--include=" %) include-patterns))
93+
include (concat [(str "--include=" include)])
94+
:always (concat [pattern path]))]
95+
(->> (apply shell/sh cmd)
96+
:out
97+
(string/split-lines)
98+
(filterv #(not (string/blank? %))))))
99+
100+
(defn ^:private run-java-grep [path pattern include]
101+
(let [include-pattern (when include
102+
(re-pattern (str ".*\\.("
103+
(-> include
104+
(string/replace #"^\*\." "")
105+
(string/replace #"\*\.\{(.+)\}" "$1")
106+
(string/replace #"," "|"))
107+
")$")))
108+
pattern-regex (re-pattern pattern)]
109+
(letfn [(search [dir]
110+
(keep
111+
(fn [file]
112+
(cond
113+
(and (fs/directory? file) (not (fs/hidden? file)))
114+
(search file)
115+
116+
(and (not (fs/directory? file))
117+
(or (nil? include-pattern)
118+
(re-matches include-pattern (fs/file-name file))))
119+
(try
120+
(with-open [rdr (io/reader (fs/file file))]
121+
(loop [lines (line-seq rdr)]
122+
(when (seq lines)
123+
(if (re-find pattern-regex (first lines))
124+
(str (fs/canonicalize file))
125+
(recur (rest lines))))))
126+
(catch Exception _ nil))))
127+
(fs/list-dir dir)))]
128+
(when (fs/exists? path)
129+
(flatten (search path))))))
130+
131+
(defn ^:private grep [arguments db]
132+
(or (invalid-arguments arguments (concat (path-validations db)
133+
[["path" fs/readable? "File $path is not readable"]
134+
["pattern" #(and % (not (string/blank? %))) "Invalid content regex pattern '$pattern'"]
135+
["include" #(or (nil? %) (not (string/blank? %))) "Invalid file pattern '$include'"]
136+
["max_results" #(or (nil? %) number?) "Invalid number '$max_results'"]]))
137+
(let [path (get arguments "path")
138+
pattern (get arguments "pattern")
139+
include (get arguments "include")
140+
max-results (or (get arguments "max_results") 1000)
141+
paths
142+
(->> (cond
143+
(tools.util/command-available? "rg" "--version")
144+
(run-ripgrep path pattern include)
145+
146+
(tools.util/command-available? "grep" "--version")
147+
(run-grep path pattern include)
148+
149+
:else
150+
(run-java-grep path pattern include))
151+
(take max-results))]
152+
(single-text-content (if (seq paths)
153+
(string/join "\n" paths)
154+
"No files found for given pattern")))))
155+
74156
(def definitions
75157
{"list_directory"
76158
{:description (str "Get a detailed listing of all files and directories in a specified path. "
@@ -94,9 +176,9 @@
94176
:parameters {:type "object"
95177
:properties {"path" {:type "string"
96178
:description "The absolute path to the file to read."}
97-
"head" {:type "number"
179+
"head" {:type "integer"
98180
:description "If provided, returns only the first N lines of the file"}
99-
"tail" {:type "number"
181+
"tail" {:type "integer"
100182
:description "If provided, returns only the last N lines of the file"}}
101183
:required ["path"]}
102184
:handler #'read-file}
@@ -113,4 +195,22 @@
113195
:description (str "Glob pattern following java FileSystem#getPathMatcher matching files or directory names."
114196
"Use '**/*' to match search in multiple levels like '**/*.txt'")}}
115197
:required ["path" "pattern"]}
116-
:handler #'search-files}})
198+
:handler #'search-files}
199+
"grep"
200+
{:description (str "Fast content search tool that works with any codebase size. "
201+
"Finds the paths to files that have matching contents using regular expressions. "
202+
"Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). "
203+
"Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). "
204+
"Returns matching file paths sorted by modification time. "
205+
"Use this tool when you need to find files containing specific patterns.")
206+
:parameters {:type "object"
207+
:properties {"path" {:type "string"
208+
:description "The absolute path to search in."}
209+
"pattern" {:type "string"
210+
:description "The regular expression pattern to search for in file contents"}
211+
"include" {:type "string"
212+
:description "File pattern to include in the search (e.g. \"*.clj\", \"*.{clj,cljs}\")"}
213+
"max_results" {:type "integer"
214+
:description "Maximum number of results to return (default: 1000)"}}
215+
:required ["path" "pattern"]}
216+
:handler #'grep}})

src/eca/features/tools/util.clj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
(ns eca.features.tools.util
22
(:require
3+
[clojure.java.shell :as shell]
34
[clojure.string :as string]
45
[eca.shared :as shared]))
56

67
(defn workspace-roots-strs [db]
78
(->> (:workspace-folders db)
89
(map #(shared/uri->filename (:uri %)))
910
(string/join "\n")))
11+
12+
(defn command-available? [command & args]
13+
(try
14+
(zero? (:exit (apply shell/sh (concat [command] args))))
15+
(catch Exception _ false)))

test/eca/features/tools/filesystem_test.clj

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
(ns eca.features.tools.filesystem-test
22
(:require
33
[babashka.fs :as fs]
4+
[clojure.java.shell :as shell]
45
[clojure.string :as string]
56
[clojure.test :refer [deftest is testing]]
67
[eca.features.tools.filesystem :as f.tools.filesystem]
8+
[eca.features.tools.util :as tools.util]
79
[eca.test-helper :as h]
8-
[matcher-combinators.test :refer [match?]]))
10+
[matcher-combinators.test :refer [match?]])
11+
(:import
12+
[java.io ByteArrayInputStream]))
913

1014
(deftest list-directory-test
1115
(testing "Invalid path"
@@ -151,3 +155,85 @@
151155
{"path" (h/file-path "/project/foo")
152156
"pattern" ".txt"}
153157
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}))))))
158+
159+
(deftest grep-test
160+
(testing "invalid pattern"
161+
(is (match?
162+
{:contents [{:type :text
163+
:error true
164+
:content "Invalid content regex pattern ' '"}]}
165+
(with-redefs [fs/exists? (constantly true)
166+
fs/readable? (constantly true)]
167+
((get-in f.tools.filesystem/definitions ["grep" :handler])
168+
{"path" (h/file-path "/project/foo")
169+
"pattern" " "}
170+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
171+
(testing "invalid include"
172+
(is (match?
173+
{:contents [{:type :text
174+
:error true
175+
:content "Invalid file pattern ' '"}]}
176+
(with-redefs [fs/exists? (constantly true)
177+
fs/readable? (constantly true)]
178+
((get-in f.tools.filesystem/definitions ["grep" :handler])
179+
{"path" (h/file-path "/project/foo")
180+
"pattern" ".*"
181+
"include" " "}
182+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
183+
(testing "no files found"
184+
(is (match?
185+
{:contents [{:type :text
186+
:error false
187+
:content "No files found for given pattern"}]}
188+
(with-redefs [fs/exists? (constantly true)
189+
fs/readable? (constantly true)
190+
tools.util/command-available? (fn [command & _args] (= "rg" command))
191+
shell/sh (constantly {:out ""})]
192+
((get-in f.tools.filesystem/definitions ["grep" :handler])
193+
{"path" (h/file-path "/project/foo")
194+
"pattern" ".*"}
195+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
196+
(testing "ripgrep search"
197+
(is (match?
198+
{:contents [{:type :text
199+
:error false
200+
:content "/project/foo/bla.txt\n/project/foo/qux.txt"}]}
201+
(with-redefs [fs/exists? (constantly true)
202+
fs/readable? (constantly true)
203+
tools.util/command-available? (fn [command & _args] (= "rg" command))
204+
shell/sh (constantly {:out "/project/foo/bla.txt\n/project/foo/qux.txt"})]
205+
((get-in f.tools.filesystem/definitions ["grep" :handler])
206+
{"path" (h/file-path "/project/foo")
207+
"pattern" "some-cool-content"}
208+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
209+
(testing "grep search"
210+
(is (match?
211+
{:contents [{:type :text
212+
:error false
213+
:content "/project/foo/bla.txt\n/project/foo/qux.txt"}]}
214+
(with-redefs [fs/exists? (constantly true)
215+
fs/readable? (constantly true)
216+
tools.util/command-available? (fn [command & _args] (= "grep" command))
217+
shell/sh (constantly {:out "/project/foo/bla.txt\n/project/foo/qux.txt"})]
218+
((get-in f.tools.filesystem/definitions ["grep" :handler])
219+
{"path" (h/file-path "/project/foo")
220+
"pattern" "some-cool-content"}
221+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
222+
(testing "java grep search"
223+
(is (match?
224+
{:contents [{:type :text
225+
:error false
226+
:content "/project/foo/bla.txt"}]}
227+
(with-redefs [fs/exists? (constantly true)
228+
fs/readable? (constantly true)
229+
tools.util/command-available? (constantly false)
230+
shell/sh (constantly {:out "/project/foo/bla.txt\n/project/foo/qux.txt"})
231+
fs/list-dir (constantly [(fs/path (h/file-path "/project/foo/bla.txt"))])
232+
fs/canonicalize identity
233+
fs/directory? (constantly false)
234+
fs/hidden? (constantly false)
235+
fs/file (constantly (ByteArrayInputStream. (.getBytes "some-cool-content")))]
236+
((get-in f.tools.filesystem/definitions ["grep" :handler])
237+
{"path" (h/file-path "/project/foo")
238+
"pattern" "some-cool-content"}
239+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}))))))

0 commit comments

Comments
 (0)