Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34758e1ac7 | |||
| 5d69126e51 |
Generated
-210
@@ -1303,174 +1303,6 @@
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oven/bun-darwin-aarch64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
|
||||
"integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-darwin-x64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
|
||||
"integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-darwin-x64-baseline": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
|
||||
"integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-aarch64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
|
||||
"integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-aarch64-musl": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
|
||||
"integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
|
||||
"integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64-baseline": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
|
||||
"integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64-musl": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
|
||||
"integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64-musl-baseline": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
|
||||
"integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-windows-aarch64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
|
||||
"integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-windows-x64": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
|
||||
"integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@oven/bun-windows-x64-baseline": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
|
||||
"integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -2641,41 +2473,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/bun": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
|
||||
"integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
|
||||
"cpu": [
|
||||
"arm64",
|
||||
"x64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"bun": "bin/bun.exe",
|
||||
"bunx": "bin/bunx.exe"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oven/bun-darwin-aarch64": "1.3.10",
|
||||
"@oven/bun-darwin-x64": "1.3.10",
|
||||
"@oven/bun-darwin-x64-baseline": "1.3.10",
|
||||
"@oven/bun-linux-aarch64": "1.3.10",
|
||||
"@oven/bun-linux-aarch64-musl": "1.3.10",
|
||||
"@oven/bun-linux-x64": "1.3.10",
|
||||
"@oven/bun-linux-x64-baseline": "1.3.10",
|
||||
"@oven/bun-linux-x64-musl": "1.3.10",
|
||||
"@oven/bun-linux-x64-musl-baseline": "1.3.10",
|
||||
"@oven/bun-windows-aarch64": "1.3.10",
|
||||
"@oven/bun-windows-x64": "1.3.10",
|
||||
"@oven/bun-windows-x64-baseline": "1.3.10"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz",
|
||||
@@ -4307,10 +4104,6 @@
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"fff-demo": "examples/search.ts",
|
||||
"fff-grep": "examples/grep.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0"
|
||||
@@ -4327,9 +4120,6 @@
|
||||
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
"packages/fff-bun/node_modules/@ff-labs/fff-bin-darwin-arm64": {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { FileFinderApi } from "@ff-labs/fff-node";
|
||||
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
||||
|
||||
export const MAX_AUX = 3;
|
||||
export const IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface AuxPicker {
|
||||
root: string;
|
||||
finder: FileFinderApi;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
export interface AuxOpts {
|
||||
frecencyDbPath?: string;
|
||||
historyDbPath?: string;
|
||||
enableFsRootScanning: boolean;
|
||||
}
|
||||
|
||||
export class AuxFinderPool {
|
||||
private entries: AuxPicker[] = [];
|
||||
constructor(private opts: AuxOpts) {}
|
||||
|
||||
destroy(): void {
|
||||
for (const e of this.entries) {
|
||||
e.finder.destroy();
|
||||
}
|
||||
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
private sweepIdle(now = Date.now()): void {
|
||||
const kept: AuxPicker[] = [];
|
||||
for (const e of this.entries) {
|
||||
if (now - e.lastUsed > IDLE_TTL_MS) {
|
||||
if (!e.finder.isDestroyed) e.finder.destroy();
|
||||
} else {
|
||||
kept.push(e);
|
||||
}
|
||||
}
|
||||
this.entries = kept;
|
||||
}
|
||||
|
||||
async acquire(
|
||||
maybeRoot: string,
|
||||
opts?: { exact?: boolean },
|
||||
): Promise<{ finder: FileFinderApi; root: string }> {
|
||||
this.sweepIdle();
|
||||
let covering: AuxPicker | null = null;
|
||||
for (const e of this.entries) {
|
||||
if (e.finder.isDestroyed) continue;
|
||||
if (opts?.exact ? e.root !== maybeRoot : !rootCovers(e.root, maybeRoot)) continue;
|
||||
if (!covering || e.root.length > covering.root.length) covering = e;
|
||||
}
|
||||
|
||||
if (covering) {
|
||||
covering.lastUsed = Date.now();
|
||||
return { finder: covering.finder, root: covering.root };
|
||||
}
|
||||
|
||||
if (this.entries.length >= MAX_AUX) {
|
||||
let oldest = this.entries[0];
|
||||
for (const e of this.entries)
|
||||
if (e.lastUsed < oldest.lastUsed) oldest = e;
|
||||
if (!oldest.finder.isDestroyed) oldest.finder.destroy();
|
||||
this.entries = this.entries.filter((e) => e !== oldest);
|
||||
}
|
||||
|
||||
const { FileFinder } = await loadSdk();
|
||||
const result = FileFinder.create({
|
||||
basePath: maybeRoot,
|
||||
frecencyDbPath: this.opts.frecencyDbPath,
|
||||
historyDbPath: this.opts.historyDbPath,
|
||||
aiMode: true,
|
||||
enableHomeDirScanning: true,
|
||||
enableFsRootScanning: this.opts.enableFsRootScanning,
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(
|
||||
`Failed to create aux file finder for ${maybeRoot}: ${result.error}`,
|
||||
);
|
||||
|
||||
await result.value.waitForScan(SCAN_TIMEOUT_MS);
|
||||
this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() });
|
||||
return { finder: result.value, root: maybeRoot };
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this.sweepIdle();
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Split an absolute path into an existing directory (the aux root) and a
|
||||
// remainder usable as a fuzzy path constraint relative to that root. Glob and
|
||||
// nonexistent segments both go into the suffix: we walk up to the nearest
|
||||
// existing ancestor so partially-wrong paths still resolve to a search root.
|
||||
export function resolveAuxRoot(
|
||||
absPath: string,
|
||||
): { root: string; suffix: string } | null {
|
||||
const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
|
||||
if (!path.isAbsolute(trimmed)) return null;
|
||||
if (trimmed === path.sep) return { root: path.sep, suffix: "" };
|
||||
|
||||
const parts = trimmed.split(path.sep);
|
||||
const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p));
|
||||
const boundary = firstGlob === -1 ? parts.length : firstGlob;
|
||||
|
||||
// Deepest existing non-glob prefix wins; everything below it is suffix.
|
||||
for (let i = boundary; i > 0; i--) {
|
||||
const candidate = parts.slice(0, i).join(path.sep) || path.sep;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
return {
|
||||
root: parts.slice(0, i - 1).join(path.sep) || path.sep,
|
||||
suffix: parts.slice(i - 1).join("/"),
|
||||
};
|
||||
}
|
||||
return { root: candidate, suffix: parts.slice(i).join("/") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decide whether a `path` parameter should route to the workspace finder or
|
||||
// to an aux finder. Accepts absolute paths, `~`-prefixed paths, and relative
|
||||
// paths escaping the workspace (`../other-project`); everything is resolved
|
||||
// against cwd first. Returns null to signal "no rerouting" (workspace finder).
|
||||
export function routePathConstraint(
|
||||
pathConstraint: string | undefined,
|
||||
cwd: string,
|
||||
): { root: string; suffix: string } | null {
|
||||
if (!pathConstraint) return null;
|
||||
let candidate = pathConstraint.trim();
|
||||
if (!candidate) return null;
|
||||
if (candidate === "~" || candidate.startsWith("~/"))
|
||||
candidate = path.join(os.homedir(), candidate.slice(1));
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
// Plain workspace-relative constraints stay on the workspace finder.
|
||||
if (candidate !== ".." && !candidate.startsWith("../")) return null;
|
||||
candidate = path.resolve(cwd, candidate);
|
||||
}
|
||||
const rel = path.relative(cwd, candidate);
|
||||
if (rel !== ".." && !rel.startsWith(`..${path.sep}`)) return null;
|
||||
return resolveAuxRoot(candidate);
|
||||
}
|
||||
|
||||
|
||||
export function rootCovers(root: string, target: string): boolean {
|
||||
if (root === target) return true;
|
||||
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
return target.startsWith(prefix);
|
||||
}
|
||||
+168
-98
@@ -5,6 +5,7 @@
|
||||
* @-mention autocomplete suggestions to the interactive editor.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
type AutocompleteItem,
|
||||
@@ -16,44 +17,15 @@ import type {
|
||||
GrepCursor,
|
||||
GrepMode,
|
||||
GrepResult,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
Result,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { AuxFinderPool, routePathConstraint } from "./aux-finders";
|
||||
import { buildQuery } from "./query";
|
||||
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
||||
|
||||
// Isomorphic SDK loading. pi hosts run under either bun (omp / oh-my-pi) or
|
||||
// node; the two SDKs implement the same FileFinderApi. Static imports of
|
||||
// @ff-labs/fff-node pull ffi-rs + optional native binaries into the module
|
||||
// graph, which trips oh-my-pi's static extension validator (issue #668). We
|
||||
// defeat static resolution with a variable-name dynamic import.
|
||||
type FileFinderStatic = {
|
||||
create(options: InitOptions): Result<FileFinderApi>;
|
||||
};
|
||||
|
||||
let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
|
||||
|
||||
function detectRuntime(): "bun" | "node" {
|
||||
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
(process as { versions?: { bun?: string } }).versions?.bun
|
||||
)
|
||||
return "bun";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
|
||||
if (sdkPromise) return sdkPromise;
|
||||
// Fail loud on wrong-runtime SDK rather than falling back — a fallback would
|
||||
// re-introduce the ffi-rs cost on bun (the whole point of the bun SDK is to
|
||||
// avoid it) and mask packaging bugs where the correct SDK wasn't installed.
|
||||
const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
|
||||
sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
|
||||
return sdkPromise;
|
||||
}
|
||||
export { SCAN_TIMEOUT_MS } from "./sdk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -118,6 +90,7 @@ interface FindCursor {
|
||||
pattern: string;
|
||||
pageSize: number;
|
||||
nextPageIndex: number;
|
||||
auxRoot?: string;
|
||||
}
|
||||
|
||||
const findCursorCache = new Map<string, FindCursor>();
|
||||
@@ -288,7 +261,9 @@ function createFffMentionProvider(
|
||||
|
||||
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
||||
const items = await getItems(query, options.signal);
|
||||
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
||||
return options.signal.aborted || items.length === 0
|
||||
? null
|
||||
: { items, prefix };
|
||||
},
|
||||
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
||||
const currentLine = _lines[cursorLine] || "";
|
||||
@@ -297,7 +272,11 @@ function createFffMentionProvider(
|
||||
const newLine = before + item.value + after;
|
||||
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
||||
return {
|
||||
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
||||
lines: [
|
||||
..._lines.slice(0, cursorLine),
|
||||
newLine,
|
||||
..._lines.slice(cursorLine + 1),
|
||||
],
|
||||
cursorLine,
|
||||
cursorCol: newCursorCol,
|
||||
};
|
||||
@@ -310,7 +289,7 @@ function createFffMentionProvider(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function fffExtension(pi: ExtensionAPI) {
|
||||
let finder: FileFinderApi | null = null;
|
||||
let mainFinder: FileFinderApi | null = null;
|
||||
let finderCwd: string | null = null;
|
||||
// Concurrent ensureFinder() callers share the same in-flight promise so
|
||||
// FileFinder.create() (which takes native DB locks) runs at most once per
|
||||
@@ -364,15 +343,23 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
return currentMode !== "tools-only";
|
||||
}
|
||||
|
||||
let auxPool = new AuxFinderPool({
|
||||
frecencyDbPath,
|
||||
historyDbPath,
|
||||
enableFsRootScanning,
|
||||
});
|
||||
|
||||
// in case cwd changes we need to figure this out
|
||||
function ensureFinder(cwd: string): Promise<FileFinderApi> {
|
||||
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
||||
return Promise.resolve(finder);
|
||||
if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd)
|
||||
return Promise.resolve(mainFinder);
|
||||
|
||||
if (finderPromise) return finderPromise;
|
||||
|
||||
finderPromise = (async () => {
|
||||
if (finder && !finder.isDestroyed) {
|
||||
finder.destroy();
|
||||
finder = null;
|
||||
if (mainFinder && !mainFinder.isDestroyed) {
|
||||
mainFinder.destroy();
|
||||
mainFinder = null;
|
||||
finderCwd = null;
|
||||
}
|
||||
|
||||
@@ -389,10 +376,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
if (!result.ok)
|
||||
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
||||
|
||||
finder = result.value;
|
||||
mainFinder = result.value;
|
||||
finderCwd = cwd;
|
||||
await finder.waitForScan(15000);
|
||||
return finder;
|
||||
await mainFinder.waitForScan(SCAN_TIMEOUT_MS);
|
||||
return mainFinder;
|
||||
})().finally(() => {
|
||||
finderPromise = null;
|
||||
});
|
||||
@@ -401,11 +388,33 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
function destroyFinder() {
|
||||
if (finder && !finder.isDestroyed) {
|
||||
finder.destroy();
|
||||
finder = null;
|
||||
if (mainFinder && !mainFinder.isDestroyed) {
|
||||
mainFinder.destroy();
|
||||
mainFinder = null;
|
||||
finderCwd = null;
|
||||
}
|
||||
|
||||
if (auxPool) {
|
||||
auxPool.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFinderForPath(
|
||||
pathParam: string | undefined,
|
||||
pattern: string,
|
||||
exclude: string | string[] | undefined,
|
||||
): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
|
||||
const route = routePathConstraint(pathParam, activeCwd);
|
||||
if (!route) return null;
|
||||
const aux = await auxPool.acquire(route.root);
|
||||
// A broader covering picker may have been reused; rebase the suffix so the
|
||||
// constraint stays relative to the picker's actual root.
|
||||
const rebase = nodePath
|
||||
.relative(aux.root, route.root)
|
||||
.replaceAll(nodePath.sep, "/");
|
||||
const suffix = [rebase, route.suffix].filter(Boolean).join("/");
|
||||
const query = buildQuery(suffix || undefined, pattern, exclude, aux.root);
|
||||
return { finder: aux.finder, query, root: aux.root };
|
||||
}
|
||||
|
||||
async function getMentionItems(
|
||||
@@ -419,20 +428,22 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
||||
if (!result.ok) return [];
|
||||
|
||||
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
||||
if (mixed.type === "directory") {
|
||||
return result.value.items
|
||||
.slice(0, MENTION_MAX_RESULTS)
|
||||
.map((mixed: MixedItem) => {
|
||||
if (mixed.type === "directory") {
|
||||
return {
|
||||
value: buildAtCompletionValue(mixed.item.relativePath),
|
||||
label: mixed.item.dirName,
|
||||
description: mixed.item.relativePath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: buildAtCompletionValue(mixed.item.relativePath),
|
||||
label: mixed.item.dirName,
|
||||
label: mixed.item.fileName,
|
||||
description: mixed.item.relativePath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: buildAtCompletionValue(mixed.item.relativePath),
|
||||
label: mixed.item.fileName,
|
||||
description: mixed.item.relativePath,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerAutocompleteProvider(ctx: {
|
||||
@@ -468,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
||||
},
|
||||
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
||||
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
||||
return current.applyCompletion(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
item,
|
||||
prefix,
|
||||
);
|
||||
},
|
||||
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
||||
return (
|
||||
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
|
||||
current.shouldTriggerFileCompletion?.(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
) ?? true
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -487,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
pi.registerFlag("fff-frecency-db", {
|
||||
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
||||
description:
|
||||
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
pi.registerFlag("fff-history-db", {
|
||||
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
||||
description:
|
||||
"Path to the query history database (overrides FFF_HISTORY_DB env)",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
@@ -552,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
context: any,
|
||||
maxLines = 15,
|
||||
) => {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
||||
const text =
|
||||
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const output =
|
||||
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
||||
if (!output) {
|
||||
text.setText(theme.fg("muted", "No output"));
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines = output.split("\n");
|
||||
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
||||
const displayLines = lines.slice(
|
||||
0,
|
||||
options.expanded ? lines.length : maxLines,
|
||||
);
|
||||
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
||||
if (lines.length > displayLines.length) {
|
||||
content += theme.fg(
|
||||
@@ -581,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
||||
"Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
|
||||
}),
|
||||
),
|
||||
exclude: Type.Optional(
|
||||
@@ -625,18 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
async execute(_toolCallId, params, signal) {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const f = await ensureFinder(activeCwd);
|
||||
const pattern = params.pattern;
|
||||
const aux = await resolveFinderForPath(
|
||||
params.path,
|
||||
pattern,
|
||||
params.exclude,
|
||||
);
|
||||
|
||||
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
||||
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
||||
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
||||
const query = aux
|
||||
? aux.query
|
||||
: buildQuery(params.path, pattern, params.exclude, activeCwd);
|
||||
|
||||
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
||||
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
||||
// only kicks in for plain mode — regex queries are intentional.
|
||||
const hasRegexSyntax =
|
||||
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
||||
if (mode === "regex") {
|
||||
try {
|
||||
new RegExp(params.pattern);
|
||||
new RegExp(pattern);
|
||||
} catch {
|
||||
mode = "plain";
|
||||
}
|
||||
@@ -645,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
||||
// to try to read a whole file. That's not what grep is for — return a terse error
|
||||
// steering them to a real pattern, preventing dozens of wasted retries.
|
||||
const p = params.pattern.trim();
|
||||
const p = pattern.trim();
|
||||
const isWildcardOnly =
|
||||
hasRegexSyntax &&
|
||||
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
||||
@@ -668,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
// (case-insensitive when pattern is all lowercase).
|
||||
const smartCase = params.caseSensitive !== true;
|
||||
|
||||
const grepResult = f.grep(query, {
|
||||
const grepResult = picker.grep(query, {
|
||||
mode,
|
||||
smartCase,
|
||||
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
||||
@@ -685,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
|
||||
// automatic fuzzy fallback allows to broad the queries and find different cases
|
||||
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
||||
const fuzzy = f.grep(params.pattern, {
|
||||
const fuzzy = picker.grep(pattern, {
|
||||
mode: "fuzzy",
|
||||
smartCase,
|
||||
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
||||
@@ -704,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
let output = formatGrepOutput(result);
|
||||
const notices: string[] = [];
|
||||
if (result.regexFallbackError) {
|
||||
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
||||
notices.push(
|
||||
`Invalid regex: ${result.regexFallbackError}, used literal match`,
|
||||
);
|
||||
}
|
||||
if (result.nextCursor) {
|
||||
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
|
||||
notices.push(
|
||||
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
||||
@@ -723,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const text =
|
||||
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const pattern = args?.pattern ?? "";
|
||||
const path = args?.path ?? ".";
|
||||
let content =
|
||||
@@ -753,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
||||
"Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
|
||||
}),
|
||||
),
|
||||
exclude: Type.Optional(
|
||||
@@ -790,21 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
async execute(_toolCallId, params, signal) {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const f = await ensureFinder(activeCwd);
|
||||
|
||||
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
||||
// the agent can't accidentally mix patterns across pages.
|
||||
// if resumed we use the same picker as before
|
||||
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
||||
const aux = resumed
|
||||
? resumed.auxRoot
|
||||
? {
|
||||
finder: (await auxPool.acquire(resumed.auxRoot, { exact: true }))
|
||||
.finder,
|
||||
root: resumed.auxRoot,
|
||||
}
|
||||
: null
|
||||
: await resolveFinderForPath(
|
||||
params.path,
|
||||
params.pattern,
|
||||
params.exclude,
|
||||
);
|
||||
|
||||
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
||||
const effectiveLimit = resumed
|
||||
? resumed.pageSize
|
||||
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
||||
|
||||
const query = resumed
|
||||
? resumed.query
|
||||
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
||||
: aux && "query" in aux
|
||||
? (aux as { query: string }).query
|
||||
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
||||
|
||||
const pattern = resumed ? resumed.pattern : params.pattern;
|
||||
const pageIndex = resumed?.nextPageIndex ?? 0;
|
||||
const auxRoot = resumed?.auxRoot ?? aux?.root;
|
||||
|
||||
const searchResult = f.fileSearch(query, {
|
||||
const searchResult = picker.fileSearch(query, {
|
||||
pageIndex,
|
||||
pageSize: effectiveLimit,
|
||||
});
|
||||
@@ -819,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
// shown so far there's another page to fetch.
|
||||
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
||||
const hasMore =
|
||||
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
|
||||
result.items.length >= effectiveLimit &&
|
||||
result.totalMatched > shownSoFar;
|
||||
|
||||
const notices: string[] = [];
|
||||
if (formatted.weak && formatted.shownCount > 0)
|
||||
@@ -834,6 +896,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
pattern,
|
||||
pageSize: effectiveLimit,
|
||||
nextPageIndex: pageIndex + 1,
|
||||
auxRoot,
|
||||
});
|
||||
notices.push(
|
||||
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
||||
@@ -853,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const text =
|
||||
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const pattern = args?.pattern ?? "";
|
||||
const path = args?.path ?? ".";
|
||||
let content =
|
||||
@@ -886,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
constraints: Type.Optional(
|
||||
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
||||
),
|
||||
context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
|
||||
context: Type.Optional(
|
||||
Type.Number({ description: "Context lines before+after" }),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({
|
||||
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
||||
@@ -952,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
|
||||
renderCall(args, theme, context) {
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const text =
|
||||
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
const patterns = args?.patterns ?? [];
|
||||
const constraints = args?.constraints;
|
||||
let content =
|
||||
@@ -974,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
// --- commands ---
|
||||
|
||||
pi.registerCommand("fff-mode", {
|
||||
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
||||
description:
|
||||
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
||||
handler: async (args, ctx) => {
|
||||
const arg = (args || "").trim();
|
||||
|
||||
@@ -988,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
|
||||
// Validate and set mode
|
||||
if (!VALID_MODES.includes(arg as FffMode)) {
|
||||
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
||||
ctx.ui.notify(
|
||||
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1009,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
pi.registerCommand("fff-health", {
|
||||
description: "Show FFF file finder health and status",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!finder || finder.isDestroyed) {
|
||||
if (!mainFinder || mainFinder.isDestroyed) {
|
||||
ctx.ui.notify("FFF not initialized", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const health = finder.healthCheck();
|
||||
const health = mainFinder.healthCheck();
|
||||
if (!health.ok) {
|
||||
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const h = health.value;
|
||||
const lines = [
|
||||
`FFF v${h.version}`,
|
||||
`FFF v${health.value.version}`,
|
||||
`Mode: ${getMode()}`,
|
||||
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
||||
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
||||
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
||||
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
||||
`Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`,
|
||||
`Picker: ${health.value.filePicker.initialized ? `${health.value.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
||||
`Frecency: ${health.value.frecency.initialized ? "active" : "disabled"}`,
|
||||
`Query tracker: ${health.value.queryTracker.initialized ? "active" : "disabled"}`,
|
||||
];
|
||||
|
||||
const progress = finder.getScanProgress();
|
||||
const progress = mainFinder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
lines.push(
|
||||
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
||||
@@ -1044,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) {
|
||||
pi.registerCommand("fff-rescan", {
|
||||
description: "Trigger FFF to rescan files",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!finder || finder.isDestroyed) {
|
||||
if (!mainFinder || mainFinder.isDestroyed) {
|
||||
ctx.ui.notify("FFF not initialized", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = finder.scanFiles();
|
||||
const result = mainFinder.scanFiles();
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,11 @@ export function normalizePathConstraint(
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
||||
if (relative === "") return null;
|
||||
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
||||
if (
|
||||
relative.startsWith("../") ||
|
||||
relative === ".." ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new Error(
|
||||
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
||||
);
|
||||
@@ -22,6 +26,9 @@ export function normalizePathConstraint(
|
||||
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
||||
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
||||
|
||||
// wif we left with the ** it means anything so treat it as a cwd path
|
||||
if (trimmed === "**" || trimmed === "**/" || trimmed === "**/*") return null;
|
||||
|
||||
// FFF's glob matcher can treat a hidden directory root glob such as
|
||||
// `.agents/**` as empty, while the tool contract says this means "inside
|
||||
// this directory". Collapse simple trailing recursive directory globs to the
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node";
|
||||
|
||||
export const SCAN_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** pi can be run either under node or sdk, we resolve correct SDK version at runtime */
|
||||
export type FileFinderStatic = {
|
||||
create(options: InitOptions): Result<FileFinderApi>;
|
||||
};
|
||||
|
||||
let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
|
||||
|
||||
function detectRuntime(): "bun" | "node" {
|
||||
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
(process as { versions?: { bun?: string } }).versions?.bun
|
||||
)
|
||||
return "bun";
|
||||
return "node";
|
||||
}
|
||||
|
||||
export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
|
||||
if (sdkPromise) return sdkPromise;
|
||||
|
||||
// default to node as it seems like default option
|
||||
const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
|
||||
sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
|
||||
return sdkPromise;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveAuxRoot,
|
||||
rootCovers,
|
||||
routePathConstraint,
|
||||
} from "../src/aux-finders";
|
||||
|
||||
describe("routePathConstraint", () => {
|
||||
const cwd = "/tmp/workspace";
|
||||
|
||||
test("returns null for workspace-relative paths", () => {
|
||||
expect(routePathConstraint("src/", cwd)).toBeNull();
|
||||
expect(routePathConstraint("**/*.ts", cwd)).toBeNull();
|
||||
expect(routePathConstraint(undefined, cwd)).toBeNull();
|
||||
// Dot-prefixed names are not parent escapes.
|
||||
expect(routePathConstraint("..foo/bar", cwd)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for absolute paths inside the workspace", () => {
|
||||
expect(routePathConstraint("/tmp/workspace/src", cwd)).toBeNull();
|
||||
});
|
||||
|
||||
test("routes absolute paths outside the workspace", () => {
|
||||
const route = routePathConstraint("/tmp", cwd);
|
||||
expect(route).toEqual({ root: "/tmp", suffix: "" });
|
||||
});
|
||||
|
||||
test("splits glob suffix from existing dir prefix", () => {
|
||||
const route = routePathConstraint("/tmp/**/*.ts", cwd);
|
||||
expect(route).toEqual({ root: "/tmp", suffix: "**/*.ts" });
|
||||
});
|
||||
|
||||
test("walks up to existing ancestor when tail does not exist", () => {
|
||||
const route = routePathConstraint("/tmp/__nonexistent_dir_xyz__", cwd);
|
||||
expect(route).toEqual({ root: "/tmp", suffix: "__nonexistent_dir_xyz__" });
|
||||
});
|
||||
|
||||
test("expands ~ to the home directory", () => {
|
||||
expect(routePathConstraint("~", cwd)).toEqual({
|
||||
root: os.homedir(),
|
||||
suffix: "",
|
||||
});
|
||||
expect(routePathConstraint("~/__fff_nope__", cwd)).toEqual({
|
||||
root: os.homedir(),
|
||||
suffix: "__fff_nope__",
|
||||
});
|
||||
});
|
||||
|
||||
describe("relative paths escaping the workspace", () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "fff-route-"));
|
||||
const workspace = path.join(base, "workspace");
|
||||
const sibling = path.join(base, "fff-demo");
|
||||
fs.mkdirSync(workspace);
|
||||
fs.mkdirSync(sibling);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("routes ../sibling to the sibling directory", () => {
|
||||
expect(routePathConstraint("../fff-demo/", workspace)).toEqual({
|
||||
root: sibling,
|
||||
suffix: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("routes .. to the parent directory", () => {
|
||||
expect(routePathConstraint("..", workspace)).toEqual({
|
||||
root: base,
|
||||
suffix: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps globs in the suffix", () => {
|
||||
expect(routePathConstraint("../fff-demo/**/*.ts", workspace)).toEqual({
|
||||
root: sibling,
|
||||
suffix: "**/*.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("walks up when the sibling does not exist", () => {
|
||||
expect(routePathConstraint("../missing-xyz/src", workspace)).toEqual({
|
||||
root: base,
|
||||
suffix: "missing-xyz/src",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when .. resolves back inside the workspace", () => {
|
||||
expect(
|
||||
routePathConstraint("../workspace/src", workspace),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rootCovers", () => {
|
||||
test("covers itself and descendants only", () => {
|
||||
expect(rootCovers("/a/b", "/a/b")).toBe(true);
|
||||
expect(rootCovers("/a/b", "/a/b/c")).toBe(true);
|
||||
expect(rootCovers("/", "/a")).toBe(true);
|
||||
expect(rootCovers("/a/b", "/a")).toBe(false);
|
||||
expect(rootCovers("/a/b", "/a/bc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAuxRoot", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fff-aux-"));
|
||||
const tmpFile = path.join(tmpDir, "real-file.ts");
|
||||
fs.writeFileSync(tmpFile, "");
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns null for relative input", () => {
|
||||
expect(resolveAuxRoot("src/")).toBeNull();
|
||||
});
|
||||
|
||||
test("existing directory becomes the root with empty suffix", () => {
|
||||
expect(resolveAuxRoot(tmpDir)).toEqual({ root: tmpDir, suffix: "" });
|
||||
});
|
||||
|
||||
test("existing file roots at its parent with basename suffix", () => {
|
||||
expect(resolveAuxRoot(tmpFile)).toEqual({
|
||||
root: tmpDir,
|
||||
suffix: "real-file.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("nonexistent tail becomes a fuzzy suffix", () => {
|
||||
expect(resolveAuxRoot(path.join(tmpDir, "nope", "foo.ts"))).toEqual({
|
||||
root: tmpDir,
|
||||
suffix: "nope/foo.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("glob after nonexistent segment stays in the suffix", () => {
|
||||
expect(resolveAuxRoot(path.join(tmpDir, "nope", "**", "*.ts"))).toEqual({
|
||||
root: tmpDir,
|
||||
suffix: "nope/**/*.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("fully bogus path walks up to filesystem root", () => {
|
||||
expect(resolveAuxRoot("/__fff_nope__/x/y.ts")).toEqual({
|
||||
root: "/",
|
||||
suffix: "__fff_nope__/x/y.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("bare filesystem root resolves to itself", () => {
|
||||
expect(resolveAuxRoot("/")).toEqual({ root: "/", suffix: "" });
|
||||
});
|
||||
|
||||
test("trailing slashes are ignored", () => {
|
||||
expect(resolveAuxRoot(`${tmpDir}/`)).toEqual({ root: tmpDir, suffix: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
interface MockFinder {
|
||||
isDestroyed: boolean;
|
||||
basePath: string;
|
||||
waitForScan: (ms: number) => Promise<void>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const created: MockFinder[] = [];
|
||||
|
||||
function createMockFinder(basePath: string): MockFinder {
|
||||
const finder: MockFinder = {
|
||||
isDestroyed: false,
|
||||
basePath,
|
||||
waitForScan: async () => {},
|
||||
destroy: () => {
|
||||
finder.isDestroyed = true;
|
||||
},
|
||||
};
|
||||
created.push(finder);
|
||||
return finder;
|
||||
}
|
||||
|
||||
const finderModule = {
|
||||
FileFinder: {
|
||||
create: (options: { basePath: string }) => ({
|
||||
ok: true,
|
||||
value: createMockFinder(options.basePath),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("@ff-labs/fff-node", () => finderModule);
|
||||
mock.module("@ff-labs/fff-bun", () => finderModule);
|
||||
|
||||
const { AuxFinderPool } = await import("../src/aux-finders");
|
||||
|
||||
function makePool() {
|
||||
created.length = 0;
|
||||
return new AuxFinderPool({ enableFsRootScanning: false });
|
||||
}
|
||||
|
||||
describe("AuxFinderPool covering reuse", () => {
|
||||
test("reuses a picker rooted at an ancestor of the requested path", async () => {
|
||||
const pool = makePool();
|
||||
const a = await pool.acquire("/a/b/c");
|
||||
expect(a.root).toBe("/a/b/c");
|
||||
|
||||
const b = await pool.acquire("/a/b/c/d");
|
||||
expect(b.finder).toBe(a.finder);
|
||||
expect(b.root).toBe("/a/b/c");
|
||||
expect(created.length).toBe(1);
|
||||
});
|
||||
|
||||
test("does not reuse a picker rooted deeper than the requested path", async () => {
|
||||
const pool = makePool();
|
||||
await pool.acquire("/a/b/c");
|
||||
const broad = await pool.acquire("/a/b");
|
||||
expect(broad.root).toBe("/a/b");
|
||||
expect(created.length).toBe(2);
|
||||
});
|
||||
|
||||
test("prefers the deepest covering picker", async () => {
|
||||
const pool = makePool();
|
||||
const narrow = await pool.acquire("/a/b/c");
|
||||
await pool.acquire("/a/b");
|
||||
|
||||
const again = await pool.acquire("/a/b/c/src");
|
||||
expect(again.finder).toBe(narrow.finder);
|
||||
expect(again.root).toBe("/a/b/c");
|
||||
});
|
||||
|
||||
test("exact mode skips ancestor reuse", async () => {
|
||||
const pool = makePool();
|
||||
await pool.acquire("/a/b");
|
||||
const exact = await pool.acquire("/a/b/c", { exact: true });
|
||||
expect(exact.root).toBe("/a/b/c");
|
||||
expect(created.length).toBe(2);
|
||||
});
|
||||
|
||||
test("does not treat sibling prefixes as covering", async () => {
|
||||
const pool = makePool();
|
||||
await pool.acquire("/a/bc");
|
||||
const other = await pool.acquire("/a/b");
|
||||
expect(other.root).toBe("/a/b");
|
||||
expect(created.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -68,4 +68,12 @@ describe("path constraint normalization", () => {
|
||||
"src/**/*.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("bare recursive globs constrain nothing", () => {
|
||||
expect(normalizePathConstraint("**", cwd)).toBeNull();
|
||||
expect(normalizePathConstraint("**/", cwd)).toBeNull();
|
||||
expect(normalizePathConstraint("**/*", cwd)).toBeNull();
|
||||
expect(normalizePathConstraint("./**", cwd)).toBeNull();
|
||||
expect(buildQuery("**", "needle", undefined, cwd)).toBe("needle");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user