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}