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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- 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
|
||||
|
||||
@@ -2,7 +2,7 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- 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
|
||||
|
||||
@@ -2,6 +2,7 @@ Finds files using fast pattern matching that works with any codebase size.
|
||||
|
||||
<instruction>
|
||||
- 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
|
||||
|
||||
@@ -2,6 +2,7 @@ Searches files using powerful regex matching built on ripgrep.
|
||||
|
||||
<instruction>
|
||||
- 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
|
||||
|
||||
@@ -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<typeof astEditSchema, AstEditToolD
|
||||
}
|
||||
const maxFiles = parseInt(process.env.PI_MAX_AST_FILES ?? "", 10) || 1000;
|
||||
|
||||
const formatScopePath = (targetPath: string): string => {
|
||||
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<typeof astEditSchema, AstEditToolD
|
||||
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
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);
|
||||
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
|
||||
const scopePath = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/") || ".";
|
||||
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
const stat = await Bun.file(resolvedSearchPath).stat();
|
||||
|
||||
@@ -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,
|
||||
@@ -86,7 +92,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
throw new ToolError("Context must be a non-negative number");
|
||||
}
|
||||
|
||||
const formatScopePath = (targetPath: string): string => {
|
||||
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<typeof astGrepSchema, AstGrepToolD
|
||||
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
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);
|
||||
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
|
||||
const scopePath = (() => {
|
||||
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();
|
||||
|
||||
@@ -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<typeof findSchema>;
|
||||
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<typeof findSchema, FindToolDetails> {
|
||||
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) {
|
||||
|
||||
@@ -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<typeof grepSchema, GrepToolDetails> {
|
||||
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<typeof grepSchema, GrepToolDetails> {
|
||||
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 {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<string[] | undefined> {
|
||||
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<ResolvedMultiSearchPath | undefined> {
|
||||
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<ResolvedMultiFindPattern | undefined> {
|
||||
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);
|
||||
|
||||
@@ -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> = {}): 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<void> {
|
||||
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/");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user