mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Feat: Simplify and Improve Search Tools (glob, grep, ripgrep) (#969)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user