214 lines
5.7 KiB
TypeScript
214 lines
5.7 KiB
TypeScript
import fs from "fs/promises";
|
|
import { createReadStream } from "fs";
|
|
import path from "path";
|
|
import readline from "readline";
|
|
|
|
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
|
|
|
export interface SearchMatch {
|
|
file: string;
|
|
line: number;
|
|
column: number;
|
|
text: string;
|
|
matchLength: number;
|
|
}
|
|
|
|
export interface SearchResult {
|
|
matches: SearchMatch[];
|
|
totalMatches: number;
|
|
filesSearched: number;
|
|
}
|
|
|
|
interface SearchOptions {
|
|
regex?: boolean;
|
|
caseSensitive?: boolean;
|
|
includePattern?: string;
|
|
excludePattern?: string;
|
|
maxResults?: number;
|
|
}
|
|
|
|
export interface FileNode {
|
|
name: string;
|
|
path: string;
|
|
type: "file" | "directory";
|
|
children?: FileNode[];
|
|
}
|
|
|
|
const IGNORED_DIRS = new Set(["node_modules", ".git", ".nasher"]);
|
|
|
|
export async function getDirectoryTree(
|
|
repo: string,
|
|
subPath: string = "",
|
|
): Promise<FileNode[]> {
|
|
const fullPath = resolveRepoPath(repo, subPath);
|
|
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
const nodes: FileNode[] = [];
|
|
|
|
const sorted = entries.sort((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
for (const entry of sorted) {
|
|
if (entry.name.startsWith(".") && entry.name !== ".gitignore") continue;
|
|
|
|
const relativePath = subPath ? `${subPath}/${entry.name}` : entry.name;
|
|
|
|
if (entry.isDirectory()) {
|
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
const children = await getDirectoryTree(repo, relativePath);
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relativePath,
|
|
type: "directory",
|
|
children,
|
|
});
|
|
} else {
|
|
nodes.push({ name: entry.name, path: relativePath, type: "file" });
|
|
}
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
export async function readFile(
|
|
repo: string,
|
|
filePath: string,
|
|
): Promise<string> {
|
|
const fullPath = resolveRepoPath(repo, filePath);
|
|
return fs.readFile(fullPath, "utf-8");
|
|
}
|
|
|
|
export async function writeFile(
|
|
repo: string,
|
|
filePath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const fullPath = resolveRepoPath(repo, filePath);
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(fullPath, content, "utf-8");
|
|
}
|
|
|
|
export async function deleteFile(
|
|
repo: string,
|
|
filePath: string,
|
|
): Promise<void> {
|
|
const fullPath = resolveRepoPath(repo, filePath);
|
|
await fs.unlink(fullPath);
|
|
}
|
|
|
|
const BINARY_EXTENSIONS = new Set([
|
|
".mod", ".hak", ".bic", ".tlk", ".png", ".jpg", ".tga",
|
|
".wav", ".bmu", ".ncs", ".gic",
|
|
]);
|
|
|
|
function matchesExtPattern(filePath: string, pattern: string): boolean {
|
|
if (pattern.startsWith("*.")) {
|
|
return filePath.endsWith(pattern.slice(1));
|
|
}
|
|
return filePath.endsWith(pattern);
|
|
}
|
|
|
|
async function walkFiles(dir: string, relativeTo: string): Promise<string[]> {
|
|
const results: string[] = [];
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
results.push(...(await walkFiles(fullPath, relativeTo)));
|
|
} else {
|
|
const ext = path.extname(entry.name).toLowerCase();
|
|
if (!BINARY_EXTENSIONS.has(ext)) {
|
|
results.push(path.relative(relativeTo, fullPath));
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function searchFiles(
|
|
repo: string,
|
|
query: string,
|
|
options: SearchOptions = {},
|
|
): Promise<SearchResult> {
|
|
const maxResults = options.maxResults ?? 1000;
|
|
const repoRoot = resolveRepoPath(repo, "");
|
|
const files = await walkFiles(repoRoot, repoRoot);
|
|
|
|
const matches: SearchMatch[] = [];
|
|
let filesSearched = 0;
|
|
|
|
let pattern: RegExp | null = null;
|
|
if (options.regex) {
|
|
pattern = new RegExp(query, options.caseSensitive ? "g" : "gi");
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (matches.length >= maxResults) break;
|
|
|
|
if (options.includePattern && !matchesExtPattern(file, options.includePattern)) continue;
|
|
if (options.excludePattern && matchesExtPattern(file, options.excludePattern)) continue;
|
|
|
|
const fullPath = path.join(repoRoot, file);
|
|
filesSearched++;
|
|
|
|
const rl = readline.createInterface({
|
|
input: createReadStream(fullPath, { encoding: "utf-8" }),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let lineNum = 0;
|
|
for await (const line of rl) {
|
|
lineNum++;
|
|
if (matches.length >= maxResults) {
|
|
rl.close();
|
|
break;
|
|
}
|
|
|
|
if (pattern) {
|
|
pattern.lastIndex = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = pattern.exec(line)) !== null) {
|
|
matches.push({
|
|
file,
|
|
line: lineNum,
|
|
column: m.index,
|
|
text: line,
|
|
matchLength: m[0].length,
|
|
});
|
|
if (matches.length >= maxResults) break;
|
|
}
|
|
} else {
|
|
const haystack = options.caseSensitive ? line : line.toLowerCase();
|
|
const needle = options.caseSensitive ? query : query.toLowerCase();
|
|
let startIdx = 0;
|
|
let idx: number;
|
|
while ((idx = haystack.indexOf(needle, startIdx)) !== -1) {
|
|
matches.push({
|
|
file,
|
|
line: lineNum,
|
|
column: idx,
|
|
text: line,
|
|
matchLength: needle.length,
|
|
});
|
|
startIdx = idx + 1;
|
|
if (matches.length >= maxResults) break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { matches, totalMatches: matches.length, filesSearched };
|
|
}
|
|
|
|
function resolveRepoPath(repo: string, filePath: string): string {
|
|
const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo));
|
|
const resolved = path.resolve(repoRoot, filePath);
|
|
if (!resolved.startsWith(repoRoot)) {
|
|
throw new Error("Path traversal detected");
|
|
}
|
|
return resolved;
|
|
}
|