@ff-labs/bun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Binary download utilities for fff
3
+ *
4
+ * Downloads prebuilt binaries from GitHub releases based on commit hash.
5
+ * The release tag corresponds to the short commit SHA (7 characters).
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createHash } from "node:crypto";
12
+ import { getTriple, getLibExtension, getLibFilename } from "./platform";
13
+
14
+ const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
15
+ const GITHUB_API = "https://api.github.com";
16
+
17
+ /**
18
+ * Get the current file's directory
19
+ */
20
+ function getCurrentDir(): string {
21
+ const url = import.meta.url;
22
+ if (url.startsWith("file://")) {
23
+ return dirname(fileURLToPath(url));
24
+ }
25
+ return dirname(url);
26
+ }
27
+
28
+ /**
29
+ * Get the package root directory
30
+ */
31
+ function getPackageDir(): string {
32
+ const currentDir = getCurrentDir();
33
+ return dirname(currentDir);
34
+ }
35
+
36
+ /**
37
+ * Get the path to package.json
38
+ */
39
+ function getPackageJsonPath(): string {
40
+ return join(getPackageDir(), "package.json");
41
+ }
42
+
43
+ /**
44
+ * Get the directory where binaries are stored
45
+ */
46
+ export function getBinDir(): string {
47
+ return join(getPackageDir(), "bin");
48
+ }
49
+
50
+ /**
51
+ * Get the full path to the native library
52
+ */
53
+ export function getBinaryPath(): string {
54
+ const binDir = getBinDir();
55
+ return join(binDir, getLibFilename());
56
+ }
57
+
58
+ /**
59
+ * Check if the binary exists
60
+ */
61
+ export function binaryExists(): boolean {
62
+ return existsSync(getBinaryPath());
63
+ }
64
+
65
+ /**
66
+ * Read package.json
67
+ */
68
+ async function readPackageJson(): Promise<Record<string, unknown>> {
69
+ try {
70
+ return await Bun.file(getPackageJsonPath()).json();
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Write package.json
78
+ */
79
+ async function writePackageJson(pkg: Record<string, unknown>): Promise<void> {
80
+ const content = JSON.stringify(pkg, null, 2) + "\n";
81
+ writeFileSync(getPackageJsonPath(), content);
82
+ }
83
+
84
+ /**
85
+ * Get the installed binary hash from package.json
86
+ */
87
+ export async function getInstalledHash(): Promise<string | null> {
88
+ const pkg = await readPackageJson();
89
+ return (pkg.nativeBinaryHash as string) || null;
90
+ }
91
+
92
+ /**
93
+ * Update the installed hash in package.json
94
+ */
95
+ async function setInstalledHash(hash: string): Promise<void> {
96
+ const pkg = await readPackageJson();
97
+ pkg.nativeBinaryHash = hash;
98
+ await writePackageJson(pkg);
99
+ }
100
+
101
+ /**
102
+ * Get the development binary path (for local development)
103
+ */
104
+ export function getDevBinaryPath(): string | null {
105
+ const packageDir = getPackageDir();
106
+ const workspaceRoot = join(packageDir, "..", "..");
107
+
108
+ const possiblePaths = [
109
+ join(workspaceRoot, "target", "release", getLibFilename()),
110
+ join(workspaceRoot, "target", "debug", getLibFilename()),
111
+ ];
112
+
113
+ for (const path of possiblePaths) {
114
+ if (existsSync(path)) {
115
+ return path;
116
+ }
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Find the binary, checking both installed and dev paths
124
+ */
125
+ export function findBinary(): string | null {
126
+ const installedPath = getBinaryPath();
127
+ if (existsSync(installedPath)) {
128
+ return installedPath;
129
+ }
130
+ return getDevBinaryPath();
131
+ }
132
+
133
+ /**
134
+ * Fetch the latest release tag from GitHub
135
+ */
136
+ async function fetchLatestReleaseTag(): Promise<string> {
137
+ const url = `${GITHUB_API}/repos/${GITHUB_REPO}/releases/latest`;
138
+
139
+ const response = await fetch(url, {
140
+ headers: {
141
+ "Accept": "application/vnd.github.v3+json",
142
+ "User-Agent": "fff-bun-client",
143
+ },
144
+ });
145
+
146
+ if (!response.ok) {
147
+ // If no "latest" release, try getting the most recent prerelease
148
+ const allReleasesUrl = `${GITHUB_API}/repos/${GITHUB_REPO}/releases`;
149
+ const allResponse = await fetch(allReleasesUrl, {
150
+ headers: {
151
+ "Accept": "application/vnd.github.v3+json",
152
+ "User-Agent": "fff-bun-client",
153
+ },
154
+ });
155
+
156
+ if (!allResponse.ok) {
157
+ throw new Error(`Failed to fetch releases: ${allResponse.status}`);
158
+ }
159
+
160
+ const releases = await allResponse.json() as Array<{ tag_name: string }>;
161
+ if (releases.length === 0) {
162
+ throw new Error("No releases found");
163
+ }
164
+
165
+ return releases[0].tag_name;
166
+ }
167
+
168
+ const release = await response.json() as { tag_name: string };
169
+ return release.tag_name;
170
+ }
171
+
172
+ /**
173
+ * Resolve the hash to use for downloading
174
+ * If "latest", fetches the latest release tag from GitHub
175
+ */
176
+ async function resolveHash(hash: string): Promise<string> {
177
+ if (hash === "latest") {
178
+ console.log("fff: Fetching latest release tag...");
179
+ return await fetchLatestReleaseTag();
180
+ }
181
+ return hash;
182
+ }
183
+
184
+ /**
185
+ * Download and verify checksum for a binary
186
+ */
187
+ async function downloadWithChecksum(
188
+ binaryUrl: string,
189
+ checksumUrl: string,
190
+ ): Promise<Buffer> {
191
+ // Download binary
192
+ const binaryResponse = await fetch(binaryUrl);
193
+ if (!binaryResponse.ok) {
194
+ throw new Error(
195
+ `Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}\nURL: ${binaryUrl}`,
196
+ );
197
+ }
198
+
199
+ const binaryBuffer = Buffer.from(await binaryResponse.arrayBuffer());
200
+
201
+ // Try to download and verify checksum
202
+ try {
203
+ const checksumResponse = await fetch(checksumUrl);
204
+ if (checksumResponse.ok) {
205
+ const checksumText = await checksumResponse.text();
206
+ // Format: "hash filename" or just "hash"
207
+ const expectedHash = checksumText.trim().split(/\s+/)[0];
208
+
209
+ const actualHash = createHash("sha256").update(binaryBuffer).digest("hex");
210
+
211
+ if (actualHash !== expectedHash) {
212
+ throw new Error(
213
+ `Checksum mismatch!\nExpected: ${expectedHash}\nActual: ${actualHash}`,
214
+ );
215
+ }
216
+ console.log("fff: Checksum verified ✓");
217
+ }
218
+ } catch (error) {
219
+ if (error instanceof Error && error.message.includes("Checksum mismatch")) {
220
+ throw error;
221
+ }
222
+ // Checksum file not found, continue without verification
223
+ console.log("fff: Checksum file not available, skipping verification");
224
+ }
225
+
226
+ return binaryBuffer;
227
+ }
228
+
229
+ /**
230
+ * Download the binary from GitHub releases
231
+ * @param hash - The commit hash (release tag) to download, or "latest"
232
+ */
233
+ export async function downloadBinary(hash?: string): Promise<string> {
234
+ const currentHash = await getInstalledHash();
235
+ const packageHash = hash || currentHash || "latest";
236
+ const resolvedHash = await resolveHash(packageHash);
237
+
238
+ const triple = getTriple();
239
+ const ext = getLibExtension();
240
+
241
+ // Binary name format: c-lib-{triple}.{ext}
242
+ const binaryName = `c-lib-${triple}.${ext}`;
243
+ const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${resolvedHash}`;
244
+ const binaryUrl = `${baseUrl}/${binaryName}`;
245
+ const checksumUrl = `${baseUrl}/${binaryName}.sha256`;
246
+
247
+ console.log(`fff: Downloading native library for ${triple}...`);
248
+ console.log(`fff: Release: ${resolvedHash}`);
249
+ console.log(`fff: URL: ${binaryUrl}`);
250
+
251
+ const binaryBuffer = await downloadWithChecksum(binaryUrl, checksumUrl);
252
+
253
+ const binDir = getBinDir();
254
+ if (!existsSync(binDir)) {
255
+ mkdirSync(binDir, { recursive: true });
256
+ }
257
+
258
+ const binaryPath = getBinaryPath();
259
+ writeFileSync(binaryPath, binaryBuffer);
260
+
261
+ // Save the hash to package.json
262
+ await setInstalledHash(resolvedHash);
263
+
264
+ // Make executable on Unix
265
+ if (process.platform !== "win32") {
266
+ chmodSync(binaryPath, 0o755);
267
+ }
268
+
269
+ console.log(`fff: Binary downloaded to ${binaryPath}`);
270
+ return resolvedHash;
271
+ }
272
+
273
+ /**
274
+ * Check if an update is available
275
+ */
276
+ export async function checkForUpdate(): Promise<{
277
+ currentHash: string | null;
278
+ latestHash: string;
279
+ updateAvailable: boolean;
280
+ }> {
281
+ const currentHash = await getInstalledHash();
282
+ const latestHash = await fetchLatestReleaseTag();
283
+
284
+ return {
285
+ currentHash,
286
+ latestHash,
287
+ updateAvailable: currentHash !== latestHash,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Ensure the binary exists, downloading if necessary
293
+ */
294
+ export async function ensureBinary(): Promise<string> {
295
+ const existingPath = findBinary();
296
+ if (existingPath) {
297
+ return existingPath;
298
+ }
299
+
300
+ await downloadBinary();
301
+ return getBinaryPath();
302
+ }
303
+
304
+ /**
305
+ * Download binary, with fallback to cargo build instructions
306
+ */
307
+ export async function downloadOrBuild(): Promise<void> {
308
+ try {
309
+ await downloadBinary();
310
+ } catch (error) {
311
+ console.error(`fff: Failed to download binary: ${error}`);
312
+ console.error(`fff: You can build from source instead:`);
313
+ console.error(` cargo build --release -p fff-c`);
314
+ throw error;
315
+ }
316
+ }
package/src/ffi.ts ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Bun FFI bindings for the fff-c native library
3
+ *
4
+ * This module uses Bun's native FFI to call into the Rust C library.
5
+ * All functions follow the Result pattern for error handling.
6
+ */
7
+
8
+ import { dlopen, FFIType, ptr, CString, read, type Pointer } from "bun:ffi";
9
+ import { findBinary, ensureBinary } from "./download";
10
+ import type { Result } from "./types";
11
+ import { err } from "./types";
12
+
13
+ // Define the FFI symbols
14
+ const ffiDefinition = {
15
+ // Lifecycle
16
+ fff_init: {
17
+ args: [FFIType.cstring],
18
+ returns: FFIType.ptr,
19
+ },
20
+ fff_destroy: {
21
+ args: [],
22
+ returns: FFIType.ptr,
23
+ },
24
+
25
+ // Search
26
+ fff_search: {
27
+ args: [FFIType.cstring, FFIType.cstring],
28
+ returns: FFIType.ptr,
29
+ },
30
+
31
+ // File index
32
+ fff_scan_files: {
33
+ args: [],
34
+ returns: FFIType.ptr,
35
+ },
36
+ fff_is_scanning: {
37
+ args: [],
38
+ returns: FFIType.bool,
39
+ },
40
+ fff_get_scan_progress: {
41
+ args: [],
42
+ returns: FFIType.ptr,
43
+ },
44
+ fff_wait_for_scan: {
45
+ args: [FFIType.u64],
46
+ returns: FFIType.ptr,
47
+ },
48
+ fff_restart_index: {
49
+ args: [FFIType.cstring],
50
+ returns: FFIType.ptr,
51
+ },
52
+
53
+ // Frecency
54
+ fff_track_access: {
55
+ args: [FFIType.cstring],
56
+ returns: FFIType.ptr,
57
+ },
58
+
59
+ // Git
60
+ fff_refresh_git_status: {
61
+ args: [],
62
+ returns: FFIType.ptr,
63
+ },
64
+
65
+ // Query tracking
66
+ fff_track_query: {
67
+ args: [FFIType.cstring, FFIType.cstring],
68
+ returns: FFIType.ptr,
69
+ },
70
+ fff_get_historical_query: {
71
+ args: [FFIType.u64],
72
+ returns: FFIType.ptr,
73
+ },
74
+
75
+ // Utilities
76
+ fff_health_check: {
77
+ args: [FFIType.cstring],
78
+ returns: FFIType.ptr,
79
+ },
80
+ fff_shorten_path: {
81
+ args: [FFIType.cstring, FFIType.u64, FFIType.cstring],
82
+ returns: FFIType.ptr,
83
+ },
84
+
85
+ // Memory management
86
+ fff_free_result: {
87
+ args: [FFIType.ptr],
88
+ returns: FFIType.void,
89
+ },
90
+ fff_free_string: {
91
+ args: [FFIType.ptr],
92
+ returns: FFIType.void,
93
+ },
94
+ } as const;
95
+
96
+ type FFFLibrary = ReturnType<typeof dlopen<typeof ffiDefinition>>;
97
+
98
+ // Library instance (lazy loaded)
99
+ let lib: FFFLibrary | null = null;
100
+
101
+ /**
102
+ * Load the native library
103
+ */
104
+ function loadLibrary(): FFFLibrary {
105
+ if (lib) return lib;
106
+
107
+ const binaryPath = findBinary();
108
+ if (!binaryPath) {
109
+ throw new Error(
110
+ "fff native library not found. Run `bunx fff download` or build from source with `cargo build --release -p fff-c`"
111
+ );
112
+ }
113
+
114
+ lib = dlopen(binaryPath, ffiDefinition);
115
+ return lib;
116
+ }
117
+
118
+ /**
119
+ * Encode a string for FFI (null-terminated)
120
+ */
121
+ function encodeString(s: string): Uint8Array {
122
+ return new TextEncoder().encode(s + "\0");
123
+ }
124
+
125
+ /**
126
+ * Read a C string from a pointer
127
+ * Note: read.ptr() returns number but CString expects Pointer - we cast through unknown
128
+ */
129
+ function readCString(pointer: Pointer | number | null): string | null {
130
+ if (pointer === null || pointer === 0) return null;
131
+ // CString constructor accepts Pointer, but read.ptr returns number
132
+ // Cast through unknown for runtime compatibility
133
+ return new CString(pointer as unknown as Pointer).toString();
134
+ }
135
+
136
+ /**
137
+ * Convert snake_case keys to camelCase recursively
138
+ */
139
+ function snakeToCamel(obj: unknown): unknown {
140
+ if (obj === null || obj === undefined) return obj;
141
+ if (typeof obj !== "object") return obj;
142
+ if (Array.isArray(obj)) return obj.map(snakeToCamel);
143
+
144
+ const result: Record<string, unknown> = {};
145
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
146
+ const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
147
+ letter.toUpperCase()
148
+ );
149
+ result[camelKey] = snakeToCamel(value);
150
+ }
151
+ return result;
152
+ }
153
+
154
+ /**
155
+ * Parse a FffResult from the FFI return value
156
+ * The result is a pointer to a struct: { success: bool, data: *char, error: *char }
157
+ */
158
+ function parseResult<T>(resultPtr: Pointer | null): Result<T> {
159
+ if (resultPtr === null) {
160
+ return err("FFI returned null pointer");
161
+ }
162
+
163
+ // Read the struct fields
164
+ // FffResult layout: bool (1 byte + 7 padding) + pointer (8 bytes) + pointer (8 bytes)
165
+ // offset 0: success (bool, 1 byte)
166
+ // offset 8: data pointer (8 bytes)
167
+ // offset 16: error pointer (8 bytes)
168
+ const success = read.u8(resultPtr, 0) !== 0;
169
+ const dataPtr = read.ptr(resultPtr, 8);
170
+ const errorPtr = read.ptr(resultPtr, 16);
171
+
172
+ const library = loadLibrary();
173
+
174
+ if (success) {
175
+ const data = readCString(dataPtr);
176
+ // Free the result
177
+ library.symbols.fff_free_result(resultPtr);
178
+
179
+ if (data === null || data === "") {
180
+ return { ok: true, value: undefined as T };
181
+ }
182
+
183
+ try {
184
+ const parsed = JSON.parse(data);
185
+ // Convert snake_case to camelCase for TypeScript consumers
186
+ const transformed = snakeToCamel(parsed) as T;
187
+ return { ok: true, value: transformed };
188
+ } catch {
189
+ // For simple values like "true" or numbers
190
+ return { ok: true, value: data as T };
191
+ }
192
+ } else {
193
+ const errorMsg = readCString(errorPtr) || "Unknown error";
194
+ // Free the result
195
+ library.symbols.fff_free_result(resultPtr);
196
+ return err(errorMsg);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Initialize the file finder
202
+ */
203
+ export function ffiInit(optsJson: string): Result<void> {
204
+ const library = loadLibrary();
205
+ const resultPtr = library.symbols.fff_init(ptr(encodeString(optsJson)));
206
+ return parseResult<void>(resultPtr);
207
+ }
208
+
209
+ /**
210
+ * Destroy and clean up resources
211
+ */
212
+ export function ffiDestroy(): Result<void> {
213
+ const library = loadLibrary();
214
+ const resultPtr = library.symbols.fff_destroy();
215
+ return parseResult<void>(resultPtr);
216
+ }
217
+
218
+ /**
219
+ * Perform fuzzy search
220
+ */
221
+ export function ffiSearch(query: string, optsJson: string): Result<unknown> {
222
+ const library = loadLibrary();
223
+ const resultPtr = library.symbols.fff_search(
224
+ ptr(encodeString(query)),
225
+ ptr(encodeString(optsJson))
226
+ );
227
+ return parseResult<unknown>(resultPtr);
228
+ }
229
+
230
+ /**
231
+ * Trigger file scan
232
+ */
233
+ export function ffiScanFiles(): Result<void> {
234
+ const library = loadLibrary();
235
+ const resultPtr = library.symbols.fff_scan_files();
236
+ return parseResult<void>(resultPtr);
237
+ }
238
+
239
+ /**
240
+ * Check if scanning
241
+ */
242
+ export function ffiIsScanning(): boolean {
243
+ const library = loadLibrary();
244
+ return library.symbols.fff_is_scanning() as boolean;
245
+ }
246
+
247
+ /**
248
+ * Get scan progress
249
+ */
250
+ export function ffiGetScanProgress(): Result<unknown> {
251
+ const library = loadLibrary();
252
+ const resultPtr = library.symbols.fff_get_scan_progress();
253
+ return parseResult<unknown>(resultPtr);
254
+ }
255
+
256
+ /**
257
+ * Wait for scan to complete
258
+ */
259
+ export function ffiWaitForScan(timeoutMs: number): Result<boolean> {
260
+ const library = loadLibrary();
261
+ const resultPtr = library.symbols.fff_wait_for_scan(BigInt(timeoutMs));
262
+ const result = parseResult<string>(resultPtr);
263
+ if (!result.ok) return result;
264
+ return { ok: true, value: result.value === "true" };
265
+ }
266
+
267
+ /**
268
+ * Restart index in new path
269
+ */
270
+ export function ffiRestartIndex(newPath: string): Result<void> {
271
+ const library = loadLibrary();
272
+ const resultPtr = library.symbols.fff_restart_index(
273
+ ptr(encodeString(newPath))
274
+ );
275
+ return parseResult<void>(resultPtr);
276
+ }
277
+
278
+ /**
279
+ * Track file access
280
+ */
281
+ export function ffiTrackAccess(filePath: string): Result<boolean> {
282
+ const library = loadLibrary();
283
+ const resultPtr = library.symbols.fff_track_access(
284
+ ptr(encodeString(filePath))
285
+ );
286
+ const result = parseResult<string>(resultPtr);
287
+ if (!result.ok) return result;
288
+ return { ok: true, value: result.value === "true" };
289
+ }
290
+
291
+ /**
292
+ * Refresh git status
293
+ */
294
+ export function ffiRefreshGitStatus(): Result<number> {
295
+ const library = loadLibrary();
296
+ const resultPtr = library.symbols.fff_refresh_git_status();
297
+ const result = parseResult<string>(resultPtr);
298
+ if (!result.ok) return result;
299
+ return { ok: true, value: parseInt(result.value, 10) };
300
+ }
301
+
302
+ /**
303
+ * Track query completion
304
+ */
305
+ export function ffiTrackQuery(
306
+ query: string,
307
+ filePath: string
308
+ ): Result<boolean> {
309
+ const library = loadLibrary();
310
+ const resultPtr = library.symbols.fff_track_query(
311
+ ptr(encodeString(query)),
312
+ ptr(encodeString(filePath))
313
+ );
314
+ const result = parseResult<string>(resultPtr);
315
+ if (!result.ok) return result;
316
+ return { ok: true, value: result.value === "true" };
317
+ }
318
+
319
+ /**
320
+ * Get historical query
321
+ */
322
+ export function ffiGetHistoricalQuery(offset: number): Result<string | null> {
323
+ const library = loadLibrary();
324
+ const resultPtr = library.symbols.fff_get_historical_query(BigInt(offset));
325
+ const result = parseResult<string>(resultPtr);
326
+ if (!result.ok) return result;
327
+ if (result.value === "null") return { ok: true, value: null };
328
+ return result;
329
+ }
330
+
331
+ /**
332
+ * Health check
333
+ */
334
+ export function ffiHealthCheck(testPath: string): Result<unknown> {
335
+ const library = loadLibrary();
336
+ const resultPtr = library.symbols.fff_health_check(
337
+ ptr(encodeString(testPath))
338
+ );
339
+ return parseResult<unknown>(resultPtr);
340
+ }
341
+
342
+ /**
343
+ * Shorten path
344
+ */
345
+ export function ffiShortenPath(
346
+ path: string,
347
+ maxSize: number,
348
+ strategy: string
349
+ ): Result<string> {
350
+ const library = loadLibrary();
351
+ const resultPtr = library.symbols.fff_shorten_path(
352
+ ptr(encodeString(path)),
353
+ BigInt(maxSize),
354
+ ptr(encodeString(strategy))
355
+ );
356
+ return parseResult<string>(resultPtr);
357
+ }
358
+
359
+ /**
360
+ * Ensure the library is loaded (for preloading)
361
+ */
362
+ export async function ensureLoaded(): Promise<void> {
363
+ await ensureBinary();
364
+ loadLibrary();
365
+ }
366
+
367
+ /**
368
+ * Check if the library is available
369
+ */
370
+ export function isAvailable(): boolean {
371
+ try {
372
+ loadLibrary();
373
+ return true;
374
+ } catch {
375
+ return false;
376
+ }
377
+ }