diff --git a/src/tools/glob/cli.test.ts b/src/tools/glob/cli.test.ts new file mode 100644 index 00000000..54596923 --- /dev/null +++ b/src/tools/glob/cli.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "bun:test" +import { buildRgArgs, buildFindArgs, buildPowerShellCommand } from "./cli" + +describe("buildRgArgs", () => { + // #given default options (no hidden/follow specified) + // #when building ripgrep args + // #then should include --hidden and --follow by default + it("includes --hidden by default when not explicitly set", () => { + const args = buildRgArgs({ pattern: "*.ts" }) + expect(args).toContain("--hidden") + }) + + it("includes --follow by default when not explicitly set", () => { + const args = buildRgArgs({ pattern: "*.ts" }) + expect(args).toContain("--follow") + }) + + // #given hidden=false explicitly set + // #when building ripgrep args + // #then should NOT include --hidden + it("excludes --hidden when explicitly set to false", () => { + const args = buildRgArgs({ pattern: "*.ts", hidden: false }) + expect(args).not.toContain("--hidden") + }) + + // #given follow=false explicitly set + // #when building ripgrep args + // #then should NOT include --follow + it("excludes --follow when explicitly set to false", () => { + const args = buildRgArgs({ pattern: "*.ts", follow: false }) + expect(args).not.toContain("--follow") + }) + + // #given hidden=true explicitly set + // #when building ripgrep args + // #then should include --hidden + it("includes --hidden when explicitly set to true", () => { + const args = buildRgArgs({ pattern: "*.ts", hidden: true }) + expect(args).toContain("--hidden") + }) + + // #given follow=true explicitly set + // #when building ripgrep args + // #then should include --follow + it("includes --follow when explicitly set to true", () => { + const args = buildRgArgs({ pattern: "*.ts", follow: true }) + expect(args).toContain("--follow") + }) + + // #given pattern with special characters + // #when building ripgrep args + // #then should include glob pattern correctly + it("includes the glob pattern", () => { + const args = buildRgArgs({ pattern: "**/*.tsx" }) + expect(args).toContain("--glob=**/*.tsx") + }) +}) + +describe("buildFindArgs", () => { + // #given default options (no hidden/follow specified) + // #when building find args + // #then should include hidden files by default (no exclusion filter) + it("includes hidden files by default when not explicitly set", () => { + const args = buildFindArgs({ pattern: "*.ts" }) + // When hidden is enabled (default), should NOT have the exclusion filter + expect(args).not.toContain("-not") + expect(args.join(" ")).not.toContain("*/.*") + }) + + // #given default options (no follow specified) + // #when building find args + // #then should include -L flag for symlink following by default + it("includes -L flag for symlink following by default", () => { + const args = buildFindArgs({ pattern: "*.ts" }) + expect(args).toContain("-L") + }) + + // #given hidden=false explicitly set + // #when building find args + // #then should exclude hidden files + it("excludes hidden files when hidden is explicitly false", () => { + const args = buildFindArgs({ pattern: "*.ts", hidden: false }) + expect(args).toContain("-not") + expect(args.join(" ")).toContain("*/.*") + }) + + // #given follow=false explicitly set + // #when building find args + // #then should NOT include -L flag + it("excludes -L flag when follow is explicitly false", () => { + const args = buildFindArgs({ pattern: "*.ts", follow: false }) + expect(args).not.toContain("-L") + }) + + // #given hidden=true explicitly set + // #when building find args + // #then should include hidden files + it("includes hidden files when hidden is explicitly true", () => { + const args = buildFindArgs({ pattern: "*.ts", hidden: true }) + expect(args).not.toContain("-not") + expect(args.join(" ")).not.toContain("*/.*") + }) + + // #given follow=true explicitly set + // #when building find args + // #then should include -L flag + it("includes -L flag when follow is explicitly true", () => { + const args = buildFindArgs({ pattern: "*.ts", follow: true }) + expect(args).toContain("-L") + }) +}) + +describe("buildPowerShellCommand", () => { + // #given default options (no hidden specified) + // #when building PowerShell command + // #then should include -Force by default + it("includes -Force by default when not explicitly set", () => { + const args = buildPowerShellCommand({ pattern: "*.ts" }) + const command = args.join(" ") + expect(command).toContain("-Force") + }) + + // #given hidden=false explicitly set + // #when building PowerShell command + // #then should NOT include -Force + it("excludes -Force when hidden is explicitly false", () => { + const args = buildPowerShellCommand({ pattern: "*.ts", hidden: false }) + const command = args.join(" ") + expect(command).not.toContain("-Force") + }) + + // #given hidden=true explicitly set + // #when building PowerShell command + // #then should include -Force + it("includes -Force when hidden is explicitly true", () => { + const args = buildPowerShellCommand({ pattern: "*.ts", hidden: true }) + const command = args.join(" ") + expect(command).toContain("-Force") + }) + + // #given default options (no follow specified) + // #when building PowerShell command + // #then should NOT include -FollowSymlink (unsupported in Windows PowerShell 5.1) + it("does NOT include -FollowSymlink (unsupported in Windows PowerShell 5.1)", () => { + const args = buildPowerShellCommand({ pattern: "*.ts" }) + const command = args.join(" ") + expect(command).not.toContain("-FollowSymlink") + }) + + // #given pattern with special chars + // #when building PowerShell command + // #then should escape single quotes properly + it("escapes single quotes in pattern", () => { + const args = buildPowerShellCommand({ pattern: "test's.ts" }) + const command = args.join(" ") + expect(command).toContain("test''s.ts") + }) +}) diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 56461552..468f259a 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -22,7 +22,8 @@ function buildRgArgs(options: GlobOptions): string[] { `--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`, ] - if (options.hidden) args.push("--hidden") + if (options.hidden !== false) args.push("--hidden") + if (options.follow !== false) args.push("--follow") if (options.noIgnore) args.push("--no-ignore") args.push(`--glob=${options.pattern}`) @@ -31,7 +32,13 @@ function buildRgArgs(options: GlobOptions): string[] { } function buildFindArgs(options: GlobOptions): string[] { - const args: string[] = ["."] + const args: string[] = [] + + if (options.follow !== false) { + args.push("-L") + } + + args.push(".") const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH) args.push("-maxdepth", String(maxDepth)) @@ -39,7 +46,7 @@ function buildFindArgs(options: GlobOptions): string[] { args.push("-type", "f") args.push("-name", options.pattern) - if (!options.hidden) { + if (options.hidden === false) { args.push("-not", "-path", "*/.*") } @@ -56,10 +63,15 @@ function buildPowerShellCommand(options: GlobOptions): string[] { let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'` - if (options.hidden) { + if (options.hidden !== false) { psCommand += " -Force" } + // NOTE: Symlink following (-FollowSymlink) is NOT supported in PowerShell backend. + // -FollowSymlink was introduced in PowerShell Core 6.0+ and is unavailable in + // Windows PowerShell 5.1 (default on Windows). OpenCode auto-downloads ripgrep + // which handles symlinks via --follow. This fallback rarely triggers in practice. + psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName" return ["powershell", "-NoProfile", "-Command", psCommand] @@ -74,6 +86,8 @@ async function getFileMtime(filePath: string): Promise { } } +export { buildRgArgs, buildFindArgs, buildPowerShellCommand } + export async function runRgFiles( options: GlobOptions, resolvedCli?: ResolvedCli diff --git a/src/tools/glob/types.ts b/src/tools/glob/types.ts index 6691a9b4..0601873b 100644 --- a/src/tools/glob/types.ts +++ b/src/tools/glob/types.ts @@ -14,6 +14,7 @@ export interface GlobOptions { pattern: string paths?: string[] hidden?: boolean + follow?: boolean noIgnore?: boolean maxDepth?: number timeout?: number