@maxgfr/codeindex 2.7.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.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. package/src/workspaces.ts +748 -0
package/src/git.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { sh } from "./util.js";
2
+
3
+ // The short HEAD commit of a working tree, when it is a git repo. Recorded in
4
+ // the manifest so an index is pinned to an exact revision. Returns undefined
5
+ // when `git` is absent or the directory isn't a repo — the index still works.
6
+ export function headCommit(dir: string): string | undefined {
7
+ const res = sh("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
8
+ return res.ok ? res.stdout.trim() : undefined;
9
+ }
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Diff plumbing for `delta`. All calls disable core.quotePath so non-ASCII
13
+ // paths come back verbatim, and use -z (NUL separators) wherever a path could
14
+ // contain anything surprising.
15
+
16
+ export interface DiffFile {
17
+ path: string;
18
+ status: "added" | "modified" | "deleted" | "renamed";
19
+ oldPath?: string; // renames only
20
+ binary?: boolean;
21
+ linesAdded?: number;
22
+ linesDeleted?: number;
23
+ }
24
+
25
+ // A changed line range on the NEW side of the diff. A pure deletion has no new
26
+ // lines, so it maps to the touch-point line and is flagged approx.
27
+ export interface Hunk {
28
+ start: number;
29
+ end: number;
30
+ approx?: boolean;
31
+ }
32
+
33
+ // The diff to take: a merge-base for branch review, or the staged changeset.
34
+ export interface DiffSpec {
35
+ mergeBase?: string;
36
+ staged?: boolean;
37
+ }
38
+
39
+ const gitArgs = (dir: string): string[] => ["-C", dir, "-c", "core.quotePath=false"];
40
+ const rangeArgs = (spec: DiffSpec): string[] => (spec.staged ? ["--cached"] : [spec.mergeBase!]);
41
+
42
+ export function isGitWorktree(dir: string): boolean {
43
+ return sh("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"]).ok;
44
+ }
45
+
46
+ // Resolve the review base. An explicit ref must exist; otherwise the first of
47
+ // origin/HEAD → origin/main → origin/master → main → master that resolves is
48
+ // taken, and the comparison point is its MERGE-BASE with HEAD (PR semantics —
49
+ // commits landed on the base branch never count as yours). With no candidate
50
+ // (fresh repo, detached CI clone) the base falls back to HEAD with a note.
51
+ export function resolveBaseRef(
52
+ dir: string,
53
+ base?: string,
54
+ ): { ref: string; mergeBase: string; note?: string } | { error: string } {
55
+ const verify = (ref: string): boolean =>
56
+ sh("git", [...gitArgs(dir), "rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).ok;
57
+ const mergeBase = (ref: string): string | undefined => {
58
+ const mb = sh("git", [...gitArgs(dir), "merge-base", ref, "HEAD"]);
59
+ return mb.ok ? mb.stdout.trim() : undefined;
60
+ };
61
+
62
+ if (base) {
63
+ if (!verify(base)) return { error: `base ref "${base}" not found (tried git rev-parse --verify)` };
64
+ const mb = mergeBase(base);
65
+ if (!mb) return { error: `no merge-base between "${base}" and HEAD` };
66
+ return { ref: base, mergeBase: mb };
67
+ }
68
+
69
+ const originHead = sh("git", [...gitArgs(dir), "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
70
+ const candidates = [
71
+ ...(originHead.ok ? [originHead.stdout.trim().replace("refs/remotes/", "")] : []),
72
+ "origin/main",
73
+ "origin/master",
74
+ "main",
75
+ "master",
76
+ ];
77
+ for (const c of candidates) {
78
+ if (!verify(c)) continue;
79
+ const mb = mergeBase(c);
80
+ if (mb) return { ref: c, mergeBase: mb };
81
+ }
82
+ const head = sh("git", [...gitArgs(dir), "rev-parse", "HEAD"]);
83
+ if (!head.ok) return { error: "cannot resolve HEAD — empty repository?" };
84
+ return {
85
+ ref: "HEAD",
86
+ mergeBase: head.stdout.trim(),
87
+ note: "base: HEAD (no default branch found — reviewing uncommitted work)",
88
+ };
89
+ }
90
+
91
+ // Changed files with statuses (rename-aware) plus per-file churn/binary info
92
+ // from --numstat. Default spec compares the merge-base against the WORKTREE —
93
+ // committed + staged + unstaged, "review my branch as it sits".
94
+ export function diffFiles(dir: string, spec: DiffSpec): DiffFile[] {
95
+ const out: DiffFile[] = [];
96
+ const ns = sh("git", [...gitArgs(dir), "diff", "-z", "-M", "--name-status", ...rangeArgs(spec)]);
97
+ if (ns.ok) {
98
+ const toks = ns.stdout.split("\0");
99
+ let i = 0;
100
+ while (i < toks.length) {
101
+ const st = toks[i++];
102
+ if (!st) break;
103
+ const code = st[0]!;
104
+ if (code === "R" || code === "C") {
105
+ const oldPath = toks[i++];
106
+ const path = toks[i++];
107
+ if (path) out.push({ path, status: "renamed", oldPath });
108
+ } else {
109
+ const path = toks[i++];
110
+ if (!path) break;
111
+ const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified"; // M/T/U fold into modified
112
+ out.push({ path, status });
113
+ }
114
+ }
115
+ }
116
+
117
+ const byPath = new Map(out.map((f) => [f.path, f]));
118
+ const num = sh("git", [...gitArgs(dir), "diff", "-z", "-M", "--numstat", ...rangeArgs(spec)]);
119
+ if (num.ok) {
120
+ const toks = num.stdout.split("\0");
121
+ let i = 0;
122
+ while (i < toks.length) {
123
+ const head = toks[i++];
124
+ if (!head) break;
125
+ const m = head.match(/^(-|\d+)\t(-|\d+)\t([\s\S]*)$/);
126
+ if (!m) continue;
127
+ let path = m[3]!;
128
+ if (path === "") {
129
+ i++; // rename record: skip the old-path token
130
+ path = toks[i++] ?? "";
131
+ }
132
+ const rec = byPath.get(path);
133
+ if (!rec) continue;
134
+ if (m[1] === "-") rec.binary = true;
135
+ else {
136
+ rec.linesAdded = Number(m[1]);
137
+ rec.linesDeleted = Number(m[2]);
138
+ }
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // Changed NEW-side line ranges per file, from one --unified=0 diff call. Pure
145
+ // deletions map to their touch-point line, flagged approx. Files with no
146
+ // content hunks (pure renames, binaries) are absent.
147
+ export function diffHunks(dir: string, spec: DiffSpec): Map<string, Hunk[]> {
148
+ const map = new Map<string, Hunk[]>();
149
+ const res = sh("git", [...gitArgs(dir), "diff", "-M", "--unified=0", ...rangeArgs(spec)]);
150
+ if (!res.ok) return map;
151
+ let current: Hunk[] | undefined;
152
+ for (const line of res.stdout.split("\n")) {
153
+ if (line.startsWith("+++ ")) {
154
+ const p = line.slice(4).trim();
155
+ if (p === "/dev/null") {
156
+ current = undefined;
157
+ continue;
158
+ }
159
+ const path = p.startsWith("b/") ? p.slice(2) : p;
160
+ current = map.get(path) ?? [];
161
+ map.set(path, current);
162
+ } else if (current && line.startsWith("@@")) {
163
+ const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
164
+ if (!m) continue;
165
+ const start = Number(m[1]);
166
+ const count = m[2] === undefined ? 1 : Number(m[2]);
167
+ if (count === 0) current.push({ start: Math.max(start, 1), end: Math.max(start, 1), approx: true });
168
+ else current.push({ start, end: start + count - 1 });
169
+ }
170
+ }
171
+ return map;
172
+ }
173
+
174
+ // Untracked (but not ignored) files — part of "the branch as it sits".
175
+ export function untrackedFiles(dir: string): string[] {
176
+ const res = sh("git", [...gitArgs(dir), "ls-files", "--others", "--exclude-standard", "-z"]);
177
+ if (!res.ok) return [];
178
+ return res.stdout.split("\0").filter((p) => p.length > 0);
179
+ }
180
+
181
+ // Per-file commit counts over the whole history (or since a ref) — the churn
182
+ // half of hotspot analysis. `ok: false` means git was unavailable or the dir
183
+ // isn't a repo: degrade loudly (callers can say "no churn data"), never
184
+ // silently return an empty map that reads as "nothing ever changed".
185
+ export function gitChurn(dir: string, opts: { since?: string } = {}): { churn: Map<string, number>; ok: boolean } {
186
+ const churn = new Map<string, number>();
187
+ const range = opts.since ? [`${opts.since}..HEAD`] : [];
188
+ const res = sh("git", [...gitArgs(dir), "log", ...range, "--pretty=format:", "--name-only", "-z"]);
189
+ if (!res.ok) return { churn, ok: false };
190
+ for (const tok of res.stdout.split("\0")) {
191
+ const f = tok.replace(/^\n+/, "").trim();
192
+ if (f) churn.set(f, (churn.get(f) ?? 0) + 1);
193
+ }
194
+ return { churn, ok: true };
195
+ }
196
+
197
+ // Files changed since a ref (worktree vs ref) plus untracked files — "what did
198
+ // I touch". Returns an empty set outside a git repo.
199
+ export function changedSince(dir: string, ref: string): Set<string> {
200
+ const out = new Set<string>();
201
+ const diff = sh("git", [...gitArgs(dir), "diff", "-z", "--name-only", ref, "--"]);
202
+ if (diff.ok) for (const p of diff.stdout.split("\0")) if (p) out.add(p);
203
+ for (const p of untrackedFiles(dir)) out.add(p);
204
+ return out;
205
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { escapeRegExp } from "./util.js";
2
+
3
+ // Minimal glob → RegExp for --include/--exclude. Supports `**` (any path,
4
+ // crossing `/`), `*` (any run within a segment), and `?` (one non-`/` char).
5
+ // Patterns match against the posix path relative to the repo root. Anything
6
+ // fancier (brace expansion, extglob) is intentionally out of scope — keep it
7
+ // dependency-free and predictable.
8
+ function globToRegExp(glob: string): RegExp {
9
+ let re = "";
10
+ for (let i = 0; i < glob.length; i++) {
11
+ const c = glob[i]!;
12
+ if (c === "*") {
13
+ if (glob[i + 1] === "*") {
14
+ // `**` — match across directory separators.
15
+ i++;
16
+ if (glob[i + 1] === "/") {
17
+ // `a/**/b` should also match `a/b` → the segment is optional.
18
+ i++;
19
+ re += "(?:.*/)?";
20
+ } else {
21
+ // Trailing `**` (e.g. `src/**`) must match everything beneath, files
22
+ // included. `(?:.*/)?` only matches dir-like paths ending in `/`, so a
23
+ // bare trailing `**` would match ZERO files — use `.*` instead.
24
+ re += ".*";
25
+ }
26
+ } else {
27
+ re += "[^/]*";
28
+ }
29
+ } else if (c === "?") {
30
+ re += "[^/]";
31
+ } else {
32
+ re += escapeRegExp(c);
33
+ }
34
+ }
35
+ return new RegExp(`^${re}$`);
36
+ }
37
+
38
+ // Compile a list of globs into a single predicate (matches if ANY glob matches).
39
+ // An empty/undefined list yields `null` so callers can skip the test entirely.
40
+ export function compileGlobs(globs: string[] | undefined): ((rel: string) => boolean) | null {
41
+ if (!globs || globs.length === 0) return null;
42
+ const res = globs.map(globToRegExp);
43
+ return (rel: string) => res.some((r) => r.test(rel));
44
+ }
45
+
46
+ // Negation-aware variant: `!`-prefixed globs EXCLUDE. A path passes when it
47
+ // matches at least one positive glob (or none are given — negations alone
48
+ // mean "everything but") AND matches no negated glob. Exclusion wins over
49
+ // inclusion regardless of list order — grep.ts feeds ripgrep the same way
50
+ // (positives first, negations last) so both backends agree.
51
+ export function compileGlobFilter(globs: string[] | undefined): ((rel: string) => boolean) | null {
52
+ if (!globs || globs.length === 0) return null;
53
+ const include = compileGlobs(globs.filter((g) => !g.startsWith("!")));
54
+ const exclude = compileGlobs(globs.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
55
+ return (rel: string) => (!include || include(rel)) && !exclude?.(rel);
56
+ }
package/src/graph.ts ADDED
Binary file
package/src/grep.ts ADDED
@@ -0,0 +1,115 @@
1
+ // Repo text search: ripgrep when it's on PATH (fast), a pure-JS scan over
2
+ // walk() otherwise (always available). Both backends return the SAME shape,
3
+ // sorted by (file, line) with the cap applied after sorting, so a consumer
4
+ // cannot tell which backend ran — asserted by the backend-parity test.
5
+ import { walk, readText, IGNORE_DIRS, LOCKFILES, BINARY_EXT } from "./walk.js";
6
+ import { compileGlobFilter } from "./glob.js";
7
+ import { sh, have } from "./util.js";
8
+ import { byStr } from "./sort.js";
9
+
10
+ export interface SearchHit {
11
+ file: string; // repo-relative posix path
12
+ line: number; // 1-based
13
+ text: string; // the matching line, trimmed of the trailing newline
14
+ }
15
+
16
+ export interface GrepOptions {
17
+ // Restrict to matching paths (repo-relative, rooted dialect). A `!` prefix
18
+ // NEGATES the glob: `!sub/**` excludes that tree; exclusion beats inclusion
19
+ // regardless of list order, identically on both backends.
20
+ globs?: string[];
21
+ maxHits?: number; // cap AFTER sorting (default 200)
22
+ ignoreCase?: boolean;
23
+ // Force the JS backend even when ripgrep is available (tests, determinism).
24
+ noRipgrep?: boolean;
25
+ }
26
+
27
+ const DEFAULT_MAX_HITS = 200;
28
+
29
+ function sortHits(hits: SearchHit[]): SearchHit[] {
30
+ return hits.sort((a, b) => byStr(a.file, b.file) || a.line - b.line);
31
+ }
32
+
33
+ function rgBackend(root: string, pattern: string, opts: GrepOptions): SearchHit[] | undefined {
34
+ // Align ripgrep's universe with walk()'s exactly: search hidden files (the
35
+ // walker does), honor .gitignore even outside a git worktree, but NOT the
36
+ // user/global/exclude/parent/.ignore layers the walker never reads; skip the
37
+ // same junk dirs, lockfiles and binary extensions; same 1 MiB size cap.
38
+ const args = [
39
+ "--no-heading",
40
+ "--line-number",
41
+ "--null", // path\0line:text — a `:12:` inside a filename can't corrupt parsing
42
+ "--color=never",
43
+ "--no-messages",
44
+ "--hidden",
45
+ "--no-require-git",
46
+ "--no-ignore-global",
47
+ "--no-ignore-exclude",
48
+ "--no-ignore-parent",
49
+ "--no-ignore-dot",
50
+ "--max-filesize",
51
+ "1M",
52
+ ];
53
+ for (const d of IGNORE_DIRS) args.push("--glob", `!**/${d}/**`);
54
+ // Lockfiles match at any depth, case-insensitively (the walker lowercases).
55
+ for (const l of LOCKFILES) args.push("--iglob", `!**/${l}`);
56
+ for (const ext of BINARY_EXT) args.push("--iglob", `!**/*${ext}`);
57
+ args.push("--glob", "!*.min.js", "--glob", "!*.min.css");
58
+ if (opts.ignoreCase) args.push("--ignore-case");
59
+ // User globs use the engine's rooted dialect (compileGlobFilter anchors at
60
+ // the repo root); a leading `/` makes rg anchor the same way instead of
61
+ // basename-matching slash-less patterns. Positives go first and `!`-negated
62
+ // globs LAST: rg resolves overlapping globs by last-match-wins, so this
63
+ // ordering makes exclusion beat inclusion — the JS backend's semantics —
64
+ // regardless of how the caller ordered the list.
65
+ const user = opts.globs ?? [];
66
+ const anchor = (g: string): string => (g.startsWith("/") ? g : `/${g}`);
67
+ for (const g of user.filter((g) => !g.startsWith("!"))) args.push("--glob", anchor(g));
68
+ for (const g of user.filter((g) => g.startsWith("!"))) args.push("--glob", `!${anchor(g.slice(1))}`);
69
+ args.push("--regexp", pattern, "./");
70
+ const res = sh("rg", args, { cwd: root });
71
+ // status 1 = no matches (fine); anything else (crash, unsupported flag on an
72
+ // old rg) → let the JS backend give the authoritative answer instead.
73
+ if (res.missing || (!res.ok && res.status !== 1)) return undefined;
74
+ const hits: SearchHit[] = [];
75
+ for (const line of res.stdout.split("\n")) {
76
+ if (!line) continue;
77
+ const nul = line.indexOf("\0");
78
+ if (nul === -1) continue;
79
+ const file = line.slice(0, nul).replace(/^\.\//, "");
80
+ const rest = line.slice(nul + 1);
81
+ const colon = rest.indexOf(":");
82
+ if (colon === -1) continue;
83
+ hits.push({ file, line: Number(rest.slice(0, colon)), text: rest.slice(colon + 1) });
84
+ }
85
+ return hits;
86
+ }
87
+
88
+ function jsBackend(root: string, re: RegExp, opts: GrepOptions): SearchHit[] {
89
+ // Accept the same optionally-`/`-anchored spelling the rg path takes (the
90
+ // anchor may follow the `!` of a negated glob: `!/sub/**` ≡ `!sub/**`).
91
+ const filter = compileGlobFilter(opts.globs?.map((g) => g.replace(/^(!?)\//, "$1")));
92
+ const hits: SearchHit[] = [];
93
+ for (const f of walk(root).files) {
94
+ if (filter && !filter(f.rel)) continue;
95
+ const content = readText(f.abs);
96
+ if (!content) continue;
97
+ const lines = content.split("\n");
98
+ for (let i = 0; i < lines.length; i++) {
99
+ if (re.test(lines[i]!)) hits.push({ file: f.rel, line: i + 1, text: lines[i]! });
100
+ }
101
+ }
102
+ return hits;
103
+ }
104
+
105
+ export function grepRepo(root: string, pattern: string, opts: GrepOptions = {}): SearchHit[] {
106
+ // The pattern dialect is JS RegExp on BOTH backends: validate up front and
107
+ // throw the same error regardless of which backend runs, instead of one
108
+ // backend silently returning [] on syntax the other accepts.
109
+ const re = new RegExp(pattern, opts.ignoreCase ? "i" : "");
110
+ const max = opts.maxHits ?? DEFAULT_MAX_HITS;
111
+ let hits: SearchHit[] | undefined;
112
+ if (!opts.noRipgrep && have("rg")) hits = rgBackend(root, pattern, opts);
113
+ hits ??= jsBackend(root, re, opts);
114
+ return sortHits(hits).slice(0, max);
115
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ // Stable content hash used for the manifest's staleness oracle and for the
4
+ // `ui:gen` region fingerprints. sha1 is plenty for change detection (not
5
+ // security) and keeps the manifest compact.
6
+ export function sha1(s: string): string {
7
+ return createHash("sha1").update(s).digest("hex");
8
+ }
9
+
10
+ // A short fingerprint for embedding in region fences without bloating the file.
11
+ export function shortHash(s: string, n = 8): string {
12
+ return sha1(s).slice(0, n);
13
+ }
package/src/ignore.ts ADDED
@@ -0,0 +1,134 @@
1
+ // .gitignore support for the walker. Semantics follow git's core rules: per-
2
+ // directory files apply to their subtree, later rules win, `!` negates, a
3
+ // trailing `/` restricts to directories, a `/` anywhere else anchors the
4
+ // pattern to the .gitignore's own directory, `*`/`**`/`?` glob (never crossing
5
+ // `/` except `**`), `[...]` character classes, and `\x` fnmatch escapes.
6
+ // Verified by differential testing against `git check-ignore`. Two deliberate
7
+ // deviations: (1) once a directory is ignored the walk never descends into it,
8
+ // so a negation cannot re-include a file inside it — git behaves the same;
9
+ // (2) matching is ALWAYS case-sensitive (gitignore(5) semantics) even where
10
+ // git's platform default is core.ignorecase=true — a platform-dependent match
11
+ // would break cross-machine byte-identical builds.
12
+ import { escapeRegExp } from "./util.js";
13
+
14
+ export interface IgnoreRule {
15
+ re: RegExp; // tested against the path RELATIVE TO THE REPO ROOT (posix)
16
+ negated: boolean;
17
+ dirOnly: boolean;
18
+ }
19
+
20
+ // Compile one gitignore pattern segment-wise. Differs from glob.ts: `**` here
21
+ // follows gitignore's spec (`**/` leading, `/**` trailing, `/**/` mid-pattern),
22
+ // `\x` escapes the next character (fnmatch), and `[...]` character classes are
23
+ // supported (`*.py[cod]`, `[Tt]humbs.db`).
24
+ function patternToRegExpSource(pattern: string): string {
25
+ let re = "";
26
+ for (let i = 0; i < pattern.length; i++) {
27
+ const c = pattern[i]!;
28
+ if (c === "\\" && i + 1 < pattern.length) {
29
+ // fnmatch escaping: the next character is literal (`\*`, `\?`, `\ `, `\\`).
30
+ re += escapeRegExp(pattern[++i]!);
31
+ } else if (c === "*") {
32
+ if (pattern[i + 1] === "*") {
33
+ // `**` is special only at segment boundaries (`**/`, `/**`, `/**/`,
34
+ // or the whole pattern); anywhere else — `a**b` — git treats each
35
+ // star as a regular single-segment `*`.
36
+ const atStart = i === 0 || pattern[i - 1] === "/";
37
+ let j = i;
38
+ while (pattern[j + 1] === "*") j++;
39
+ const next = pattern[j + 1];
40
+ if (atStart && next === "/") {
41
+ i = j + 1;
42
+ re += "(?:[^/]+/)*"; // `**/` — zero or more whole segments
43
+ } else if (atStart && next === undefined) {
44
+ i = j;
45
+ re += ".*"; // trailing `/**` or bare `**` — anything, crossing `/`
46
+ } else {
47
+ i = j;
48
+ re += "[^/]*"; // mid-segment run of stars — one segment, like `*`
49
+ }
50
+ } else {
51
+ re += "[^/]*";
52
+ }
53
+ } else if (c === "?") {
54
+ re += "[^/]";
55
+ } else if (c === "[") {
56
+ // Character class: consume up to the closing `]` (a leading `]` is
57
+ // literal, `!` negates). Falls back to a literal `[` when unclosed.
58
+ let j = i + 1;
59
+ let body = "";
60
+ if (pattern[j] === "!") {
61
+ body += "^";
62
+ j++;
63
+ }
64
+ if (pattern[j] === "]") {
65
+ body += "\\]";
66
+ j++;
67
+ }
68
+ while (j < pattern.length && pattern[j] !== "]") {
69
+ const ch = pattern[j]!;
70
+ body += ch === "\\" || ch === "^" ? "\\" + ch : ch;
71
+ j++;
72
+ }
73
+ if (j < pattern.length && body !== "" && body !== "^") {
74
+ re += `[${body}]`;
75
+ i = j;
76
+ } else {
77
+ re += "\\[";
78
+ }
79
+ } else {
80
+ re += escapeRegExp(c);
81
+ }
82
+ }
83
+ return re;
84
+ }
85
+
86
+ // Parse one .gitignore file. `baseRel` is the directory holding the file,
87
+ // relative to the repo root ("" for the root .gitignore), posix-style.
88
+ export function parseGitignore(content: string, baseRel: string): IgnoreRule[] {
89
+ const rules: IgnoreRule[] = [];
90
+ const prefix = baseRel ? escapeRegExp(baseRel) + "/" : "";
91
+ for (const rawLine of content.split(/\r?\n/)) {
92
+ // Trailing SPACES are ignored unless backslash-escaped (git trims only
93
+ // 0x20 — a trailing tab is significant). Blank lines and comments carry no
94
+ // rule. Escapes (`\ `, `\*`, `\#`…) are consumed by the pattern compiler.
95
+ let line = rawLine.replace(/(?<!\\) +$/, "");
96
+ if (!line || line.startsWith("#")) continue;
97
+ let negated = false;
98
+ if (line.startsWith("!")) {
99
+ negated = true;
100
+ line = line.slice(1);
101
+ }
102
+ let dirOnly = false;
103
+ if (line.endsWith("/")) {
104
+ dirOnly = true;
105
+ line = line.slice(0, -1);
106
+ }
107
+ if (!line) continue;
108
+ // A slash anywhere (leading or interior) anchors the pattern to the base
109
+ // directory; otherwise it floats to any depth beneath it.
110
+ const anchored = line.includes("/");
111
+ if (line.startsWith("/")) line = line.slice(1);
112
+ const body = patternToRegExpSource(line);
113
+ const source = anchored ? `^${prefix}${body}$` : `^${prefix}(?:[^/]+/)*${body}$`;
114
+ try {
115
+ rules.push({ re: new RegExp(source), negated, dirOnly });
116
+ } catch {
117
+ // An unparsable pattern is dropped rather than crashing the walk.
118
+ }
119
+ }
120
+ return rules;
121
+ }
122
+
123
+ // Decide whether `rel` (posix, repo-root-relative) is ignored under an ordered
124
+ // rule chain (root rules first, deeper .gitignore rules appended after — which
125
+ // realizes "later rules win" across nesting levels too). Returns the verdict of
126
+ // the LAST matching rule, or false when none match.
127
+ export function isIgnored(rules: readonly IgnoreRule[], rel: string, isDir: boolean): boolean {
128
+ let ignored = false;
129
+ for (const rule of rules) {
130
+ if (rule.dirOnly && !isDir) continue;
131
+ if (rule.re.test(rel)) ignored = !rule.negated;
132
+ }
133
+ return ignored;
134
+ }
package/src/lang/c.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { CodeSymbol } from "../types.js";
2
+ import { scan, type Rule } from "./common.js";
3
+
4
+ // C / C++. Regex heuristics: function definitions (a return type then `name(…)`
5
+ // with the line ending in an opening brace or nothing — not a `;` prototype or a
6
+ // control statement), plus type declarations. Conservative to avoid matching
7
+ // calls and macros.
8
+ const NOT_KEYWORD = "(?!\\s*(?:if|for|while|switch|return|else|do|sizeof|typedef)\\b)";
9
+
10
+ const RULES: Rule[] = [
11
+ // C++ types
12
+ { re: /^\s*(?:class|struct)\s+(?<name>[A-Za-z_]\w+)\s*(?:[:{]|$)/, kind: "class", exported: true },
13
+ { re: /^\s*namespace\s+(?<name>[A-Za-z_]\w+)/, kind: "namespace", exported: true },
14
+ // typedef struct/enum/union NAME {
15
+ { re: /^\s*(?:typedef\s+)?(?:struct|enum|union)\s+(?<name>[A-Za-z_]\w+)\s*\{/, kind: "struct", exported: true },
16
+ // function definition: <type ...> name(<args>) [const] {? at column 0-ish
17
+ { re: new RegExp(`^${NOT_KEYWORD}[A-Za-z_][\\w\\s\\*&<>:,]*?\\b(?<name>[A-Za-z_]\\w+)\\s*\\([^;{]*\\)\\s*(?:const)?\\s*\\{?\\s*$`), kind: "function", exported: true },
18
+ ];
19
+
20
+ export const c = {
21
+ lang: "c/cpp",
22
+ exts: [".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"],
23
+ extract(rel: string, content: string): CodeSymbol[] {
24
+ return scan(rel, content, rel.match(/\.(c|h)$/) ? "c" : "cpp", RULES);
25
+ },
26
+ };
@@ -0,0 +1,68 @@
1
+ import type { CodeSymbol } from "../types.js";
2
+
3
+ // A line-level extraction rule. `re` must capture the symbol name in a named
4
+ // group `name` (or capture group 1). One symbol is emitted per matching line
5
+ // (first rule wins), which keeps the heuristics cheap and predictable.
6
+ export interface Rule {
7
+ re: RegExp;
8
+ kind: string;
9
+ exported?: boolean | ((m: RegExpExecArray, line: string) => boolean);
10
+ }
11
+
12
+ // Run a list of rules line-by-line over file content. Deterministic and
13
+ // zero-dep — no parser, no AST, no LLM. Good enough to locate declarations and
14
+ // rank them; ripgrep covers everything inside bodies.
15
+ export function scan(rel: string, content: string, lang: string, rules: Rule[]): CodeSymbol[] {
16
+ const out: CodeSymbol[] = [];
17
+ const lines = content.split(/\r?\n/);
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const line = lines[i]!;
20
+ if (!line.trim()) continue;
21
+ for (const rule of rules) {
22
+ const m = rule.re.exec(line);
23
+ if (!m) continue;
24
+ const name = m.groups?.name ?? m[1];
25
+ if (!name) continue;
26
+ const exported =
27
+ typeof rule.exported === "function" ? rule.exported(m, line) : rule.exported ?? false;
28
+ out.push({
29
+ name,
30
+ kind: rule.kind,
31
+ file: rel,
32
+ line: i + 1,
33
+ signature: line.trim().slice(0, 200),
34
+ exported,
35
+ lang,
36
+ });
37
+ break;
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // Broad extension → language label table, used for the index's language
44
+ // histogram even when no symbol extractor exists for that language.
45
+ const EXT_LANG: Record<string, string> = {
46
+ ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
47
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
48
+ ".py": "python", ".pyi": "python",
49
+ ".go": "go",
50
+ ".rb": "ruby", ".rake": "ruby",
51
+ ".java": "java",
52
+ ".rs": "rust",
53
+ ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp",
54
+ ".cs": "csharp", ".php": "php", ".swift": "swift", ".kt": "kotlin", ".kts": "kotlin",
55
+ ".scala": "scala", ".sc": "scala", ".clj": "clojure", ".ex": "elixir", ".exs": "elixir", ".erl": "erlang",
56
+ ".hs": "haskell", ".dart": "dart", ".lua": "lua",
57
+ ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".ksh": "shell", ".fish": "shell",
58
+ ".hh": "cpp", ".m": "objective-c", ".mm": "objective-c",
59
+ ".sql": "sql", ".graphql": "graphql", ".gql": "graphql", ".proto": "protobuf",
60
+ ".md": "markdown", ".mdx": "markdown", ".rst": "restructuredtext", ".txt": "text",
61
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
62
+ ".html": "html", ".css": "css", ".scss": "scss", ".vue": "vue", ".svelte": "svelte",
63
+ ".astro": "astro",
64
+ };
65
+
66
+ export function extToLang(ext: string): string {
67
+ return EXT_LANG[ext] ?? "other";
68
+ }
@@ -0,0 +1,22 @@
1
+ import type { CodeSymbol } from "../types.js";
2
+ import { scan, type Rule } from "./common.js";
3
+
4
+ // C#. `public`/`internal` mark the public surface. Covers types and methods.
5
+ const pub = (_m: RegExpExecArray, l: string) => /\b(public|internal)\b/.test(l);
6
+
7
+ const RULES: Rule[] = [
8
+ { re: /^\s*(?:public|internal|protected|private)?\s*(?:static\s+|sealed\s+|abstract\s+|partial\s+)*(?:class|record)\s+(?<name>\w+)/, kind: "class", exported: pub },
9
+ { re: /^\s*(?:public|internal|protected|private)?\s*(?:partial\s+)?interface\s+(?<name>\w+)/, kind: "interface", exported: pub },
10
+ { re: /^\s*(?:public|internal|protected|private)?\s*(?:readonly\s+)?(?:ref\s+)?struct\s+(?<name>\w+)/, kind: "struct", exported: pub },
11
+ { re: /^\s*(?:public|internal|protected|private)?\s*enum\s+(?<name>\w+)/, kind: "enum", exported: pub },
12
+ // method: a visibility modifier, a return type, then `name(`
13
+ { re: /^\s*(?:public|internal|protected|private)\s+(?:static\s+|virtual\s+|override\s+|async\s+|sealed\s+|abstract\s+|new\s+)*[\w<>\[\],.?]+\s+(?<name>\w+)\s*(?:<[^>]*>)?\s*\(/, kind: "method", exported: pub },
14
+ ];
15
+
16
+ export const csharp = {
17
+ lang: "csharp",
18
+ exts: [".cs"],
19
+ extract(rel: string, content: string): CodeSymbol[] {
20
+ return scan(rel, content, "csharp", RULES);
21
+ },
22
+ };
@@ -0,0 +1,18 @@
1
+ import type { CodeSymbol } from "../types.js";
2
+ import { scan, type Rule } from "./common.js";
3
+
4
+ // Elixir. Modules and def/defp/defmacro. `defp` is private (not exported).
5
+ const RULES: Rule[] = [
6
+ { re: /^\s*defmodule\s+(?<name>[\w.]+)/, kind: "module", exported: true },
7
+ { re: /^\s*defp\s+(?<name>[\w?!]+)/, kind: "function", exported: false },
8
+ { re: /^\s*def\s+(?<name>[\w?!]+)/, kind: "function", exported: true },
9
+ { re: /^\s*defmacrop?\s+(?<name>[\w?!]+)/, kind: "macro", exported: true },
10
+ ];
11
+
12
+ export const elixir = {
13
+ lang: "elixir",
14
+ exts: [".ex", ".exs"],
15
+ extract(rel: string, content: string): CodeSymbol[] {
16
+ return scan(rel, content, "elixir", RULES);
17
+ },
18
+ };