001/*
002 * Copyright 2025 - 2025 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package com.agentsflex.tool.commons;
017
018import com.agentsflex.core.model.chat.tool.annotation.ToolDef;
019import com.agentsflex.core.model.chat.tool.annotation.ToolParam;
020import com.agentsflex.core.util.StringUtil;
021
022import java.io.BufferedReader;
023import java.io.IOException;
024import java.nio.charset.StandardCharsets;
025import java.nio.file.*;
026import java.util.*;
027import java.util.concurrent.atomic.AtomicInteger;
028import java.util.regex.Matcher;
029import java.util.regex.Pattern;
030import java.util.stream.Stream;
031
032/**
033 * Pure Java grep implementation that doesn't require external ripgrep installation. Uses
034 * Java NIO.2 for file traversal and regex Pattern/Matcher for searching.
035 * <p>
036 * Generated with Claude Code AI assistant.
037 *
038 * @author Christian Tzolov
039 * @author Claude Code
040 * @author Michael Yang
041 */
042public class GrepTool {
043
044    private final int maxOutputLength;
045
046    private final int maxDepth;
047
048    private final int maxLineLength;
049
050    private final Path workingDirectory;
051
052    // File type mappings (common extensions)
053    private static final Map<String, String[]> FILE_TYPE_EXTENSIONS = new HashMap<>();
054
055    static {
056        FILE_TYPE_EXTENSIONS.put("java", new String[]{"*.java", "*.jsp"});
057        FILE_TYPE_EXTENSIONS.put("js", new String[]{"*.js", "*.jsx"});
058        FILE_TYPE_EXTENSIONS.put("ts", new String[]{"*.ts", "*.tsx"});
059        FILE_TYPE_EXTENSIONS.put("py", new String[]{"*.py"});
060        FILE_TYPE_EXTENSIONS.put("rust", new String[]{"*.rs"});
061        FILE_TYPE_EXTENSIONS.put("go", new String[]{"*.go"});
062        FILE_TYPE_EXTENSIONS.put("cpp", new String[]{"*.cpp", "*.cc", "*.cxx", "*.hpp", "*.h"});
063        FILE_TYPE_EXTENSIONS.put("c", new String[]{"*.c", "*.h"});
064        FILE_TYPE_EXTENSIONS.put("rb", new String[]{"*.rb"});
065        FILE_TYPE_EXTENSIONS.put("php", new String[]{"*.php"});
066        FILE_TYPE_EXTENSIONS.put("cs", new String[]{"*.cs"});
067        FILE_TYPE_EXTENSIONS.put("xml", new String[]{"*.xml"});
068        FILE_TYPE_EXTENSIONS.put("json", new String[]{"*.json"});
069        FILE_TYPE_EXTENSIONS.put("yaml", new String[]{"*.yaml", "*.yml"});
070        FILE_TYPE_EXTENSIONS.put("md", new String[]{"*.md", "*.markdown"});
071        FILE_TYPE_EXTENSIONS.put("txt", new String[]{"*.txt"});
072        FILE_TYPE_EXTENSIONS.put("sh", new String[]{"*.sh", "*.bash"});
073        FILE_TYPE_EXTENSIONS.put("html", new String[]{"*.html", "*.htm", "*.xhtml", "*.xht", "*.css", "*.less", "*.sass", "*.scss"});
074    }
075
076    /**
077     * Default constructor with default values for all configuration parameters.
078     * @deprecated Use {@link #builder()} instead.
079     */
080    public GrepTool() {
081        this(100000, 100, 10000, null);
082    }
083
084    /**
085     * Constructor with configurable parameters.
086     * @param maxOutputLength Maximum output length before truncation (default: 100000)
087     * @param maxDepth Maximum directory traversal depth to prevent infinite recursion
088     * (default: 100)
089     * @param maxLineLength Maximum line length to process, longer lines are skipped
090     * (default: 10000)
091     * @param workingDirectory The working directory to use when path is not specified.
092     * If null, defaults to current JVM working directory.
093     */
094    private GrepTool(int maxOutputLength, int maxDepth, int maxLineLength, Path workingDirectory) {
095        this.maxOutputLength = maxOutputLength;
096        this.maxDepth = maxDepth;
097        this.maxLineLength = maxLineLength;
098        this.workingDirectory = workingDirectory;
099    }
100
101    /**
102     * Output modes for grep
103     */
104    public enum OutputMode {// @formatter:off
105                files_with_matches,
106                count,
107                content
108
109        }// @formatter:on
110
111    // @formatter:off
112        @ToolDef(name = "Grep", description = "A powerful search tool built with pure Java (no external dependencies required)\n" +
113        "\n" +
114        "Usage:\n" +
115        "- ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n" +
116        "- Supports full regex syntax (e.g., \"log.*Error\", \"function\\s+\\w+\")\n" +
117        "- Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n" +
118        "- Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n" +
119        "- Use Task tool for open-ended searches requiring multiple rounds\n" +
120        "- Pattern syntax: Java regex - use standard Java regex escaping\n" +
121        "- Multiline matching: By default patterns match within single lines only. For cross-line patterns, use `multiline: true`\n" +
122        "\n" +
123        "Note: This is a pure Java implementation that doesn't require ripgrep installation. But it provides similar functionality.")
124        public String grep(
125                @ToolParam(name = "pattern", description = "The regular expression pattern to search for in file contents") String pattern,
126                @ToolParam(name = "path", description = "File or directory to search in. Defaults to current working directory.", required = false) String path,
127                @ToolParam(name = "glob", description = "Glob pattern to filter files (e.g. \"*.js\", \"**/*.tsx\")", required = false) String glob,
128                @ToolParam(name = "outputMode", description = "Output mode: \"content\" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"files_with_matches\".", required = false) OutputMode outputMode,
129                @ToolParam(name = "contextBefore", description = "Number of lines to show before each match. Requires output_mode: \"content\", ignored otherwise.", required = false) Integer contextBefore,
130                @ToolParam(name = "contextAfter", description = "Number of lines to show after each match. Requires output_mode: \"content\", ignored otherwise.", required = false) Integer contextAfter,
131                @ToolParam(name = "context", description = "Number of lines to show before and after each match. Requires output_mode: \"content\", ignored otherwise.", required = false) Integer context,
132                @ToolParam(name = "showLineNumbers", description = "Show line numbers in output. Requires output_mode: \"content\", ignored otherwise. Defaults to true.", required = false) Boolean showLineNumbers,
133                @ToolParam(name = "caseInsensitive", description = "Case insensitive search", required = false) Boolean caseInsensitive,
134                @ToolParam(name = "type", description = "File type to search. Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types.", required = false) String type,
135                @ToolParam(name = "headLimit", description = "Limit output to first N lines/entries. Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 0 (unlimited).", required = false) Integer headLimit,
136                @ToolParam(name = "offset", description = "Skip first N lines/entries before applying head_limit. Works across all output modes. Defaults to 0.", required = false) Integer offset,
137                @ToolParam(name = "multiline", description = "Enable multiline mode where . matches newlines and patterns can span lines. Default: false.", required = false) Boolean multiline) { // @formatter:on
138
139        try {
140            // Determine search path - use configured workingDirectory if path not specified
141            Path searchPath;
142            if (StringUtil.hasText(path)) {
143                searchPath = Paths.get(path);
144            } else if (this.workingDirectory != null) {
145                searchPath = this.workingDirectory;
146            } else {
147                searchPath = Paths.get(".");
148            }
149
150            if (!Files.exists(searchPath)) {
151                return "Error: Path does not exist: " + searchPath.toAbsolutePath();
152            }
153
154            // Compile regex pattern
155            int flags = Pattern.MULTILINE;
156            if (Boolean.TRUE.equals(caseInsensitive)) {
157                flags |= Pattern.CASE_INSENSITIVE;
158            }
159            if (Boolean.TRUE.equals(multiline)) {
160                flags |= Pattern.DOTALL;
161            }
162
163            Pattern searchPattern;
164            try {
165                searchPattern = Pattern.compile(pattern, flags);
166            } catch (Exception e) {
167                return "Error: Invalid regex pattern: " + e.getMessage();
168            }
169
170            // Determine output mode
171            outputMode = outputMode != null ? outputMode : OutputMode.files_with_matches;
172
173            // Build glob matchers
174            List<PathMatcher> globMatchers = this.buildGlobMatchers(glob, type);
175
176            // Perform search based on mode
177            String result;
178            switch (outputMode) {
179                case files_with_matches:
180                    result = this.searchFilesWithMatches(searchPath, searchPattern, globMatchers, headLimit, offset);
181                    break;
182                case count:
183                    result = this.searchCount(searchPath, searchPattern, globMatchers, headLimit, offset);
184                    break;
185                case content:
186                    int beforeContext = context != null ? context : (contextBefore != null ? contextBefore : 0);
187                    int afterContext = context != null ? context : (contextAfter != null ? contextAfter : 0);
188                    boolean lineNumbers = showLineNumbers == null || showLineNumbers;
189                    result = this.searchContent(searchPath, searchPattern, globMatchers, beforeContext, afterContext,
190                        lineNumbers, headLimit, offset);
191                    break;
192                default:
193                    result = this.searchFilesWithMatches(searchPath, searchPattern, globMatchers, headLimit, offset);
194            }
195
196            // Truncate if too long
197            if (result.length() > this.maxOutputLength) {
198                result = result.substring(0, this.maxOutputLength) + "\n... (output truncated, "
199                    + (result.length() - this.maxOutputLength) + " characters omitted)";
200            }
201
202            return result;
203
204        } catch (Exception e) {
205            return "Error executing grep: " + e.getMessage();
206        }
207    }
208
209    /**
210     * Build glob matchers from glob pattern or file type
211     */
212    private List<PathMatcher> buildGlobMatchers(String glob, String type) {
213        List<PathMatcher> matchers = new ArrayList<>();
214
215        // Add type-based matchers
216        if (StringUtil.hasText(type)) {
217            String[] extensions = FILE_TYPE_EXTENSIONS.get(type.toLowerCase());
218            if (extensions != null) {
219                for (String ext : extensions) {
220                    matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + ext));
221                }
222            }
223        }
224
225        // Add explicit glob matcher
226        if (StringUtil.hasText(glob)) {
227            // Handle both simple globs (*.java) and complex globs (**/*.java)
228            String globPattern = glob.startsWith("**/") ? glob : "**/" + glob;
229            matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + globPattern));
230        }
231
232        return matchers;
233    }
234
235    /**
236     * Check if a file matches any of the glob matchers
237     */
238    private boolean matchesGlob(Path file, List<PathMatcher> matchers) {
239        if (matchers.isEmpty()) {
240            return true; // No filters, match all files
241        }
242
243        for (PathMatcher matcher : matchers) {
244            if (matcher.matches(file)) {
245                return true;
246            }
247        }
248        return false;
249    }
250
251    /**
252     * Search for files containing matches (files_with_matches mode)
253     */
254    private String searchFilesWithMatches(Path searchPath, Pattern pattern, List<PathMatcher> matchers,
255                                          Integer headLimit, Integer offset) throws IOException {
256
257        List<String> matchingFiles = new ArrayList<>();
258        AtomicInteger count = new AtomicInteger(0);
259        int skip = offset != null ? offset : 0;
260        int limit = headLimit != null && headLimit > 0 ? headLimit : Integer.MAX_VALUE;
261
262        this.processFiles(searchPath, matchers, file -> {
263            if (count.get() >= skip + limit) {
264                return false; // Stop processing
265            }
266
267            if (this.fileContainsPattern(file, pattern)) {
268                if (count.getAndIncrement() >= skip) {
269                    matchingFiles.add(file.toString());
270                }
271            }
272            return true; // Continue processing
273        });
274
275        if (matchingFiles.isEmpty()) {
276            return "No matches found for pattern: " + pattern.pattern();
277        }
278
279        return String.join("\n", matchingFiles);
280    }
281
282    /**
283     * Search and count matches per file (count mode)
284     */
285    private String searchCount(Path searchPath, Pattern pattern, List<PathMatcher> matchers, Integer headLimit,
286                               Integer offset) throws IOException {
287
288        Map<String, Integer> fileCounts = new LinkedHashMap<>();
289        AtomicInteger fileCount = new AtomicInteger(0);
290        int skip = offset != null ? offset : 0;
291        int limit = headLimit != null && headLimit > 0 ? headLimit : Integer.MAX_VALUE;
292
293        this.processFiles(searchPath, matchers, file -> {
294            if (fileCount.get() >= skip + limit) {
295                return false; // Stop processing
296            }
297
298            int matches = this.countMatchesInFile(file, pattern);
299            if (matches > 0) {
300                if (fileCount.getAndIncrement() >= skip) {
301                    fileCounts.put(file.toString(), matches);
302                }
303            }
304            return true; // Continue processing
305        });
306
307        if (fileCounts.isEmpty()) {
308            return "No matches found for pattern: " + pattern.pattern();
309        }
310
311        StringBuilder result = new StringBuilder();
312        for (Map.Entry<String, Integer> entry : fileCounts.entrySet()) {
313            result.append(entry.getKey()).append(":").append(entry.getValue()).append("\n");
314        }
315
316        return result.toString().trim();
317    }
318
319    /**
320     * Search and show matching content with context (content mode)
321     */
322    private String searchContent(Path searchPath, Pattern pattern, List<PathMatcher> matchers, int beforeContext,
323                                 int afterContext, boolean lineNumbers, Integer headLimit, Integer offset) throws IOException {
324
325        StringBuilder result = new StringBuilder();
326        AtomicInteger lineCount = new AtomicInteger(0);
327        int skip = offset != null ? offset : 0;
328        int limit = headLimit != null && headLimit > 0 ? headLimit : Integer.MAX_VALUE;
329
330        this.processFiles(searchPath, matchers, file -> {
331            if (lineCount.get() >= skip + limit) {
332                return false; // Stop processing
333            }
334
335            List<String> matches = this.findMatchesWithContext(file, pattern, beforeContext, afterContext, lineNumbers);
336            if (!matches.isEmpty()) {
337                // Add file header
338                result.append(file.toString()).append("\n");
339
340                // Add matches with offset and limit
341                for (String match : matches) {
342                    if (lineCount.get() >= skip + limit) {
343                        break;
344                    }
345                    if (lineCount.getAndIncrement() >= skip) {
346                        result.append(match).append("\n");
347                    }
348                }
349                result.append("\n");
350            }
351            return lineCount.get() < skip + limit; // Continue if under limit
352        });
353
354        if (result.length() == 0) {
355            return "No matches found for pattern: " + pattern.pattern();
356        }
357
358        return result.toString().trim();
359    }
360
361    /**
362     * Process files in the search path
363     */
364    private void processFiles(Path searchPath, List<PathMatcher> matchers, FileProcessor processor) throws IOException {
365        if (Files.isRegularFile(searchPath)) {
366            // Single file
367            if (this.matchesGlob(searchPath, matchers)) {
368                processor.process(searchPath);
369            }
370        } else if (Files.isDirectory(searchPath)) {
371            // Directory traversal
372            try (Stream<Path> paths = Files.walk(searchPath, this.maxDepth, FileVisitOption.FOLLOW_LINKS)) {
373                paths.filter(Files::isRegularFile)
374                    .filter(p -> this.matchesGlob(p, matchers))
375                    .filter(p -> !this.isIgnoredPath(p))
376                    .anyMatch(file -> !processor.process(file)); // Stop when processor
377                // returns false
378            }
379        }
380    }
381
382    /**
383     * Check if a file should be ignored (common ignore patterns)
384     */
385    private boolean isIgnoredPath(Path path) {
386        String pathStr = path.toString();
387        return pathStr.contains("/.git/") || pathStr.contains("/node_modules/") || pathStr.contains("/target/")
388            || pathStr.contains("/build/") || pathStr.contains("/.idea/") || pathStr.contains("/.vscode/")
389            || pathStr.contains("/dist/") || pathStr.contains("/__pycache__/");
390    }
391
392    /**
393     * Check if file contains the pattern
394     */
395    private boolean fileContainsPattern(Path file, Pattern pattern) {
396        try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
397            String line;
398            while ((line = reader.readLine()) != null) {
399                if (line.length() > this.maxLineLength)
400                    continue;
401                if (pattern.matcher(line).find()) {
402                    return true;
403                }
404            }
405        } catch (IOException e) {
406            // Skip files that can't be read
407        }
408        return false;
409    }
410
411    /**
412     * Count matches in a file
413     */
414    private int countMatchesInFile(Path file, Pattern pattern) {
415        int count = 0;
416        try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
417            String line;
418            while ((line = reader.readLine()) != null) {
419                if (line.length() > this.maxLineLength)
420                    continue;
421                Matcher matcher = pattern.matcher(line);
422                while (matcher.find()) {
423                    count++;
424                }
425            }
426        } catch (IOException e) {
427            // Skip files that can't be read
428        }
429        return count;
430    }
431
432    /**
433     * Find matches with context lines
434     */
435    private List<String> findMatchesWithContext(Path file, Pattern pattern, int beforeContext, int afterContext,
436                                                boolean lineNumbers) {
437        List<String> results = new ArrayList<>();
438
439        try {
440            List<String> allLines = Files.readAllLines(file, StandardCharsets.UTF_8);
441            List<Integer> matchingLineNumbers = new ArrayList<>();
442
443            // Find all matching line numbers
444            for (int i = 0; i < allLines.size(); i++) {
445                String line = allLines.get(i);
446                if (line.length() > this.maxLineLength)
447                    continue;
448                if (pattern.matcher(line).find()) {
449                    matchingLineNumbers.add(i);
450                }
451            }
452
453            // Extract matches with context
454            for (int matchLineNum : matchingLineNumbers) {
455                int start = Math.max(0, matchLineNum - beforeContext);
456                int end = Math.min(allLines.size() - 1, matchLineNum + afterContext);
457
458                for (int i = start; i <= end; i++) {
459                    String prefix = "";
460                    if (lineNumbers) {
461                        prefix = (i + 1) + ":";
462                    }
463                    if (i == matchLineNum) {
464                        prefix += "  "; // Indicate matching line
465                    } else {
466                        prefix += "- "; // Indicate context line
467                    }
468                    results.add(prefix + allLines.get(i));
469                }
470
471                // Add separator between match groups
472                if (!matchingLineNumbers.isEmpty()) {
473                    results.add("--");
474                }
475            }
476
477            // Remove trailing separator
478            if (!results.isEmpty() && results.get(results.size() - 1).equals("--")) {
479                results.remove(results.size() - 1);
480            }
481
482        } catch (IOException e) {
483            // Skip files that can't be read
484        }
485
486        return results;
487    }
488
489    /**
490     * Functional interface for file processing
491     */
492    @FunctionalInterface
493    private interface FileProcessor {
494
495        /**
496         * Process a file
497         *
498         * @return true to continue processing, false to stop
499         */
500        boolean process(Path file);
501
502    }
503
504    public static Builder builder() {
505        return new Builder();
506    }
507
508    public static class Builder {
509
510        private int maxOutputLength = 100000;
511
512        private int maxDepth = 100;
513
514        private int maxLineLength = 10000;
515
516        private Path workingDirectory = null;
517
518        public Builder maxOutputLength(int maxOutputLength) {
519            this.maxOutputLength = maxOutputLength;
520            return this;
521        }
522
523        public Builder maxDepth(int maxDepth) {
524            this.maxDepth = maxDepth;
525            return this;
526        }
527
528        public Builder maxLineLength(int maxLineLength) {
529            this.maxLineLength = maxLineLength;
530            return this;
531        }
532
533        /**
534         * Set the working directory to use when the agent doesn't specify a path. This
535         * allows tools to operate within a sandbox/workspace context.
536         *
537         * @param workingDirectory the working directory path
538         * @return this builder
539         */
540        public Builder workingDirectory(Path workingDirectory) {
541            this.workingDirectory = workingDirectory;
542            return this;
543        }
544
545        /**
546         * Set the working directory using a string path.
547         *
548         * @param workingDirectory the working directory path as string
549         * @return this builder
550         */
551        public Builder workingDirectory(String workingDirectory) {
552            this.workingDirectory = workingDirectory != null ? Paths.get(workingDirectory) : null;
553            return this;
554        }
555
556        public GrepTool build() {
557            return new GrepTool(this.maxOutputLength, this.maxDepth, this.maxLineLength, this.workingDirectory);
558        }
559
560    }
561
562}