Feat: Simplify and Improve Search Tools (glob, grep, ripgrep) (#969)

This commit is contained in:
tanzhenxin
2025-11-06 16:25:06 +08:00
committed by GitHub
parent decb04efc4
commit d17c37af7d
9 changed files with 840 additions and 782 deletions

View File

@@ -4,8 +4,53 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { escapePath, unescapePath, isSubpath } from './paths.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import {
escapePath,
resolvePath,
validatePath,
resolveAndValidatePath,
unescapePath,
isSubpath,
} from './paths.js';
import type { Config } from '../config/config.js';
function createConfigStub({
targetDir,
allowedDirectories,
}: {
targetDir: string;
allowedDirectories: string[];
}): Config {
const resolvedTargetDir = path.resolve(targetDir);
const resolvedDirectories = allowedDirectories.map((dir) =>
path.resolve(dir),
);
const workspaceContext = {
isPathWithinWorkspace(testPath: string) {
const resolvedPath = path.resolve(testPath);
return resolvedDirectories.some((dir) => {
const relative = path.relative(dir, resolvedPath);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
});
},
getDirectories() {
return resolvedDirectories;
},
};
return {
getTargetDir: () => resolvedTargetDir,
getWorkspaceContext: () => workspaceContext,
} as unknown as Config;
}
describe('escapePath', () => {
it('should escape spaces', () => {
@@ -314,3 +359,240 @@ describe('isSubpath on Windows', () => {
expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false);
});
});
describe('resolvePath', () => {
it('resolves relative paths against the provided base directory', () => {
const result = resolvePath('/home/user/project', 'src/main.ts');
expect(result).toBe(path.resolve('/home/user/project', 'src/main.ts'));
});
it('resolves relative paths against cwd when baseDir is undefined', () => {
const cwd = process.cwd();
const result = resolvePath(undefined, 'src/main.ts');
expect(result).toBe(path.resolve(cwd, 'src/main.ts'));
});
it('returns absolute paths unchanged', () => {
const absolutePath = '/absolute/path/to/file.ts';
const result = resolvePath('/some/base', absolutePath);
expect(result).toBe(absolutePath);
});
it('expands tilde to home directory', () => {
const homeDir = os.homedir();
const result = resolvePath(undefined, '~');
expect(result).toBe(homeDir);
});
it('expands tilde-prefixed paths to home directory', () => {
const homeDir = os.homedir();
const result = resolvePath(undefined, '~/documents/file.txt');
expect(result).toBe(path.join(homeDir, 'documents/file.txt'));
});
it('uses baseDir when provided for relative paths', () => {
const baseDir = '/custom/base';
const result = resolvePath(baseDir, './relative/path');
expect(result).toBe(path.resolve(baseDir, './relative/path'));
});
it('handles tilde expansion regardless of baseDir', () => {
const homeDir = os.homedir();
const result = resolvePath('/some/base', '~/file.txt');
expect(result).toBe(path.join(homeDir, 'file.txt'));
});
it('handles dot paths correctly', () => {
const result = resolvePath('/base/dir', '.');
expect(result).toBe(path.resolve('/base/dir', '.'));
});
it('handles parent directory references', () => {
const result = resolvePath('/base/dir/subdir', '..');
expect(result).toBe(path.resolve('/base/dir/subdir', '..'));
});
});
describe('validatePath', () => {
let workspaceRoot: string;
let config: Config;
beforeAll(() => {
workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'validate-path-test-'),
);
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
config = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot],
});
});
afterAll(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it('validates paths within workspace boundaries', () => {
const validPath = path.join(workspaceRoot, 'subdir');
expect(() => validatePath(config, validPath)).not.toThrow();
});
it('throws when path is outside workspace boundaries', () => {
const outsidePath = path.join(os.tmpdir(), 'outside');
expect(() => validatePath(config, outsidePath)).toThrowError(
/Path is not within workspace/,
);
});
it('throws when path does not exist', () => {
const nonExistentPath = path.join(workspaceRoot, 'nonexistent');
expect(() => validatePath(config, nonExistentPath)).toThrowError(
/Path does not exist:/,
);
});
it('throws when path is a file, not a directory (default behavior)', () => {
const filePath = path.join(workspaceRoot, 'test-file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() => validatePath(config, filePath)).toThrowError(
/Path is not a directory/,
);
} finally {
fs.rmSync(filePath);
}
});
it('allows files when allowFiles option is true', () => {
const filePath = path.join(workspaceRoot, 'test-file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() =>
validatePath(config, filePath, { allowFiles: true }),
).not.toThrow();
} finally {
fs.rmSync(filePath);
}
});
it('validates paths at workspace root', () => {
expect(() => validatePath(config, workspaceRoot)).not.toThrow();
});
it('validates paths in allowed directories', () => {
const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-'));
try {
const configWithExtra = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, extraDir],
});
expect(() => validatePath(configWithExtra, extraDir)).not.toThrow();
} finally {
fs.rmSync(extraDir, { recursive: true, force: true });
}
});
});
describe('resolveAndValidatePath', () => {
let workspaceRoot: string;
let config: Config;
beforeAll(() => {
workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-'),
);
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
config = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot],
});
});
afterAll(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it('returns the target directory when no path is provided', () => {
expect(resolveAndValidatePath(config)).toBe(workspaceRoot);
});
it('resolves relative paths within the workspace', () => {
const expected = path.join(workspaceRoot, 'subdir');
expect(resolveAndValidatePath(config, 'subdir')).toBe(expected);
});
it('allows absolute paths that are permitted by the workspace context', () => {
const extraDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-extra-'),
);
try {
const configWithExtra = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, extraDir],
});
expect(resolveAndValidatePath(configWithExtra, extraDir)).toBe(extraDir);
} finally {
fs.rmSync(extraDir, { recursive: true, force: true });
}
});
it('expands tilde-prefixed paths using the home directory', () => {
const fakeHome = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-home-'),
);
const homeSubdir = path.join(fakeHome, 'project');
fs.mkdirSync(homeSubdir);
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
try {
const configWithHome = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, fakeHome],
});
expect(resolveAndValidatePath(configWithHome, '~/project')).toBe(
homeSubdir,
);
expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome);
} finally {
homedirSpy.mockRestore();
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it('throws when the path resolves outside of the workspace', () => {
expect(() => resolveAndValidatePath(config, '../outside')).toThrowError(
/Path is not within workspace/,
);
});
it('throws when the path does not exist', () => {
expect(() => resolveAndValidatePath(config, 'missing')).toThrowError(
/Path does not exist:/,
);
});
it('throws when the path points to a file (default behavior)', () => {
const filePath = path.join(workspaceRoot, 'file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() => resolveAndValidatePath(config, 'file.txt')).toThrowError(
`Path is not a directory: ${filePath}`,
);
} finally {
fs.rmSync(filePath);
}
});
it('allows file paths when allowFiles option is true', () => {
const filePath = path.join(workspaceRoot, 'file.txt');
fs.writeFileSync(filePath, 'content');
try {
const result = resolveAndValidatePath(config, 'file.txt', {
allowFiles: true,
});
expect(result).toBe(filePath);
} finally {
fs.rmSync(filePath);
}
});
});

View File

@@ -4,9 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import * as crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { isNodeError } from './errors.js';
export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
@@ -191,3 +194,93 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
!pathModule.isAbsolute(relative)
);
}
/**
* Resolves a path with tilde (~) expansion and relative path resolution.
* Handles tilde expansion for home directory and resolves relative paths
* against the provided base directory or current working directory.
*
* @param baseDir The base directory to resolve relative paths against (defaults to current working directory)
* @param relativePath The path to resolve (can be relative, absolute, or tilde-prefixed)
* @returns The resolved absolute path
*/
export function resolvePath(
baseDir: string | undefined = process.cwd(),
relativePath: string,
): string {
const homeDir = os.homedir();
if (relativePath === '~') {
return homeDir;
} else if (relativePath.startsWith('~/')) {
return path.join(homeDir, relativePath.slice(2));
} else if (path.isAbsolute(relativePath)) {
return relativePath;
} else {
return path.resolve(baseDir, relativePath);
}
}
export interface PathValidationOptions {
/**
* If true, allows both files and directories. If false (default), only allows directories.
*/
allowFiles?: boolean;
}
/**
* Validates that a resolved path exists within the workspace boundaries.
*
* @param config The configuration object containing workspace context
* @param resolvedPath The absolute path to validate
* @param options Validation options
* @throws Error if the path is outside workspace boundaries, doesn't exist, or is not a directory (when allowFiles is false)
*/
export function validatePath(
config: Config,
resolvedPath: string,
options: PathValidationOptions = {},
): void {
const { allowFiles = false } = options;
const workspaceContext = config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) {
throw new Error('Path is not within workspace');
}
try {
const stats = fs.statSync(resolvedPath);
if (!allowFiles && !stats.isDirectory()) {
throw new Error(`Path is not a directory: ${resolvedPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
throw new Error(`Path does not exist: ${resolvedPath}`);
}
throw error;
}
}
/**
* Resolves a path relative to the workspace root and verifies that it exists
* within the workspace boundaries defined in the config.
*
* @param config The configuration object
* @param relativePath The relative path to resolve (optional, defaults to target directory)
* @param options Validation options (e.g., allowFiles to permit file paths)
*/
export function resolveAndValidatePath(
config: Config,
relativePath?: string,
options: PathValidationOptions = {},
): string {
const targetDir = config.getTargetDir();
if (!relativePath) {
return targetDir;
}
const resolvedPath = resolvePath(targetDir, relativePath);
validatePath(config, resolvedPath, options);
return resolvedPath;
}