pi-readseek 0.3.8 → 0.3.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,5 @@
1
1
  import type { Node as SyntaxNode, Parser as WasmParser, Tree } from "web-tree-sitter";
2
- import { detectLanguage } from "./readseek/language-detect.js";
3
- import { getWasmParser } from "./readseek/parser-loader.js";
2
+ import { getWasmParser, type WasmLanguageId } from "./readseek/parser-loader.js";
4
3
  import { reportParserError } from "./readseek/parser-errors.js";
5
4
 
6
5
  export interface ValidateInput {
@@ -75,15 +74,31 @@ function dedupeSortLines(
75
74
  return out.map((o) => o.key);
76
75
  }
77
76
 
77
+ const EXTENSION_TO_LANGUAGE: Record<string, WasmLanguageId> = {
78
+ ".rs": "rust",
79
+ ".c": "cpp",
80
+ ".cc": "cpp",
81
+ ".cpp": "cpp",
82
+ ".cxx": "cpp",
83
+ ".h": "c-header",
84
+ ".hpp": "c-header",
85
+ ".hxx": "c-header",
86
+ ".java": "java",
87
+ };
88
+
89
+ function detectWasmLang(filePath: string): WasmLanguageId | null {
90
+ for (const [ext, langId] of Object.entries(EXTENSION_TO_LANGUAGE)) {
91
+ if (filePath.toLowerCase().endsWith(ext)) return langId;
92
+ }
93
+ return null;
94
+ }
95
+
78
96
  export async function validateSyntaxRegression(
79
- input: ValidateInput,
97
+ input: ValidateInput,
80
98
  ): Promise<ValidateResult | null> {
81
- const lang = detectLanguage(input.filePath);
82
- if (!lang) return null;
83
- if (lang.id !== "rust" && lang.id !== "cpp" && lang.id !== "c-header" && lang.id !== "java") {
84
- return null;
85
- }
86
- const parser = await getWasmParser(lang.id);
99
+ const langId = detectWasmLang(input.filePath);
100
+ if (!langId) return null;
101
+ const parser = await getWasmParser(langId);
87
102
  if (!parser) return null;
88
103
 
89
104
  try {
@@ -109,12 +124,12 @@ export async function validateSyntaxRegression(
109
124
  ...afterStats.missing,
110
125
  ]);
111
126
  return { errorLines, newErrorCount, newMissingCount };
112
- } catch (err) {
113
- reportParserError(`wasm:syntax-validate:${lang.id}:${err instanceof Error ? err.message : String(err)}`, err, {
114
- context: `tree-sitter syntax validation failed for ${lang.id}`,
115
- });
116
- return null;
117
- } finally {
118
- parser.delete();
127
+ } catch (err) {
128
+ reportParserError(`wasm:syntax-validate:${langId}:${err instanceof Error ? err.message : String(err)}`, err, {
129
+ context: `tree-sitter syntax validation failed for ${langId}`,
130
+ });
131
+ return null;
132
+ } finally {
133
+ parser.delete();
119
134
  }
120
135
  }
package/src/map-cache.ts CHANGED
@@ -1,18 +1,12 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import type { FileMap } from "./readseek/types.js";
3
- import { generateMap, generateMapWithIdentity, READSEEK_MAPPER_IDENTITY } from "./readseek/mapper.js";
4
- import {
5
- computeKey,
6
- contentHashFor64k,
7
- readCached,
8
- writeCached,
9
- persistenceEnabled,
10
- } from "./persistent-map-cache.js";
3
+ import { generateMap } from "./readseek/mapper.js";
4
+
11
5
  interface CacheEntry {
12
6
  mtimeMs: number;
13
- contentHash: string;
14
7
  map: FileMap | null;
15
8
  }
9
+
16
10
  export const MAP_CACHE_MAX_SIZE = 500;
17
11
 
18
12
  interface MapCacheGlobalState {
@@ -41,21 +35,12 @@ function rememberInMemory(absPath: string, entry: CacheEntry): void {
41
35
  }
42
36
  }
43
37
 
44
- async function stableContentHash(
45
- absPath: string,
46
- mtimeMs: number,
47
- expectedHash: string,
48
- ): Promise<string | null> {
49
- if (!expectedHash) return null;
50
- const currentStat = await stat(absPath);
51
- if (currentStat.mtimeMs !== mtimeMs) return null;
52
- const currentHash = await contentHashFor64k(absPath);
53
- if (!currentHash || currentHash !== expectedHash) return null;
54
- return currentHash;
55
- }
56
-
57
38
  /**
58
- * Get or generate a structural file map, with mtime-based caching.
39
+ * Get or generate a structural file map, with mtime-based in-memory caching.
40
+ * Readseek already caches map results in `.readseek/maps/` with its own binary
41
+ * format. The in-memory layer here avoids redundant process spawns within a
42
+ * single session.
43
+ *
59
44
  * Returns null on any failure — never throws.
60
45
  */
61
46
  export async function getOrGenerateMap(absPath: string): Promise<FileMap | null> {
@@ -65,72 +50,14 @@ export async function getOrGenerateMap(absPath: string): Promise<FileMap | null>
65
50
  const state = getMapCacheState();
66
51
  const cached = state.cache.get(absPath);
67
52
  if (cached && cached.mtimeMs === mtimeMs) {
68
- const currentHash = await contentHashFor64k(absPath);
69
- if (currentHash && currentHash === cached.contentHash) {
70
- state.cache.delete(absPath);
71
- state.cache.set(absPath, cached);
72
- return cached.map;
73
- }
74
- }
75
- if (!persistenceEnabled()) {
76
- const map = await generateMap(absPath);
77
- const hash = await contentHashFor64k(absPath);
78
- rememberInMemory(absPath, { mtimeMs, contentHash: hash, map });
79
- return map;
80
- }
81
-
82
- let preContentHash = "";
83
- try {
84
- preContentHash = await contentHashFor64k(absPath);
85
- if (preContentHash) {
86
- const key = computeKey(
87
- absPath,
88
- mtimeMs,
89
- preContentHash,
90
- READSEEK_MAPPER_IDENTITY.mapperName,
91
- READSEEK_MAPPER_IDENTITY.mapperVersion,
92
- );
93
- const fromDisk = await readCached(key);
94
- if (fromDisk) {
95
- rememberInMemory(absPath, { mtimeMs, contentHash: preContentHash, map: fromDisk });
96
- return fromDisk;
97
- }
98
- }
99
- } catch {
100
- // fall through to regeneration on a disk-cache miss
101
- }
102
- const { map, mapperName, mapperVersion } = await generateMapWithIdentity(absPath);
103
- const persistentIdentity = { mapperName, mapperVersion };
104
- let stableHash: string | null = null;
105
- let shouldRemember = true;
106
- if (preContentHash) {
107
- try {
108
- stableHash = await stableContentHash(absPath, mtimeMs, preContentHash);
109
- shouldRemember = stableHash !== null;
110
- } catch {
111
- shouldRemember = false;
112
- }
113
- }
114
- if (shouldRemember) {
115
- const hash = stableHash ?? preContentHash ?? "";
116
- rememberInMemory(absPath, { mtimeMs, contentHash: hash, map });
117
- }
118
- if (map && stableHash) {
119
- try {
120
- const key = computeKey(
121
- absPath,
122
- mtimeMs,
123
- stableHash,
124
- persistentIdentity.mapperName,
125
- persistentIdentity.mapperVersion,
126
- );
127
- await writeCached(key, map);
128
- } catch {
129
- // never fail the caller on a cache-write miss
130
- }
53
+ state.cache.delete(absPath);
54
+ state.cache.set(absPath, cached);
55
+ return cached.map;
131
56
  }
57
+ const map = await generateMap(absPath);
58
+ rememberInMemory(absPath, { mtimeMs, map });
132
59
  return map;
133
60
  } catch {
134
61
  return null;
135
62
  }
136
- }
63
+ }
@@ -4,49 +4,25 @@ import { readseekMap, readseekMapContent } from "../readseek-client.js";
4
4
  import { throwIfAborted } from "../runtime.js";
5
5
  import type { FileMap, MapOptions } from "./types.js";
6
6
 
7
- export const READSEEK_MAPPER_NAME = "readseek";
8
- export const READSEEK_MAPPER_VERSION = 2;
9
-
10
- export interface MapperIdentity {
11
- mapperName: string;
12
- mapperVersion: number;
13
- }
14
-
15
- export interface MapResultWithIdentity extends MapperIdentity {
16
- map: FileMap | null;
17
- }
18
-
19
- export const READSEEK_MAPPER_IDENTITY: MapperIdentity = {
20
- mapperName: READSEEK_MAPPER_NAME,
21
- mapperVersion: READSEEK_MAPPER_VERSION,
22
- };
23
- export async function generateMapWithIdentity(
24
- filePath: string,
25
- options: MapOptions = {},
26
- ): Promise<MapResultWithIdentity> {
27
- throwIfAborted(options.signal);
28
- const fileStat = await stat(filePath);
29
- throwIfAborted(options.signal);
30
- const map = await readseekMap(filePath, fileStat.size, { signal: options.signal });
31
- throwIfAborted(options.signal);
32
- return { map, ...READSEEK_MAPPER_IDENTITY };
33
- }
34
-
35
7
  export async function generateMap(
36
- filePath: string,
37
- options: MapOptions = {},
8
+ filePath: string,
9
+ options: MapOptions = {},
38
10
  ): Promise<FileMap | null> {
39
- return (await generateMapWithIdentity(filePath, options)).map;
11
+ throwIfAborted(options.signal);
12
+ const fileStat = await stat(filePath);
13
+ throwIfAborted(options.signal);
14
+ const map = await readseekMap(filePath, fileStat.size, { signal: options.signal });
15
+ throwIfAborted(options.signal);
16
+ return map;
40
17
  }
41
18
 
42
19
  export async function generateMapFromContent(
43
- filePath: string,
44
- content: string,
45
- options: MapOptions = {},
20
+ filePath: string,
21
+ content: string,
22
+ options: MapOptions = {},
46
23
  ): Promise<FileMap | null> {
47
- throwIfAborted(options.signal);
48
- const map = await readseekMapContent(filePath, content, { signal: options.signal });
49
- throwIfAborted(options.signal);
50
- return map;
24
+ throwIfAborted(options.signal);
25
+ const map = await readseekMapContent(filePath, content, { signal: options.signal });
26
+ throwIfAborted(options.signal);
27
+ return map;
51
28
  }
52
-
@@ -4,7 +4,6 @@ import { join } from "node:path";
4
4
 
5
5
  export interface ReadseekJsonSettings {
6
6
  grep?: { maxLines?: number; maxBytes?: number };
7
- mapCache?: { dir?: string; enabled?: boolean };
8
7
  edit?: { diffDisplay?: "collapsed" | "expanded" };
9
8
  }
10
9
 
@@ -77,16 +76,6 @@ function validateSettings(raw: unknown, source: string): ReadseekSettingsResult
77
76
  if (Object.keys(grep).length > 0) settings.grep = grep;
78
77
  }
79
78
 
80
- if (isRecord(raw.mapCache)) {
81
- const mapCache: NonNullable<ReadseekJsonSettings["mapCache"]> = {};
82
- if ("dir" in raw.mapCache) {
83
- if (typeof raw.mapCache.dir === "string" && raw.mapCache.dir.length > 0) mapCache.dir = raw.mapCache.dir;
84
- else warnings.push(invalid(source, "mapCache.dir"));
85
- }
86
- const enabled = readBoolean(raw.mapCache, "enabled", "mapCache.enabled", source, warnings);
87
- if (enabled !== undefined) mapCache.enabled = enabled;
88
- if (Object.keys(mapCache).length > 0) settings.mapCache = mapCache;
89
- }
90
79
 
91
80
  if (isRecord(raw.edit)) {
92
81
  const edit: NonNullable<ReadseekJsonSettings["edit"]> = {};
@@ -117,8 +106,6 @@ function mergeSettings(base: ReadseekJsonSettings, override: ReadseekJsonSetting
117
106
  const merged: ReadseekJsonSettings = {};
118
107
  const grep = { ...(base.grep ?? {}), ...(override.grep ?? {}) };
119
108
  if (Object.keys(grep).length > 0) merged.grep = grep;
120
- const mapCache = { ...(base.mapCache ?? {}), ...(override.mapCache ?? {}) };
121
- if (Object.keys(mapCache).length > 0) merged.mapCache = mapCache;
122
109
  const edit = { ...(base.edit ?? {}), ...(override.edit ?? {}) };
123
110
  if (Object.keys(edit).length > 0) merged.edit = edit;
124
111
  return merged;
@@ -1,77 +0,0 @@
1
- import { existsSync as defaultExistsSync, readFileSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
- import { dirname, resolve } from "node:path";
4
-
5
- const require = createRequire(import.meta.url);
6
-
7
- export interface ResolveBundledBinDeps {
8
- resolvePackageJson?: (specifier: string) => string;
9
- readPackageJson?: (packageJsonPath: string) => string;
10
- existsSync?: (candidate: string) => boolean;
11
- platform?: NodeJS.Platform;
12
- }
13
-
14
- function defaultResolvePackageJson(specifier: string): string {
15
- return require.resolve(specifier);
16
- }
17
-
18
- function defaultReadPackageJson(packageJsonPath: string): string {
19
- return readFileSync(packageJsonPath, "utf8");
20
- }
21
-
22
- function binEntryFor(packageJsonText: string, binName: string): string | undefined {
23
- const parsed = JSON.parse(packageJsonText) as { bin?: string | Record<string, string> };
24
- if (typeof parsed.bin === "string") return parsed.bin;
25
- return parsed.bin?.[binName];
26
- }
27
-
28
- function commandCandidates(basePath: string, platform: NodeJS.Platform): string[] {
29
- if (platform !== "win32") return [basePath];
30
- return [`${basePath}.exe`, basePath];
31
- }
32
-
33
- function firstExisting(candidates: string[], existsSync: (candidate: string) => boolean): string | undefined {
34
- return candidates.find((candidate) => existsSync(candidate));
35
- }
36
-
37
- export interface ExecutableCommand {
38
- command: string;
39
- argsPrefix: string[];
40
- }
41
-
42
- export function executableCommand(binaryPath: string, platform: NodeJS.Platform = process.platform): ExecutableCommand {
43
- if (platform === "win32" && /\.js$/i.test(binaryPath)) {
44
- return { command: process.execPath, argsPrefix: [binaryPath] };
45
- }
46
- return { command: binaryPath, argsPrefix: [] };
47
- }
48
-
49
- export function resolveBundledBin(
50
- packageName: string,
51
- binName: string,
52
- fallbackCommand: string,
53
- deps: ResolveBundledBinDeps = {},
54
- ): string {
55
- const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson;
56
- const readPackageJson = deps.readPackageJson ?? defaultReadPackageJson;
57
- const existsSync = deps.existsSync ?? defaultExistsSync;
58
- const platform = deps.platform ?? process.platform;
59
-
60
- try {
61
- const packageJsonPath = resolvePackageJson(`${packageName}/package.json`);
62
- const packageDir = dirname(packageJsonPath);
63
- const binEntry = binEntryFor(readPackageJson(packageJsonPath), binName);
64
- if (!binEntry) return fallbackCommand;
65
-
66
-
67
- const packageBinCandidate = firstExisting(
68
- commandCandidates(resolve(packageDir, binEntry), platform),
69
- existsSync,
70
- );
71
- if (packageBinCandidate) return packageBinCandidate;
72
- } catch {
73
- // Fall through to PATH fallback.
74
- }
75
-
76
- return fallbackCommand;
77
- }
@@ -1,89 +0,0 @@
1
- const SIZE_MULTIPLIERS: Record<string, number> = {
2
- "": 1,
3
- B: 1,
4
- K: 1024,
5
- KB: 1024,
6
- M: 1024 * 1024,
7
- MB: 1024 * 1024,
8
- G: 1024 ** 3,
9
- GB: 1024 ** 3,
10
- };
11
-
12
- export function parseSize(field: string, value: number | string): number {
13
- if (typeof value === "number") {
14
- if (!Number.isFinite(value) || value < 0) {
15
- throw new Error(
16
- `Invalid ${field} value: ${value} (expected a non-negative number of bytes)`,
17
- );
18
- }
19
- return value;
20
- }
21
-
22
- const match = /^\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*$/.exec(value);
23
- if (!match) {
24
- throw new Error(
25
- `Invalid ${field} value: ${JSON.stringify(value)} ` +
26
- `(expected a number with optional B/K/KB/M/MB/G/GB suffix; units are 1024-based)`,
27
- );
28
- }
29
-
30
- const num = parseFloat(match[1]);
31
- const suffix = match[2].toUpperCase();
32
- const mult = SIZE_MULTIPLIERS[suffix];
33
- if (mult === undefined) {
34
- throw new Error(
35
- `Invalid ${field} value: ${JSON.stringify(value)} ` +
36
- `(unknown unit '${match[2]}'; accepted: B, K, KB, M, MB, G, GB)`,
37
- );
38
- }
39
-
40
- return Math.round(num * mult);
41
- }
42
-
43
- const RELATIVE_UNIT_MS: Record<string, number> = {
44
- m: 60 * 1000,
45
- h: 60 * 60 * 1000,
46
- d: 24 * 60 * 60 * 1000,
47
- };
48
-
49
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
50
- const ISO_TS_RE =
51
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
52
-
53
- export function parseRelativeOrIsoDate(
54
- field: string,
55
- value: string,
56
- now: Date = new Date(),
57
- ): Date {
58
- if (typeof value !== "string" || value.length === 0) {
59
- throw new Error(
60
- `Invalid ${field} value: ${JSON.stringify(value)} ` +
61
- `(expected ISO date/timestamp or relative shorthand like '1h', '24h', '7d', '30m')`,
62
- );
63
- }
64
-
65
- const rel = /^\s*(\d+)\s*([mhd])\s*$/.exec(value);
66
- if (rel) {
67
- const n = parseInt(rel[1], 10);
68
- return new Date(now.getTime() - n * RELATIVE_UNIT_MS[rel[2]]);
69
- }
70
-
71
- if (ISO_DATE_RE.test(value)) {
72
- const parsed = new Date(`${value}T00:00:00.000Z`);
73
- if (!Number.isNaN(parsed.getTime())) {
74
- return parsed;
75
- }
76
- }
77
-
78
- if (ISO_TS_RE.test(value)) {
79
- const parsed = new Date(value);
80
- if (!Number.isNaN(parsed.getTime())) {
81
- return parsed;
82
- }
83
- }
84
-
85
- throw new Error(
86
- `Invalid ${field} value: ${JSON.stringify(value)} ` +
87
- `(expected ISO date/timestamp or relative shorthand like '1h', '24h', '7d', '30m')`,
88
- );
89
- }
package/src/find-stat.ts DELETED
@@ -1,36 +0,0 @@
1
- import * as fsPromises from "node:fs/promises";
2
- import type { Stats } from "node:fs";
3
- import { resolve as resolvePath } from "node:path";
4
-
5
- export const DEFAULT_STAT_CONCURRENCY = 32;
6
- export const _testable = {
7
- stat: fsPromises.stat,
8
- };
9
-
10
- export async function statAllWithConcurrency(
11
- relPaths: string[],
12
- baseDir: string,
13
- concurrency: number = DEFAULT_STAT_CONCURRENCY,
14
- ): Promise<(Stats | null)[]> {
15
- const requested = Number.isFinite(concurrency)
16
- ? concurrency
17
- : DEFAULT_STAT_CONCURRENCY;
18
- const limit = Math.max(1, Math.min(requested, DEFAULT_STAT_CONCURRENCY));
19
- const out: (Stats | null)[] = new Array(relPaths.length).fill(null);
20
- let next = 0;
21
-
22
- async function worker() {
23
- while (true) {
24
- const i = next++;
25
- if (i >= relPaths.length) return;
26
- try {
27
- out[i] = await _testable.stat(resolvePath(baseDir, relPaths[i]));
28
- } catch {
29
- out[i] = null;
30
- }
31
- }
32
- }
33
-
34
- await Promise.all(Array.from({ length: Math.min(limit, relPaths.length) }, () => worker()));
35
- return out;
36
- }
@@ -1,230 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import { createHash, randomBytes } from "node:crypto";
4
- import { open, readFile, writeFile as fsWriteFile, mkdir as fsMkdir, rename, readdir, stat, unlink } from "node:fs/promises";
5
- import xxhashWasm from "xxhash-wasm";
6
- import type { FileMap } from "./readseek/types.js";
7
- import { resolveReadseekJsonSettings } from "./readseek-settings.js";
8
-
9
- /**
10
- * Resolve the on-disk map-cache directory using config precedence:
11
- * 1. `PI_HASHLINE_MAP_CACHE_DIR` (when non-empty) — used verbatim.
12
- * 2. JSON `mapCache.dir` (when configured).
13
- * 3. `$XDG_CACHE_HOME/pi-readseek/maps` (when non-empty).
14
- * 4. `~/.cache/pi-readseek/maps`.
15
- */
16
- function resolveCacheDir(): string {
17
- const explicit = process.env.PI_HASHLINE_MAP_CACHE_DIR;
18
- if (explicit && explicit.length > 0) return explicit;
19
-
20
- const configured = resolveReadseekJsonSettings().settings.mapCache?.dir;
21
- if (configured && configured.length > 0) return configured;
22
-
23
- const xdg = process.env.XDG_CACHE_HOME;
24
- if (xdg && xdg.length > 0) return join(xdg, "pi-readseek/maps");
25
-
26
- return join(homedir(), ".cache/pi-readseek/maps");
27
- }
28
-
29
- export function persistenceEnabled(): boolean {
30
- if (process.env.PI_HASHLINE_NO_PERSIST_MAPS === "1") return false;
31
- const configured = resolveReadseekJsonSettings().settings.mapCache?.enabled;
32
- if (configured !== undefined) return configured;
33
- return true;
34
- }
35
-
36
- /**
37
- * Build a deterministic hex cache key from the five input components.
38
- * Uses SHA-256 truncated to 32 hex chars; collision-safe for our purposes
39
- * and short enough for readable filenames.
40
- */
41
- export function computeKey(
42
- absolutePath: string,
43
- mtimeMs: number,
44
- contentHash: string,
45
- mapperName: string,
46
- mapperVersion: number,
47
- ): string {
48
- return createHash("sha256")
49
- .update(`${absolutePath}\0${mtimeMs}\0${contentHash}\0${mapperName}\0${mapperVersion}`)
50
- .digest("hex")
51
- .slice(0, 32);
52
- }
53
-
54
- const CONTENT_HASH_WINDOW_BYTES = 64 * 1024;
55
-
56
- let xxhashReady: Promise<{ h32Raw: (b: Uint8Array, seed?: number) => number }> | null = null;
57
-
58
- function loadXxhash(): Promise<{ h32Raw: (b: Uint8Array, seed?: number) => number }> {
59
- if (!xxhashReady) {
60
- xxhashReady = xxhashWasm().then((h) => ({ h32Raw: h.h32Raw }));
61
- }
62
-
63
- return xxhashReady;
64
- }
65
-
66
- /**
67
- * xxHash32 hex digest over the first 64 KB of `absPath`. Returns "" if the
68
- * file cannot be opened or read — the caller treats that as a miss.
69
- */
70
- export async function contentHashFor64k(absPath: string): Promise<string> {
71
- try {
72
- const fh = await open(absPath, "r");
73
- try {
74
- const buf = Buffer.alloc(CONTENT_HASH_WINDOW_BYTES);
75
- const { bytesRead } = await fh.read(buf, 0, CONTENT_HASH_WINDOW_BYTES, 0);
76
- const view = buf.subarray(0, bytesRead);
77
- const { h32Raw } = await loadXxhash();
78
- const n = h32Raw(view, 0) >>> 0;
79
- return n.toString(16).padStart(8, "0");
80
- } finally {
81
- await fh.close();
82
- }
83
- } catch {
84
- return "";
85
- }
86
- }
87
-
88
- function keyPath(key: string): string {
89
- return join(resolveCacheDir(), `${key}.json`);
90
- }
91
-
92
- function isRecord(value: unknown): value is Record<string, unknown> {
93
- return typeof value === "object" && value !== null;
94
- }
95
-
96
- function isStringArray(value: unknown): value is string[] {
97
- return Array.isArray(value) && value.every((item) => typeof item === "string");
98
- }
99
-
100
- function isFileSymbolLike(value: unknown): boolean {
101
- if (!isRecord(value)) return false;
102
- if (typeof value.name !== "string") return false;
103
- if (typeof value.kind !== "string") return false;
104
- if (typeof value.startLine !== "number") return false;
105
- if (typeof value.endLine !== "number") return false;
106
- if (value.signature !== undefined && typeof value.signature !== "string") return false;
107
- if (value.children !== undefined && (!Array.isArray(value.children) || !value.children.every(isFileSymbolLike))) {
108
- return false;
109
- }
110
- if (value.modifiers !== undefined && !isStringArray(value.modifiers)) return false;
111
- if (value.docstring !== undefined && typeof value.docstring !== "string") return false;
112
- if (value.isExported !== undefined && typeof value.isExported !== "boolean") return false;
113
- return true;
114
- }
115
-
116
- function isTruncatedInfoLike(value: unknown): boolean {
117
- if (!isRecord(value)) return false;
118
- return (
119
- typeof value.totalSymbols === "number"
120
- && typeof value.shownSymbols === "number"
121
- && typeof value.omittedSymbols === "number"
122
- );
123
- }
124
-
125
- const DETAIL_LEVELS = new Set(["full", "compact", "minimal", "outline", "truncated"]);
126
-
127
- function isFileMap(value: unknown): value is FileMap {
128
- if (!isRecord(value)) return false;
129
- if (typeof value.path !== "string") return false;
130
- if (typeof value.totalLines !== "number") return false;
131
- if (typeof value.totalBytes !== "number") return false;
132
- if (typeof value.language !== "string") return false;
133
- if (!Array.isArray(value.symbols) || !value.symbols.every(isFileSymbolLike)) return false;
134
- if (!isStringArray(value.imports)) return false;
135
- if (typeof value.detailLevel !== "string" || !DETAIL_LEVELS.has(value.detailLevel)) return false;
136
- if (value.truncatedInfo !== undefined && !isTruncatedInfoLike(value.truncatedInfo)) return false;
137
- return true;
138
- }
139
-
140
- /** Read a cached FileMap. Returns null on any failure (missing, corrupt, unreadable). */
141
- export async function readCached(key: string): Promise<FileMap | null> {
142
- if (!persistenceEnabled()) return null;
143
-
144
- try {
145
- const raw = await readFile(keyPath(key), "utf-8");
146
- const parsed: unknown = JSON.parse(raw);
147
- return isFileMap(parsed) ? parsed : null;
148
- } catch {
149
- return null;
150
- }
151
- }
152
-
153
- async function tryWriteCachedRaw(key: string, map: FileMap): Promise<boolean> {
154
- if (!persistenceEnabled()) return false;
155
- const dir = resolveCacheDir();
156
- const target = join(dir, `${key}.json`);
157
- const tmp = join(dir, `${key}.${randomBytes(6).toString("hex")}.tmp`);
158
- try {
159
- await fsMkdir(dir, { recursive: true });
160
- await fsWriteFile(tmp, JSON.stringify(map));
161
- await rename(tmp, target);
162
- return true;
163
- } catch {
164
- try {
165
- await unlink(tmp);
166
- } catch {
167
- // ignore temp-file cleanup failures
168
- }
169
- return false;
170
- }
171
- }
172
-
173
- /** Low-level atomic write. Internal helper. */
174
- async function writeCachedRaw(key: string, map: FileMap): Promise<void> {
175
- await tryWriteCachedRaw(key, map);
176
- }
177
-
178
- const EVICTION_INTERVAL = 20;
179
- const EVICTION_CAP = 5000;
180
- let writeCounter = 0;
181
-
182
-
183
- const CACHE_KEY_FILE = /^[0-9a-f]{32}\.json$/;
184
- async function maybeEvict(): Promise<void> {
185
- try {
186
- const dir = resolveCacheDir();
187
- const names = (await readdir(dir)).filter((n) => CACHE_KEY_FILE.test(n));
188
- if (names.length <= EVICTION_CAP) return;
189
-
190
- const entries: Array<{ path: string; age: number }> = [];
191
- for (const name of names) {
192
- const path = join(dir, name);
193
- try {
194
- const s = await stat(path);
195
- const age = (s.atimeMs ?? s.mtimeMs) || 0;
196
- entries.push({ path, age });
197
- } catch {
198
- // ignore individual stat failures
199
- }
200
- }
201
-
202
- entries.sort((a, b) => a.age - b.age);
203
- const excess = entries.length - EVICTION_CAP;
204
- for (let i = 0; i < excess; i += 1) {
205
- try {
206
- await unlink(entries[i].path);
207
- } catch {
208
- // ignore individual unlink failures
209
- }
210
- }
211
- } catch {
212
- // Swallow eviction errors — never propagate.
213
- }
214
- }
215
-
216
- /**
217
- * Public atomic write. Counts successful invocations so eviction can trigger lazily.
218
- */
219
- export async function writeCached(key: string, map: FileMap): Promise<void> {
220
- if (!(await tryWriteCachedRaw(key, map))) return;
221
- writeCounter += 1;
222
- if (writeCounter % EVICTION_INTERVAL === 0) {
223
- try {
224
- await maybeEvict();
225
- } catch {
226
- // Swallow eviction errors at the call site too.
227
- }
228
- }
229
- }
230
-
@@ -1,29 +0,0 @@
1
- import type { LanguageInfo } from "./types.js";
2
-
3
- const EXTENSION_LANGUAGE_MAP: Record<string, LanguageInfo> = {
4
- ".rs": { id: "rust", name: "Rust" },
5
- ".c": { id: "cpp", name: "C" },
6
- ".cc": { id: "cpp", name: "C++" },
7
- ".cpp": { id: "cpp", name: "C++" },
8
- ".cxx": { id: "cpp", name: "C++" },
9
- ".hpp": { id: "c-header", name: "C/C++ Header" },
10
- ".h": { id: "c-header", name: "C/C++ Header" },
11
- ".hxx": { id: "c-header", name: "C/C++ Header" },
12
- ".java": { id: "java", name: "Java" },
13
- };
14
-
15
- export function detectLanguage(filePath: string): LanguageInfo | null {
16
- const normalized = filePath.toLowerCase();
17
- for (const [ext, language] of Object.entries(EXTENSION_LANGUAGE_MAP)) {
18
- if (normalized.endsWith(ext)) return language;
19
- }
20
- return null;
21
- }
22
-
23
- export function isSupported(filePath: string): boolean {
24
- return detectLanguage(filePath) !== null;
25
- }
26
-
27
- export function getSupportedExtensions(): string[] {
28
- return Object.keys(EXTENSION_LANGUAGE_MAP);
29
- }