Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public class LocalFilesystemBackend implements FilesystemBackend {
private static final String EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents";
private static final int MAX_LINE_LENGTH = 10000;
private static final int LINE_NUMBER_WIDTH = 6;
private static final int MAX_RIPGREP_DIAGNOSTIC_LENGTH = 4096;

private final Path cwd;
private final boolean virtualMode;
Expand Down Expand Up @@ -487,6 +488,13 @@ public Object grepRaw(String pattern, String path, String glob) {
} catch (PatternSyntaxException e) {
return "Invalid regex pattern: " + e.getMessage();
}
if (glob != null) {
try {
FileSystems.getDefault().getPathMatcher("glob:" + glob);
} catch (IllegalArgumentException e) {
return "Invalid glob pattern: " + e.getMessage();
}
}

// Resolve base path
Path baseFull;
Expand All @@ -504,10 +512,12 @@ public Object grepRaw(String pattern, String path, String glob) {
}

// Try ripgrep first
Map<String, List<LineMatch>> results = ripgrepSearch(pattern, baseFull, glob);
if (results == null) {
results = javaSearch(pattern, baseFull, glob);
RipgrepSearchResult ripgrepResult = ripgrepSearch(pattern, baseFull, glob);
if (ripgrepResult.error() != null) {
return ripgrepResult.error();
}
Map<String, List<LineMatch>> results = ripgrepResult.fallbackToJava()
? javaSearch(pattern, baseFull, glob) : ripgrepResult.matches();

List<GrepMatch> matches = new ArrayList<>();
for (Map.Entry<String, List<LineMatch>> entry : results.entrySet()) {
Expand All @@ -520,7 +530,7 @@ public Object grepRaw(String pattern, String path, String glob) {

// Helper methods

private Map<String, List<LineMatch>> ripgrepSearch(String pattern, Path baseFull, String includeGlob) {
private RipgrepSearchResult ripgrepSearch(String pattern, Path baseFull, String includeGlob) {
boolean explicitFile = Files.isRegularFile(baseFull, LinkOption.NOFOLLOW_LINKS);
List<String> cmd = new ArrayList<>();
cmd.add("rg");
Expand All @@ -543,62 +553,128 @@ private Map<String, List<LineMatch>> ripgrepSearch(String pattern, Path baseFull

try {
if (explicitFile && Files.size(baseFull) > maxFileSizeBytes) {
return Collections.emptyMap();
return RipgrepSearchResult.matches(Collections.emptyMap());
}
ProcessBuilder pb = new ProcessBuilder(cmd);
Process process = pb.start();

Map<String, List<LineMatch>> results = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
Process process;
try {
process = pb.start();
} catch (IOException e) {
if (!ripgrepCommandExistsOnPath()) {
return RipgrepSearchResult.fallback();
}
return RipgrepSearchResult.error("Unable to start ripgrep: " + e.getMessage());
}

try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
try {
JsonNode data = mapper.readTree(line);
if (!"match".equals(data.path("type").asText())) {
continue;
}
JsonNode pdata = data.path("data");
String ftext = pdata.path("path").path("text").asText(null);
if (ftext == null) {
continue;
}
Path p = Paths.get(ftext);
String virt;
if (virtualMode) {
try {
Path resolved = p.toAbsolutePath().normalize();
Path relative = cwd.relativize(resolved);
virt = "/" + relative.toString().replace("\\", "/");
} catch (Exception e) {
StringBuilder diagnostics = new StringBuilder();
Thread diagnosticReader = new Thread(() -> readRipgrepDiagnostics(process, diagnostics),
"argi-ripgrep-diagnostics");
diagnosticReader.setDaemon(true);
diagnosticReader.start();

try {
Map<String, List<LineMatch>> results = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();

try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
try {
JsonNode data = mapper.readTree(line);
if (!"match".equals(data.path("type").asText())) {
continue;
}
} else {
virt = p.toString();
}
Integer ln = pdata.path("line_number").asInt(0);
if (ln == 0) {
continue;
}
String lt = pdata.path("lines").path("text").asText("");
if (lt.endsWith("\n")) {
lt = lt.substring(0, lt.length() - 1);
JsonNode pdata = data.path("data");
String ftext = pdata.path("path").path("text").asText(null);
if (ftext == null) {
continue;
}
Path p = Paths.get(ftext);
String virt;
if (virtualMode) {
try {
Path resolved = p.toAbsolutePath().normalize();
Path relative = cwd.relativize(resolved);
virt = "/" + relative.toString().replace("\\", "/");
} catch (Exception e) {
continue;
}
} else {
virt = p.toString();
}
Integer ln = pdata.path("line_number").asInt(0);
if (ln == 0) {
continue;
}
String lt = pdata.path("lines").path("text").asText("");
if (lt.endsWith("\n")) {
lt = lt.substring(0, lt.length() - 1);
}
results.computeIfAbsent(virt, k -> new ArrayList<>())
.add(new LineMatch(ln, lt));
} catch (Exception ignored) {
// Skip malformed JSON lines
}
results.computeIfAbsent(virt, k -> new ArrayList<>())
.add(new LineMatch(ln, lt));
} catch (Exception ignored) {
// Skip malformed JSON lines
}
}
}

process.waitFor();
return results;
int exitCode = process.waitFor();
diagnosticReader.join();
if (exitCode == 0) {
return RipgrepSearchResult.matches(results);
}
if (exitCode == 1) {
return RipgrepSearchResult.matches(Collections.emptyMap());
}
String diagnostic = diagnostics.toString().trim();
if (diagnostic.contains("regex parse error:")) {
return RipgrepSearchResult.fallback();
}
String message = "ripgrep failed with exit code " + exitCode;
return RipgrepSearchResult.error(diagnostic.isEmpty() ? message : message + ": " + diagnostic);
} catch (InterruptedException | IOException e) {
process.destroyForcibly();
diagnosticReader.interrupt();
throw e;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return RipgrepSearchResult.error("ripgrep search was interrupted");
} catch (Exception e) {
// Ripgrep not available or failed, return null to fallback to Java search
return null;
return RipgrepSearchResult.error("Unable to read ripgrep results: " + e.getMessage());
}
}

private boolean ripgrepCommandExistsOnPath() {
String path = System.getenv("PATH");
if (path == null || path.isEmpty()) {
return false;
}
String pathSeparator = System.getProperty("path.separator");
for (String directory : path.split(Pattern.quote(pathSeparator))) {
Path executable = directory.isEmpty() ? Paths.get("rg") : Paths.get(directory).resolve("rg");
if (Files.isRegularFile(executable)) {
return true;
}
}
return false;
}

private void readRipgrepDiagnostics(Process process, StringBuilder diagnostics) {
try (InputStreamReader reader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) {
char[] buffer = new char[512];
int count;
while ((count = reader.read(buffer)) != -1) {
int remaining = MAX_RIPGREP_DIAGNOSTIC_LENGTH - diagnostics.length();
if (remaining > 0) {
diagnostics.append(buffer, 0, Math.min(count, remaining));
}
}
} catch (IOException e) {
if (diagnostics.length() < MAX_RIPGREP_DIAGNOSTIC_LENGTH) {
diagnostics.append("Unable to read ripgrep diagnostics: ").append(e.getMessage());
}
}
}

Expand Down Expand Up @@ -734,6 +810,22 @@ private String formatTimestamp(Instant instant) {
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}

private record RipgrepSearchResult(Map<String, List<LineMatch>> matches, boolean fallbackToJava, String error) {

private static RipgrepSearchResult matches(Map<String, List<LineMatch>> matches) {
return new RipgrepSearchResult(matches, false, null);
}

private static RipgrepSearchResult fallback() {
return new RipgrepSearchResult(null, true, null);
}

private static RipgrepSearchResult error(String error) {
return new RipgrepSearchResult(null, false, error);
}

}

private static class LineMatch {
final int lineNum;
final String lineText;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* Copyright 2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.agentic.ai.agent.orchestration.extension.file;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

public class LocalFilesystemBackendRegexSemanticsTest {

@TempDir
Path root;

/** 验证启用 ripgrep 后,合法的 Java 前瞻正则与 Java 回退路径返回相同结果。 */
@Test
void ripgrepAndJavaFallbackShouldMatchTheSameLookAheadPattern() throws Exception {
assumeTrue(isRipgrepAvailable(), "ripgrep is required to reproduce issue #230");
Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8);

final String javaResult = probe("foo(?=bar)", "/sample.txt", null, emptyPath());
final String ripgrepResult = probe("foo(?=bar)", "/sample.txt", null, null);

assertEquals("matches=1", javaResult);
assertEquals(javaResult, ripgrepResult,
"Issue #230: enabling ripgrep must not change results for a valid Java regex");
}

/** 验证 ripgrep 的退出码 0 和 1 分别表示有匹配和无匹配。 */
@Test
void ripgrepShouldPreserveMatchAndNoMatchExitSemantics() throws Exception {
assumeTrue(isRipgrepAvailable(), "ripgrep is required to verify its exit codes");
Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8);

assertEquals("matches=1", probe("foo", "/sample.txt", null, null));
assertEquals("matches=0", probe("missing", "/sample.txt", null, null));
}

/** 验证 ripgrep 执行失败时返回诊断,而不是伪装成无匹配。 */
@Test
void ripgrepExecutionFailureShouldReturnDiagnostic() throws Exception {
Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8);
final Path bin = Files.createDirectories(this.root.resolve("failing-rg"));
final Path executable = bin.resolve("rg");
Files.writeString(executable, "#!/bin/sh\nprintf 'synthetic rg failure\\n' >&2\nexit 2\n",
StandardCharsets.UTF_8);
assertTrue(executable.toFile().setExecutable(true), "Could not make the fake rg executable");

final String result = probe("foo", "/sample.txt", null, bin);

assertTrue(result.startsWith("error="), result);
assertTrue(result.contains("exit code 2"), result);
assertTrue(result.contains("synthetic rg failure"), result);
}

/** 验证 PATH 中存在但没有执行权限的 rg 会返回启动错误。 */
@Test
void nonExecutableRipgrepShouldReturnStartError() throws Exception {
Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8);
final Path bin = Files.createDirectories(this.root.resolve("non-executable-rg"));
final Path executable = bin.resolve("rg");
Files.writeString(executable, "#!/bin/sh\nexit 0\n", StandardCharsets.UTF_8);
assertTrue(executable.toFile().setExecutable(false), "Could not remove executable permission from fake rg");

final String result = probe("foo", "/sample.txt", null, bin);

assertTrue(result.startsWith("error=Unable to start ripgrep:"), result);
}

/** 验证无效 glob 返回诊断,不会被当作正常空结果。 */
@Test
void invalidGlobShouldReturnDiagnostic() throws Exception {
Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8);

final String result = probe("foo", "/", "[", emptyPath());

assertTrue(result.startsWith("error="), result);
}

private boolean isRipgrepAvailable() throws Exception {
final Process process = new ProcessBuilder("rg", "--version")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
try {
return process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0;
}
finally {
process.destroyForcibly();
}
}

private Path emptyPath() throws Exception {
return Files.createDirectories(this.root.resolve("empty-path"));
}

private String probe(String pattern, String searchPath, String glob, Path commandPath) throws Exception {
final String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
final String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
final String suffix = commandPath == null ? "ripgrep" : "custom-path";
final Path result = this.root.resolve(suffix + ".result");
final Path log = this.root.resolve(suffix + ".log");
final ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath,
LocalFilesystemBackendRegexSemanticsTest.class.getName(), this.root.toString(), result.toString(), pattern,
searchPath, glob == null ? "<null>" : glob);
if (commandPath != null) {
builder.environment().put("PATH", commandPath.toString());
}
builder.redirectErrorStream(true).redirectOutput(log.toFile());
final Process process = builder.start();
try {
assertTrue(process.waitFor(30, TimeUnit.SECONDS), "Probe JVM timed out");
final String output = Files.readString(log);
assertEquals(0, process.exitValue(), output);
return Files.readString(result).trim();
}
finally {
process.destroyForcibly();
}
}

public static void main(String[] args) throws Exception {
final LocalFilesystemBackend backend = new LocalFilesystemBackend(args[0], true, 1);
final String glob = "<null>".equals(args[4]) ? null : args[4];
final Object result = backend.grepRaw(args[2], args[3], glob);
final String output = result instanceof java.util.List<?> matches ? "matches=" + matches.size()
: "error=" + result;
Files.writeString(Path.of(args[1]), output, StandardCharsets.UTF_8);
}

}
Loading