mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* 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';
|
|
|
|
/**
|
|
* Special characters that need to be escaped in file paths for shell compatibility.
|
|
* Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
|
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
|
*/
|
|
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
|
|
|
|
/**
|
|
* Replaces the home directory with a tilde.
|
|
* @param path - The path to tildeify.
|
|
* @returns The tildeified path.
|
|
*/
|
|
export function tildeifyPath(path: string): string {
|
|
const homeDir = os.homedir();
|
|
if (path.startsWith(homeDir)) {
|
|
return path.replace(homeDir, '~');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
|
|
* Shows root + first segment + "..." + end segments when middle segments are omitted.
|
|
* Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
|
|
*/
|
|
export function shortenPath(filePath: string, maxLen: number = 80): string {
|
|
if (filePath.length <= maxLen) {
|
|
return filePath;
|
|
}
|
|
|
|
const separator = path.sep;
|
|
const ellipsis = '...';
|
|
|
|
// Simple fallback for very short maxLen
|
|
if (maxLen < 10) {
|
|
return filePath.substring(0, maxLen - 3) + ellipsis;
|
|
}
|
|
|
|
const parsedPath = path.parse(filePath);
|
|
const root = parsedPath.root;
|
|
const relativePath = filePath.substring(root.length);
|
|
const segments = relativePath.split(separator).filter((s) => s !== '');
|
|
|
|
// Handle edge cases: no segments or single segment
|
|
if (segments.length === 0) {
|
|
return root.length <= maxLen
|
|
? root
|
|
: root.substring(0, maxLen - 3) + ellipsis;
|
|
}
|
|
|
|
if (segments.length === 1) {
|
|
const full = root + segments[0];
|
|
if (full.length <= maxLen) {
|
|
return full;
|
|
}
|
|
const keepLen = Math.floor((maxLen - 3) / 2);
|
|
const start = full.substring(0, keepLen);
|
|
const end = full.substring(full.length - keepLen);
|
|
return `${start}${ellipsis}${end}`;
|
|
}
|
|
|
|
// For 2+ segments: build from start and end, insert "..." if there's a gap
|
|
const startPart = root + segments[0]; // Always include root and first segment
|
|
|
|
// Collect segments from the end, working backwards
|
|
const endSegments: string[] = [];
|
|
|
|
for (let i = segments.length - 1; i >= 1; i--) {
|
|
const segment = segments[i];
|
|
|
|
// Calculate what the total would be if we add this segment
|
|
const endPart = [segment, ...endSegments].join(separator);
|
|
const needsEllipsis = i > 1; // If we're not at segment[1], there's a gap
|
|
|
|
let candidateResult: string;
|
|
if (needsEllipsis) {
|
|
candidateResult = startPart + separator + ellipsis + separator + endPart;
|
|
} else {
|
|
candidateResult = startPart + separator + endPart;
|
|
}
|
|
|
|
if (candidateResult.length <= maxLen) {
|
|
endSegments.unshift(segment);
|
|
|
|
// If we've reached segment[1], we have all segments - return immediately
|
|
if (i === 1) {
|
|
return candidateResult;
|
|
}
|
|
} else {
|
|
break; // Can't add more segments
|
|
}
|
|
}
|
|
|
|
// Build final result
|
|
if (endSegments.length === 0) {
|
|
// Couldn't fit any end segments - use simple truncation
|
|
const keepLen = Math.floor((maxLen - 3) / 2);
|
|
const start = filePath.substring(0, keepLen);
|
|
const end = filePath.substring(filePath.length - keepLen);
|
|
return `${start}${ellipsis}${end}`;
|
|
}
|
|
|
|
// We have some end segments but not all - there's a gap, insert ellipsis
|
|
return (
|
|
startPart + separator + ellipsis + separator + endSegments.join(separator)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Calculates the relative path from a root directory to a target path.
|
|
* Ensures both paths are resolved before calculating.
|
|
* Returns '.' if the target path is the same as the root directory.
|
|
*
|
|
* @param targetPath The absolute or relative path to make relative.
|
|
* @param rootDirectory The absolute path of the directory to make the target path relative to.
|
|
* @returns The relative path from rootDirectory to targetPath.
|
|
*/
|
|
export function makeRelative(
|
|
targetPath: string,
|
|
rootDirectory: string,
|
|
): string {
|
|
const resolvedTargetPath = path.resolve(targetPath);
|
|
const resolvedRootDirectory = path.resolve(rootDirectory);
|
|
|
|
if (!isSubpath(resolvedRootDirectory, resolvedTargetPath)) {
|
|
return resolvedTargetPath;
|
|
}
|
|
|
|
const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath);
|
|
|
|
// If the paths are the same, path.relative returns '', return '.' instead
|
|
return relativePath || '.';
|
|
}
|
|
|
|
/**
|
|
* Escapes special characters in a file path like macOS terminal does.
|
|
* Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
|
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
|
*/
|
|
export function escapePath(filePath: string): string {
|
|
let result = '';
|
|
for (let i = 0; i < filePath.length; i++) {
|
|
const char = filePath[i];
|
|
|
|
// Count consecutive backslashes before this character
|
|
let backslashCount = 0;
|
|
for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) {
|
|
backslashCount++;
|
|
}
|
|
|
|
// Character is already escaped if there's an odd number of backslashes before it
|
|
const isAlreadyEscaped = backslashCount % 2 === 1;
|
|
|
|
// Only escape if not already escaped
|
|
if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) {
|
|
result += '\\' + char;
|
|
} else {
|
|
result += char;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Unescapes special characters in a file path.
|
|
* Removes backslash escaping from shell metacharacters.
|
|
*/
|
|
export function unescapePath(filePath: string): string {
|
|
return filePath.replace(
|
|
new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'),
|
|
'$1',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generates a unique hash for a project based on its root path.
|
|
* On Windows, paths are case-insensitive, so we normalize to lowercase
|
|
* to ensure the same physical path always produces the same hash.
|
|
* @param projectRoot The absolute path to the project's root directory.
|
|
* @returns A SHA256 hash of the project root path.
|
|
*/
|
|
export function getProjectHash(projectRoot: string): string {
|
|
// On Windows, normalize path to lowercase for case-insensitive matching
|
|
const normalizedPath =
|
|
os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot;
|
|
return crypto.createHash('sha256').update(normalizedPath).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a directory path to create a safe project ID.
|
|
*
|
|
* - On Windows: normalizes to lowercase for case-insensitive matching
|
|
* - Replaces all non-alphanumeric characters with hyphens
|
|
*
|
|
* This is used for:
|
|
* - Creating project-specific directories
|
|
* - Generating session IDs for debug logging during startup
|
|
*
|
|
* @param cwd - The directory path to sanitize
|
|
* @returns A sanitized string safe for use as a project identifier
|
|
*/
|
|
export function sanitizeCwd(cwd: string): string {
|
|
// On Windows, normalize to lowercase for case-insensitive matching
|
|
const normalizedCwd = os.platform() === 'win32' ? cwd.toLowerCase() : cwd;
|
|
return normalizedCwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is a subpath of another path.
|
|
* @param parentPath The parent path.
|
|
* @param childPath The child path.
|
|
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
|
*/
|
|
export function isSubpath(parentPath: string, childPath: string): boolean {
|
|
const isWindows = os.platform() === 'win32';
|
|
const pathModule = isWindows ? path.win32 : path;
|
|
|
|
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
|
const relative = pathModule.relative(parentPath, childPath);
|
|
|
|
return (
|
|
!relative.startsWith(`..${pathModule.sep}`) &&
|
|
relative !== '..' &&
|
|
!pathModule.isAbsolute(relative)
|
|
);
|
|
}
|
|
|
|
export function isSubpaths(parentPath: string[], childPath: string): boolean {
|
|
return parentPath.some((p) => isSubpath(p, childPath));
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|