diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 00d9e8c3867..22b714b85d4 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -240,7 +240,8 @@ export namespace Ripgrep { if (done) break buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = buffer.split(/\r?\n/) buffer = lines.pop() || "" for (const line of lines) { @@ -379,7 +380,8 @@ export namespace Ripgrep { return [] } - const lines = result.text().trim().split("\n").filter(Boolean) + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = result.text().trim().split(/\r?\n/).filter(Boolean) // Parse JSON lines from ripgrep output return lines diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 99af448ba68..d73bc161683 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -49,7 +49,8 @@ export const GrepTool = Tool.define("grep", { throw new Error(`ripgrep failed: ${errorOutput}`) } - const lines = output.trim().split("\n") + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = output.trim().split(/\r?\n/) const matches = [] for (const line of lines) { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts new file mode 100644 index 00000000000..f3da666a091 --- /dev/null +++ b/packages/opencode/test/tool/grep.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { GrepTool } from "../../src/tool/grep" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +const projectRoot = path.join(__dirname, "../..") + +describe("tool.grep", () => { + test("basic search", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const grep = await GrepTool.init() + const result = await grep.execute( + { + pattern: "export", + path: path.join(projectRoot, "src/tool"), + include: "*.ts", + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + expect(result.output).toContain("Found") + }, + }) + }) + + test("no matches returns correct output", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + const result = await grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: tmp.path, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }, + }) + }) + + test("handles CRLF line endings in output", async () => { + // This test verifies the regex split handles both \n and \r\n + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a test file with content + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + const result = await grep.execute( + { + pattern: "line", + path: tmp.path, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }, + }) + }) +}) + +describe("CRLF regex handling", () => { + test("regex correctly splits Unix line endings", () => { + const unixOutput = "file1.txt|1|content1\nfile2.txt|2|content2\nfile3.txt|3|content3" + const lines = unixOutput.trim().split(/\r?\n/) + expect(lines.length).toBe(3) + expect(lines[0]).toBe("file1.txt|1|content1") + expect(lines[2]).toBe("file3.txt|3|content3") + }) + + test("regex correctly splits Windows CRLF line endings", () => { + const windowsOutput = "file1.txt|1|content1\r\nfile2.txt|2|content2\r\nfile3.txt|3|content3" + const lines = windowsOutput.trim().split(/\r?\n/) + expect(lines.length).toBe(3) + expect(lines[0]).toBe("file1.txt|1|content1") + expect(lines[2]).toBe("file3.txt|3|content3") + }) + + test("regex handles mixed line endings", () => { + const mixedOutput = "file1.txt|1|content1\nfile2.txt|2|content2\r\nfile3.txt|3|content3" + const lines = mixedOutput.trim().split(/\r?\n/) + expect(lines.length).toBe(3) + }) +})