From 88364ee830e03a975e84e423752bade5992ec45b Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 10 Mar 2026 22:35:32 +0100 Subject: [PATCH] feat(coding-agent/tools): added multi-path search support to find, grep, ast_grep, and ast_edit tools - Added support for comma/space-separated path lists in find, grep, ast_grep, and ast_edit tools, allowing users to search multiple directories with a single query (e.g., 'apps/,packages/,phases/' or 'apps/ packages/ phases/'). - Added resolveMultiSearchPath and resolveMultiFindPattern utility functions to path-utils for intelligent parsing and resolution of multi-path search inputs with automatic common base path detection. - Updated tool documentation for find, grep, ast_grep, and ast_edit to clarify that path parameters accept files, directories, glob patterns, or comma/space-separated path lists. - Refactored path resolution logic in find, grep, ast_grep, and ast_edit tools to use unified multi-path handling with intelligent delimiter detection (comma or whitespace). --- packages/coding-agent/CHANGELOG.md | 9 + .../src/prompts/tools/ast-edit.md | 2 +- .../src/prompts/tools/ast-grep.md | 2 +- .../coding-agent/src/prompts/tools/find.md | 1 + .../coding-agent/src/prompts/tools/grep.md | 1 + packages/coding-agent/src/tools/ast-edit.ts | 31 +- packages/coding-agent/src/tools/ast-grep.ts | 33 +- packages/coding-agent/src/tools/find.ts | 73 +--- packages/coding-agent/src/tools/grep.ts | 35 +- packages/coding-agent/src/tools/path-utils.ts | 342 ++++++++++++++++++ .../test/tools/search-path-lists.test.ts | 197 ++++++++++ 11 files changed, 639 insertions(+), 87 deletions(-) create mode 100644 packages/coding-agent/test/tools/search-path-lists.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 49dcf0946..91de69163 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- Support for comma/space-separated path lists in `find`, `grep`, `ast_grep`, and `ast_edit` tools (e.g., `apps/,packages/,phases/` or `apps/ packages/ phases/`) +- New `resolveMultiSearchPath` and `resolveMultiFindPattern` functions to handle multi-path search inputs with automatic common base path detection + +### Changed + +- Updated tool documentation to clarify that `path` parameter accepts files, directories, glob patterns, or comma/space-separated path lists +- Refactored path resolution logic in `find`, `grep`, `ast_grep`, and `ast_edit` tools to use unified multi-path handling ## [13.10.1] - 2026-03-10 ### Added diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index 9e15b8296..185b4039d 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -2,7 +2,7 @@ Performs structural AST-aware rewrites via native ast-grep. - Use for codemods and structural rewrites where plain text replace is unsafe -- Narrow scope with `path` before replacing (`path` accepts files, directories, or glob patterns; use `glob` for an additional filter relative to `path`) +- Narrow scope with `path` before replacing (`path` accepts files, directories, glob patterns, or comma/space-separated path lists; use `glob` for an additional filter relative to `path`) - Default to language-scoped rewrites in mixed repositories: set `lang` and keep `path`/`glob` narrow - Treat parse issues as a scoping or pattern-shape signal: tighten `path`/`lang`, or rewrite the pattern into valid syntax before retrying - Metavariables captured in each rewrite pattern (`$A`, `$$$ARGS`) are substituted into that entry's rewrite template diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index 8e90196f2..8a3f92fb4 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -2,7 +2,7 @@ Performs structural code search using AST matching via native ast-grep. - Use this when syntax shape matters more than raw text (calls, declarations, specific language constructs) -- Prefer a precise `path` scope to keep results targeted and deterministic (`path` accepts files, directories, or glob patterns; use `glob` for an additional filter relative to `path`) +- Prefer a precise `path` scope to keep results targeted and deterministic (`path` accepts files, directories, glob patterns, or comma/space-separated path lists; use `glob` for an additional filter relative to `path`) - Default to language-scoped search in mixed repositories: pair `path` + `glob` + explicit `lang` to avoid parse-noise from non-source files - `pat` is required and must include at least one non-empty AST pattern; `lang` is optional (`lang` is inferred per file extension when omitted) - Multiple patterns run in one native pass; results are merged and then `offset`/`limit` are applied to the combined match set diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index f6ac32e47..2ef90f464 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -2,6 +2,7 @@ Finds files using fast pattern matching that works with any codebase size. - Pattern includes the search path: `src/**/*.ts`, `lib/*.json`, `**/*.md` +- You may provide comma/space-separated path lists, for example `apps/,packages/,phases/` or `apps/ packages/ phases/`; each item is searched and results are merged - Simple patterns like `*.ts` automatically search recursively from cwd - Includes hidden files by default (use `hidden: false` to exclude) - You **SHOULD** perform multiple searches in parallel when potentially useful diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index d5fdb241e..e81c8752c 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -2,6 +2,7 @@ Searches files using powerful regex matching built on ripgrep. - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go) +- `path` may be a file, directory, glob path, or comma/space-separated path list; pair it with `glob` when you need an additional relative file filter - Filter files with `glob` (e.g., `*.js`, `**/*.tsx`) or `type` (e.g., `js`, `py`, `rust`) - Respects `.gitignore` by default; set `gitignore: false` to include ignored files - For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true` if needed diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 993ae6726..5c6199281 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -14,7 +14,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; -import { combineSearchGlobs, hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils"; +import { + combineSearchGlobs, + hasGlobPathChars, + parseSearchPath, + resolveMultiSearchPath, + resolveToCwd, +} from "./path-utils"; import { dedupeParseErrors, formatCount, @@ -98,7 +104,12 @@ export class AstEditTool implements AgentTool { + const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/"); + return relative.length === 0 ? "." : relative; + }; let searchPath: string | undefined; + let scopePath: string | undefined; let globFilter = params.glob?.trim() || undefined; const rawPath = params.path?.trim(); if (rawPath) { @@ -112,15 +123,23 @@ export class AstEditTool implements AgentTool { + const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/"); + return relative.length === 0 ? "." : relative; + }; let searchPath: string | undefined; + let scopePath: string | undefined; let globFilter = params.glob?.trim() || undefined; const rawPath = params.path?.trim(); if (rawPath) { @@ -100,18 +111,24 @@ export class AstGrepTool implements AgentTool { - const relative = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/"); - return relative.length === 0 ? "." : relative; - })(); + scopePath = scopePath ?? formatScopePath(resolvedSearchPath); let isDirectory: boolean; try { const stat = await Bun.file(resolvedSearchPath).stat(); diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 9cb3106a0..8c19e8ceb 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -24,7 +24,7 @@ import { import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; -import { resolveToCwd } from "./path-utils"; +import { parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils"; import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -40,56 +40,6 @@ export type FindToolInput = Static; const DEFAULT_LIMIT = 1000; const GLOB_TIMEOUT_MS = 5000; -/** - * Parse a pattern to extract the base directory path and glob pattern. - * Examples: - * "src/app/**\/*.tsx" → { basePath: "src/app", globPattern: "**\/*.tsx" } - * "src/app/*.tsx" → { basePath: "src/app", globPattern: "*.tsx" } - * "*.ts" → { basePath: ".", globPattern: "**\/*.ts" } - * "**\/*.json" → { basePath: ".", globPattern: "**\/*.json" } - * "/abs/path/**\/*.ts" → { basePath: "/abs/path", globPattern: "**\/*.ts" } - */ -function parsePatternPath(pattern: string): { basePath: string; globPattern: string } { - // Find the first segment containing glob characters - const segments = pattern.split("/"); - const globChars = ["*", "?", "[", "{"]; - - let firstGlobIndex = -1; - for (let i = 0; i < segments.length; i++) { - if (globChars.some(c => segments[i].includes(c))) { - firstGlobIndex = i; - break; - } - } - - // No glob characters found - treat as literal path with implicit **/* - if (firstGlobIndex === -1) { - // Pattern is a directory path like "src/app" - search recursively in it - return { basePath: pattern, globPattern: "**/*" }; - } - - // Glob starts at first segment - no base path - if (firstGlobIndex === 0) { - // Simple pattern like "*.ts" needs **/ prefix for recursive search - const needsRecursive = !pattern.startsWith("**/"); - return { - basePath: ".", - globPattern: needsRecursive ? `**/${pattern}` : pattern, - }; - } - - // Split at the glob boundary - const basePath = segments.slice(0, firstGlobIndex).join("/"); - const globPattern = segments.slice(firstGlobIndex).join("/"); - - return { basePath, globPattern }; -} - -function hasGlobChars(pattern: string): boolean { - const globChars = ["*", "?", "[", "{"]; - return globChars.some(char => pattern.includes(char)); -} - export interface FindToolDetails { truncation?: TruncationResult; resultLimitReached?: number; @@ -149,27 +99,26 @@ export class FindTool implements AgentTool { const { pattern, limit, hidden } = params; return untilAborted(signal, async () => { - // Parse pattern to extract base directory and glob pattern - // e.g., "src/app/**/*.tsx" → basePath: "src/app", globPattern: "**/*.tsx" - // e.g., "*.ts" → basePath: ".", globPattern: "**/*.ts" + const formatScopePath = (targetPath: string): string => { + const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/"); + return relative.length === 0 ? "." : relative; + }; const normalizedPattern = pattern.trim().replace(/\\/g, "/"); if (!normalizedPattern) { throw new ToolError("Pattern must not be empty"); } - const hasGlob = hasGlobChars(normalizedPattern); - const { basePath, globPattern } = parsePatternPath(normalizedPattern); - const searchPath = resolveToCwd(basePath, this.session.cwd); + const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd); + const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern); + const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false); + const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*"; + const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd); + const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath); if (searchPath === "/") { throw new ToolError("Searching from root directory '/' is not allowed"); } - const scopePath = (() => { - const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/"); - return relative.length === 0 ? "." : relative; - })(); - const rawLimit = limit ?? DEFAULT_LIMIT; const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN; if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) { diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 39f1c19b2..0e4934ffb 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -16,7 +16,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; -import { combineSearchGlobs, hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils"; +import { + combineSearchGlobs, + hasGlobPathChars, + parseSearchPath, + resolveMultiSearchPath, + resolveToCwd, +} from "./path-utils"; import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -107,7 +113,12 @@ export class GrepTool implements AgentTool { const effectiveMultiline = multiline ?? patternHasNewline; const useHashLines = resolveFileDisplayMode(this.session).hashLines; + const formatScopePath = (targetPath: string): string => { + const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/"); + return relative.length === 0 ? "." : relative; + }; let searchPath: string; + let scopePath: string; let globFilter = glob?.trim() || undefined; const internalRouter = this.session.internalRouter; if (searchDir?.trim()) { @@ -121,20 +132,26 @@ export class GrepTool implements AgentTool { throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`); } searchPath = resource.sourcePath; + scopePath = formatScopePath(searchPath); } else { - const parsedPath = parseSearchPath(rawPath); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - if (parsedPath.glob) { - globFilter = combineSearchGlobs(parsedPath.glob, globFilter); + const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter); + if (multiSearchPath) { + searchPath = multiSearchPath.basePath; + globFilter = multiSearchPath.glob; + scopePath = multiSearchPath.scopePath; + } else { + const parsedPath = parseSearchPath(rawPath); + searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); + if (parsedPath.glob) { + globFilter = combineSearchGlobs(parsedPath.glob, globFilter); + } + scopePath = formatScopePath(searchPath); } } } else { searchPath = resolveToCwd(".", this.session.cwd); + scopePath = "."; } - const scopePath = (() => { - const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/"); - return relative.length === 0 ? "." : relative; - })(); let isDirectory: boolean; try { diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 241208252..95f760a90 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -4,6 +4,14 @@ import * as path from "node:path"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; +const TOP_LEVEL_INTERNAL_URL_PREFIXES = [ + "agent://", + "artifact://", + "skill://", + "rule://", + "local://", + "mcp://", +] as const; function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); @@ -38,6 +46,15 @@ function fileExists(filePath: string): boolean { } } +async function pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + function normalizeAtPrefix(filePath: string): string { if (!filePath.startsWith("@")) return filePath; @@ -105,6 +122,24 @@ export interface ParsedSearchPath { glob?: string; } +export interface ParsedFindPattern { + basePath: string; + globPattern: string; + hasGlob: boolean; +} + +export interface ResolvedMultiSearchPath { + basePath: string; + glob?: string; + scopePath: string; +} + +export interface ResolvedMultiFindPattern { + basePath: string; + globPattern: string; + scopePath: string; +} + /** * Split a user path into a base path + glob pattern for tools that delegate to * APIs accepting separate `path` and `glob` arguments. @@ -128,6 +163,44 @@ export function parseSearchPath(filePath: string): ParsedSearchPath { }; } +// Parse a find pattern into a base directory path and a glob pattern. +// Examples: +// src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true } +// src/app/\*.tsx -> { basePath: "src/app", globPattern: "*.tsx", hasGlob: true } +// \*.ts -> { basePath: ".", globPattern: "**/*.ts", hasGlob: true } +// **/\*.json -> { basePath: ".", globPattern: "**/*.json", hasGlob: true } +// /abs/path/**/\*.ts -> { basePath: "/abs/path", globPattern: "**/*.ts", hasGlob: true } +// src/app -> { basePath: "src/app", globPattern: "**/*", hasGlob: false } +export function parseFindPattern(pattern: string): ParsedFindPattern { + const segments = pattern.split("/"); + let firstGlobIndex = -1; + for (let i = 0; i < segments.length; i++) { + if (hasGlobPathChars(segments[i])) { + firstGlobIndex = i; + break; + } + } + + if (firstGlobIndex === -1) { + return { basePath: pattern, globPattern: "**/*", hasGlob: false }; + } + + if (firstGlobIndex === 0) { + const needsRecursive = !pattern.startsWith("**/"); + return { + basePath: ".", + globPattern: needsRecursive ? `**/${pattern}` : pattern, + hasGlob: true, + }; + } + + return { + basePath: segments.slice(0, firstGlobIndex).join("/"), + globPattern: segments.slice(firstGlobIndex).join("/"), + hasGlob: true, + }; +} + export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): string | undefined { if (!prefixGlob) return suffixGlob; if (!suffixGlob) return prefixGlob; @@ -138,6 +211,275 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st return `${normalizedPrefix}/${normalizedSuffix}`; } +type TopLevelSeparator = "comma" | "whitespace"; + +function splitTopLevel(value: string, separator: TopLevelSeparator): string[] { + const parts: string[] = []; + let current = ""; + let braceDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let quote: '"' | "'" | undefined; + let escaped = false; + + const pushCurrent = () => { + const normalized = current.trim(); + if (normalized.length > 0) { + parts.push(normalized); + } + current = ""; + }; + + for (const char of value) { + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (char === "\\") { + current += char; + escaped = true; + continue; + } + + if (quote) { + current += char; + if (char === quote) { + quote = undefined; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + + if (char === "{") braceDepth += 1; + else if (char === "}" && braceDepth > 0) braceDepth -= 1; + else if (char === "[") bracketDepth += 1; + else if (char === "]" && bracketDepth > 0) bracketDepth -= 1; + else if (char === "(") parenDepth += 1; + else if (char === ")" && parenDepth > 0) parenDepth -= 1; + + const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0; + const isWhitespace = /\s/.test(char); + if (topLevel && separator === "comma" && char === ",") { + pushCurrent(); + continue; + } + if (topLevel && separator === "whitespace" && isWhitespace) { + pushCurrent(); + continue; + } + + current += char; + } + + pushCurrent(); + return parts.length > 1 ? parts : [value.trim()]; +} + +function normalizePosixPath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function joinRelativeGlob(basePath: string | undefined, globPattern: string): string { + if (!basePath || basePath === ".") return normalizePosixPath(globPattern).replace(/^\/+/, ""); + const normalizedBase = normalizePosixPath(basePath).replace(/\/+$/, ""); + const normalizedGlob = normalizePosixPath(globPattern).replace(/^\/+/, ""); + return `${normalizedBase}/${normalizedGlob}`; +} + +function buildBraceUnion(patterns: string[]): string | undefined { + const uniquePatterns = [...new Set(patterns.map(pattern => normalizePosixPath(pattern).trim()).filter(Boolean))]; + if (uniquePatterns.length === 0) return undefined; + if (uniquePatterns.length === 1) return uniquePatterns[0]; + return `{${uniquePatterns.join(",")}}`; +} + +function findCommonBasePath(paths: string[]): string { + if (paths.length === 0) return "."; + let commonParts = path.resolve(paths[0]).split(path.sep); + for (const candidatePath of paths.slice(1)) { + const candidateParts = path.resolve(candidatePath).split(path.sep); + let sharedCount = 0; + const maxShared = Math.min(commonParts.length, candidateParts.length); + while (sharedCount < maxShared && commonParts[sharedCount] === candidateParts[sharedCount]) { + sharedCount += 1; + } + commonParts = commonParts.slice(0, sharedCount); + } + if (commonParts.length === 0) { + return path.parse(path.resolve(paths[0])).root; + } + const joined = commonParts.join(path.sep); + return joined || path.parse(path.resolve(paths[0])).root; +} + +function toScopeDisplay(items: string[]): string { + return items.map(item => normalizePosixPath(item)).join(", "); +} + +function looksLikeDelimitedPathToken(token: string): boolean { + return ( + TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) || + token.startsWith(".") || + token.startsWith("/") || + token.startsWith("~") || + token.startsWith("@") || + token.includes("/") || + token.includes("\\") || + hasGlobPathChars(token) || + /\.[^./\\]+$/.test(token) + ); +} + +async function areDelimitedTokensResolvable( + tokens: string[], + cwd: string, + parseBasePath: (value: string) => string, + allowBareExistingTokens: boolean, +): Promise { + for (const token of tokens) { + if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) { + return false; + } + + if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) { + return false; + } + + const basePath = parseBasePath(token); + const resolvedBasePath = resolveToCwd(basePath, cwd); + if (await pathExists(resolvedBasePath)) { + continue; + } + + if (!allowBareExistingTokens) { + return false; + } + + const resolvedExactPath = resolveToCwd(token, cwd); + if (!(await pathExists(resolvedExactPath))) { + return false; + } + } + + return true; +} + +async function splitDelimitedSearchInput( + rawInput: string, + cwd: string, + parseBasePath: (value: string) => string, +): Promise { + const trimmed = rawInput.trim(); + if (!trimmed) return undefined; + + const resolvedExactPath = resolveToCwd(trimmed, cwd); + if (await pathExists(resolvedExactPath)) { + return undefined; + } + + const commaSeparated = splitTopLevel(trimmed, "comma"); + if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) { + return [...new Set(commaSeparated)]; + } + + const whitespaceSeparated = splitTopLevel(trimmed, "whitespace"); + if ( + whitespaceSeparated.length > 1 && + (await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false)) + ) { + return [...new Set(whitespaceSeparated)]; + } + + return undefined; +} + +export async function resolveMultiSearchPath( + rawPath: string, + cwd: string, + suffixGlob?: string, +): Promise { + const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath); + if (!pathItems || pathItems.length <= 1) { + return undefined; + } + + const parsedItems = await Promise.all( + pathItems.map(async item => { + const parsedPath = parseSearchPath(item); + const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd); + const stat = await fs.promises.stat(absoluteBasePath); + return { raw: item, parsedPath, absoluteBasePath, stat }; + }), + ); + + const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath)); + const combinedPatterns = parsedItems.map(item => { + const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || "."; + if (item.parsedPath.glob) { + const pathGlob = joinRelativeGlob(relativeBasePath, item.parsedPath.glob); + return combineSearchGlobs(pathGlob, suffixGlob) ?? pathGlob; + } + if (suffixGlob) { + const pathPrefix = relativeBasePath === "." ? undefined : relativeBasePath; + return combineSearchGlobs(pathPrefix, suffixGlob) ?? suffixGlob; + } + if (item.stat.isDirectory()) { + return joinRelativeGlob(relativeBasePath, "**/*"); + } + return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath; + }); + + return { + basePath: commonBasePath, + glob: buildBraceUnion(combinedPatterns), + scopePath: toScopeDisplay(pathItems), + }; +} + +export async function resolveMultiFindPattern( + rawPattern: string, + cwd: string, +): Promise { + const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath); + if (!patternItems || patternItems.length <= 1) { + return undefined; + } + + const parsedItems = await Promise.all( + patternItems.map(async item => { + const parsedPattern = parseFindPattern(item); + const absoluteBasePath = resolveToCwd(parsedPattern.basePath, cwd); + const stat = await fs.promises.stat(absoluteBasePath); + return { raw: item, parsedPattern, absoluteBasePath, stat }; + }), + ); + + const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath)); + const combinedPatterns = parsedItems.map(item => { + const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || "."; + if (item.parsedPattern.hasGlob) { + return joinRelativeGlob(relativeBasePath, item.parsedPattern.globPattern); + } + if (item.stat.isDirectory()) { + return joinRelativeGlob(relativeBasePath, "**/*"); + } + return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath; + }); + + return { + basePath: commonBasePath, + globPattern: buildBraceUnion(combinedPatterns) ?? "**/*", + scopePath: toScopeDisplay(patternItems), + }; +} + export function resolveReadPath(filePath: string, cwd: string): string { const resolved = resolveToCwd(filePath, cwd); const shellEscapedVariant = tryShellEscapedPath(resolved); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts new file mode 100644 index 000000000..8d4c918ef --- /dev/null +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { PendingActionStore } from "@oh-my-pi/pi-coding-agent/tools/pending-action"; + +function createTestSession(cwd: string, overrides: Partial = {}): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + ...overrides, + }; +} + +function getText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter(entry => entry.type === "text") + .map(entry => entry.text ?? "") + .join("\n"); +} + +async function createSearchFixture(rootDir: string): Promise { + const targets = ["apps", "packages", "phases"] as const; + for (const target of targets) { + await fs.mkdir(path.join(rootDir, target), { recursive: true }); + } + await fs.mkdir(path.join(rootDir, "other"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "folder with spaces"), { recursive: true }); + + await Bun.write(path.join(rootDir, "apps", "grep.txt"), "shared-needle apps\n"); + await Bun.write(path.join(rootDir, "packages", "grep.txt"), "shared-needle packages\n"); + await Bun.write(path.join(rootDir, "phases", "grep.txt"), "shared-needle phases\n"); + await Bun.write(path.join(rootDir, "other", "grep.txt"), "shared-needle other\n"); + await Bun.write(path.join(rootDir, "folder with spaces", "note.txt"), "space-needle\n"); + + await Bun.write( + path.join(rootDir, "apps", "ast.ts"), + "const providerOptions = {};\nlegacyWrap(appsValue, appsArg);\n", + ); + await Bun.write( + path.join(rootDir, "packages", "ast.ts"), + "const providerOptions = {};\nlegacyWrap(packagesValue, packagesArg);\n", + ); + await Bun.write( + path.join(rootDir, "phases", "ast.ts"), + "const providerOptions = {};\nlegacyWrap(phasesValue, phasesArg);\n", + ); + await Bun.write( + path.join(rootDir, "other", "ast.ts"), + "const providerOptions = {};\nlegacyWrap(otherValue, otherArg);\n", + ); +} + +describe("search tool path lists", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "search-path-lists-")); + await createSearchFixture(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("grep accepts space-separated path lists", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "grep"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing grep tool"); + + const result = await tool.execute("grep-space-paths", { + pattern: "shared-needle", + path: "apps/ packages/ phases/", + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toContain("# apps"); + expect(text).toContain("# packages"); + expect(text).toContain("# phases"); + expect(text).toContain("## └─ grep.txt"); + expect(text).not.toContain("# other"); + expect(details?.fileCount).toBe(3); + expect(details?.scopePath).toBe("apps/, packages/, phases/"); + }); + + it("grep keeps a single path that contains spaces", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "grep"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing grep tool"); + + const result = await tool.execute("grep-space-directory", { + pattern: "space-needle", + path: "folder with spaces/", + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toContain("note.txt"); + expect(details?.fileCount).toBe(1); + expect(details?.scopePath).toBe("folder with spaces"); + }); + + it("ast_grep accepts comma-separated path lists", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "ast_grep"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing ast_grep tool"); + + const result = await tool.execute("ast-grep-comma-paths", { + pat: ["providerOptions"], + sel: "identifier", + lang: "typescript", + path: "apps/,packages/,phases/", + glob: "**/*.ts", + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toContain("# apps"); + expect(text).toContain("# packages"); + expect(text).toContain("# phases"); + expect(text).toContain("## └─ ast.ts"); + expect(text).not.toContain("# other"); + expect(details?.fileCount).toBe(3); + expect(details?.scopePath).toBe("apps/, packages/, phases/"); + }); + + it("ast_edit applies across a space-separated path list", async () => { + const pendingActionStore = new PendingActionStore(); + const tools = await createTools(createTestSession(tempDir, { pendingActionStore })); + const tool = tools.find(entry => entry.name === "ast_edit"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing ast_edit tool"); + + const preview = await tool.execute("ast-edit-space-paths", { + ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], + lang: "typescript", + path: "apps/ packages/ phases/", + glob: "**/*.ts", + }); + const text = getText(preview); + const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined; + + expect(text).toContain("# apps"); + expect(text).toContain("# packages"); + expect(text).toContain("# phases"); + expect(text).toContain("## └─ ast.ts (1 replacement)"); + expect(text).not.toContain("# other"); + expect(details?.totalReplacements).toBe(3); + expect(details?.scopePath).toBe("apps/, packages/, phases/"); + + const pending = pendingActionStore.peek(); + expect(pending).not.toBeNull(); + if (!pending) throw new Error("Expected pending action"); + await pending.apply("apply multi-path ast edit"); + + expect(await Bun.file(path.join(tempDir, "apps", "ast.ts")).text()).toContain("modernWrap(appsValue, appsArg)"); + expect(await Bun.file(path.join(tempDir, "packages", "ast.ts")).text()).toContain( + "modernWrap(packagesValue, packagesArg)", + ); + expect(await Bun.file(path.join(tempDir, "phases", "ast.ts")).text()).toContain( + "modernWrap(phasesValue, phasesArg)", + ); + expect(await Bun.file(path.join(tempDir, "other", "ast.ts")).text()).toContain( + "legacyWrap(otherValue, otherArg)", + ); + }); + + it("find accepts comma-separated path lists", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + const result = await tool.execute("find-comma-paths", { + pattern: "apps/,packages/,phases/", + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toContain("apps/ast.ts"); + expect(text).toContain("packages/ast.ts"); + expect(text).toContain("phases/ast.ts"); + expect(text).toContain("apps/grep.txt"); + expect(text).not.toContain("other/ast.ts"); + expect(details?.fileCount).toBe(6); + expect(details?.scopePath).toBe("apps/, packages/, phases/"); + }); +});