import type { Context } from "@earendil-works/chord"; import { type FileError, getOrThrow } from "@earendil-works/pi-durable/env"; import ignore, { type Ignore } from "ignore"; import type { Environment } from "@berry/api/environment.ts"; function unavailable(error: FileError): boolean { return ["not_found", "not_directory", "permission_denied", "invalid"].includes(error.code); } function score(path: string, query: string): number { const text = path.toLowerCase(); if (query.length > text.length) return -Infinity; let previous = Array(text.length).fill(-Infinity); for (let q = 0; q < query.length; q++) { const current = Array(text.length).fill(-Infinity); let best = -Infinity; for (let i = 0; i < text.length; i++) { if (text[i] === query[q]) { const boundary = i === 0 || /[/._-]/u.test(text[i - 1]) || (path[i] !== text[i] && path[i - 1] === text[i - 1]); current[i] = (q === 0 ? -i * 0.1 : Math.max(best, (previous[i - 1] ?? -Infinity) + 8)) + 1 + (boundary ? 6 : 0); } best = Math.max(best, previous[i]) - 1; } previous = current; } const quality = Math.max(...previous); if (!Number.isFinite(quality)) return -Infinity; const name = text.replace(/\/$/u, "").split("/").at(-1)!; const rank = text === query || name === query ? 3 : name.startsWith(query) ? 2 : name.includes(query) ? 1 : 0; return rank * 1000 + quality - path.length * 0.01; } const WALK_WIDTH = 16; /** Whether `path` names a directory, through any links; `false` where nothing can be seen. */ async function isDirectory(env: Environment, path: string, context: Context): Promise { const canonical = await env.canonicalPath(path, context); const info = canonical.ok ? await env.fileInfo(canonical.value, context) : canonical; if (info.ok) return info.value.kind === "directory"; if (unavailable(info.error)) return false; throw info.error; } /** Paths extending a reference from the environment's working directory, with directories left open. */ export async function references( env: Environment, prefix: string, context: Context, ): Promise { if (/\s/u.test(prefix) || prefix.includes("\u0000")) return []; if (prefix === "~") return ["~/"]; let base = prefix.match(/^(?:~\/|\/|(?:\.\.?\/)+)/u)?.[0] ?? ""; let query = prefix.slice(base.length); let directory = getOrThrow(await env.absolutePath(base || ".", context)); while (query.includes("/")) { const part = query.slice(0, query.indexOf("/") + 1); const next = getOrThrow(await env.joinPath([directory, part], context)); if (!(await isDirectory(env, next, context))) break; base += part; query = query.slice(part.length); directory = next; } query = query.toLowerCase(); const matches: { path: string; score: number }[] = []; type Rule = { root: string; matcher: Ignore }; type Visit = { path: string; current: string; rules: Rule[] }; async function visit({ path, current, rules: inherited }: Visit): Promise { const entries = await env.listDir(current, context); if (!entries.ok) { if (unavailable(entries.error)) return []; throw entries.error; } let rules = inherited; const gitignore = entries.value.find((entry) => entry.name === ".gitignore"); if (gitignore) { const source = await env.readTextFile(gitignore.path, context); if (source.ok) rules = [...rules, { root: path, matcher: ignore().add(source.value) }]; else if (!unavailable(source.error)) throw source.error; } const next: Visit[] = []; for (const entry of entries.value) { if (/\s/u.test(entry.name) || [".git", ".jj", "node_modules"].includes(entry.name)) continue; const relative = path + entry.name; const directory = entry.kind === "directory" || (entry.kind === "symlink" && await isDirectory(env, entry.path, context)); const candidate = relative + (directory ? "/" : ""); let ignored = false; for (const rule of rules) { const result = rule.matcher.test(candidate.slice(rule.root.length)); if (result.ignored) ignored = true; else if (result.unignored) ignored = false; } if (ignored) continue; const quality = query === "" ? 0 : score(candidate, query); if (Number.isFinite(quality)) matches.push({ path: base + candidate, score: quality }); // explicit symlink directories can be browsed, but recursive search must not follow cycles if (query !== "" && entry.kind === "directory") { next.push({ path: candidate, current: entry.path, rules }); } } return next; } // several directories at once, as each can be a round trip to another machine const pending: Visit[] = [{ path: "", current: directory, rules: [] }]; const running = new Set>(); while (pending.length > 0 || running.size > 0) { while (pending.length > 0 && running.size < WALK_WIDTH) { const visiting: Promise = visit(pending.pop()!).then((next) => { running.delete(visiting); pending.push(...next); }); running.add(visiting); } await Promise.race(running); } return matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)) .slice(0, 50).map((match) => match.path); }