mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 17:27:54 +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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user