pi-supernova 0.0.7 → 0.0.11

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/repo-index.js ADDED
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { extractStructuralSurface } from "./surface.js";
4
+
5
+ // In-process workspace index: the gitignore-aware file list comes from one
6
+ // \`rg --files\` spawn and is then reused; file text, lowercase text, and the
7
+ // structural surface are cached per path and validated by mtime. snap/grep/glob
8
+ // read from here instead of spawning, so a warm call is sub-millisecond.
9
+
10
+ const LIST_TTL_MS = 10_000;
11
+ const MAX_INDEXED_FILES = 4000;
12
+ const MAX_FILE_BYTES = 512 * 1024;
13
+ const BINARY_EXT = new Set([
14
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
15
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".wasm", ".class",
16
+ ".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
17
+ ]);
18
+ const REGEX_SPECIAL = /[.+^${}()|\\]/g;
19
+ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
20
+ const EMPTY = Object.freeze([]);
21
+ const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
22
+
23
+ export function isTextCandidate(filePath) {
24
+ return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
25
+ }
26
+
27
+ /** Translate one glob token at index i → [regexSource, nextIndex]. */
28
+ function globToken(glob, i) {
29
+ const ch = glob[i];
30
+ if (ch === "*" && glob[i + 1] === "*") {
31
+ const slashAfter = glob[i + 2] === "/";
32
+ return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
33
+ }
34
+ if (ch === "*") return ["[^/]*", i + 1];
35
+ if (ch === "?") return ["[^/]", i + 1];
36
+ if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
37
+ return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
38
+ }
39
+
40
+ /** {a,b} alternation or [..] class starting at i. */
41
+ function globGroup(glob, i, open) {
42
+ const close = open === "{" ? "}" : "]";
43
+ const end = glob.indexOf(close, i);
44
+ if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
45
+ const inner = glob.slice(i + 1, end);
46
+ const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
47
+ return [source, end + 1];
48
+ }
49
+
50
+ function globBody(glob) {
51
+ let source = "";
52
+ let i = 0;
53
+ while (i < glob.length) {
54
+ const [piece, next] = globToken(glob, i);
55
+ source += piece;
56
+ i = next;
57
+ }
58
+ return source;
59
+ }
60
+
61
+ /** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
62
+ export function globToRegExp(glob) {
63
+ const body = globBody(glob);
64
+ return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
65
+ }
66
+
67
+ export class WorkspaceIndex {
68
+ constructor(runCommand) {
69
+ this.runCommand = runCommand;
70
+ this.lists = new Map();
71
+ this.entries = new Map();
72
+ }
73
+
74
+ invalidate() {
75
+ this.lists.clear();
76
+ }
77
+
78
+ /** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
79
+ async files(root, includeHidden = false) {
80
+ const key = root + "\0" + (includeHidden ? "h" : "");
81
+ const cached = this.lists.get(key);
82
+ if (cached && Date.now() - cached.at < LIST_TTL_MS) return cached.files;
83
+ const args = ["rg", "--files"];
84
+ if (includeHidden) args.push("--hidden");
85
+ args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
86
+ let files = [];
87
+ try {
88
+ const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
89
+ files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
90
+ } catch {
91
+ files = [];
92
+ }
93
+ this.lists.set(key, { files, at: Date.now() });
94
+ return files;
95
+ }
96
+
97
+ /** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
98
+ entry(filePath) {
99
+ if (!isTextCandidate(filePath)) return null;
100
+ let stat;
101
+ try {
102
+ stat = fs.statSync(filePath);
103
+ } catch {
104
+ this.entries.delete(filePath);
105
+ return null;
106
+ }
107
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
108
+ const cached = this.entries.get(filePath);
109
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
110
+ let text;
111
+ try {
112
+ text = fs.readFileSync(filePath, "utf8");
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (text.includes("\0")) return null;
117
+ const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined };
118
+ this.entries.set(filePath, created);
119
+ return created;
120
+ }
121
+
122
+ static fromText(filePath, text) {
123
+ return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined };
124
+ }
125
+
126
+ /** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens — computed once per entry. */
127
+ static linesOf(entry) {
128
+ if (entry.lines) return entry.lines;
129
+ const raw = entry.text.split("\n");
130
+ const lower = new Array(raw.length);
131
+ const defNames = new Array(raw.length);
132
+ const idents = new Array(raw.length);
133
+ for (let i = 0; i < raw.length; i++) {
134
+ const trimmed = raw[i].trim();
135
+ lower[i] = trimmed.toLowerCase();
136
+ defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
137
+ idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
138
+ }
139
+ entry.lines = { raw, lower, defNames, idents };
140
+ return entry.lines;
141
+ }
142
+
143
+ static surfaceOf(entry) {
144
+ if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
145
+ return entry.surface;
146
+ }
147
+
148
+ /** True when the list is small enough to scan in-process instead of spawning rg. */
149
+ canScan(files) {
150
+ return files.length <= MAX_INDEXED_FILES;
151
+ }
152
+
153
+ /** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
154
+ filesContaining(files, needles, anyOf) {
155
+ const hits = [];
156
+ for (const filePath of files) {
157
+ const e = this.entry(filePath);
158
+ if (!e) continue;
159
+ const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
160
+ if (found) hits.push(filePath);
161
+ }
162
+ return hits;
163
+ }
164
+
165
+ /** rg-style "path:line:text" rows over the indexed files, paths relative to root. */
166
+ grep(files, regex, root) {
167
+ const out = [];
168
+ for (const filePath of files) {
169
+ const e = this.entry(filePath);
170
+ if (!e || !regex.test(e.text)) continue;
171
+ const lines = e.text.split("\n");
172
+ const rel = path.relative(root, filePath) || filePath;
173
+ for (let i = 0; i < lines.length; i++) {
174
+ if (regex.test(lines[i])) out.push(rel + ":" + (i + 1) + ":" + lines[i]);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ }