all-hands-cli 0.1.15 → 0.1.16
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/.allhands/harness/src/commands/complexity.ts +6 -74
- package/.allhands/harness/src/commands/docs.ts +4 -48
- package/.allhands/harness/src/lib/docs-validation.ts +11 -127
- package/package.json +1 -1
- package/.allhands/harness/src/lib/__tests__/ctags.test.ts +0 -244
- package/.allhands/harness/src/lib/ctags.ts +0 -497
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Complexity command - Get complexity metrics for files or directories.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Provides line counts and estimated token counts for source files.
|
|
5
5
|
*
|
|
6
6
|
* Command:
|
|
7
7
|
* ah complexity <path> - Get complexity metrics
|
|
@@ -17,15 +17,11 @@ import {
|
|
|
17
17
|
CommandResult,
|
|
18
18
|
} from "../lib/base-command.js";
|
|
19
19
|
import { getProjectRoot } from "../lib/git.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
generateCtagsIndex,
|
|
23
|
-
getFileSymbols,
|
|
24
|
-
} from "../lib/ctags.js";
|
|
20
|
+
|
|
21
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".swift", ".rb", ".kt"];
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
24
|
* Get complexity metrics for a file or directory.
|
|
28
|
-
* Uses ctags to count symbols instead of AST parsing.
|
|
29
25
|
*/
|
|
30
26
|
async function complexity(pathArg: string): Promise<CommandResult> {
|
|
31
27
|
const projectRoot = getProjectRoot();
|
|
@@ -41,70 +37,27 @@ async function complexity(pathArg: string): Promise<CommandResult> {
|
|
|
41
37
|
};
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
// Check ctags availability
|
|
45
|
-
const ctagsCheck = checkCtagsAvailable();
|
|
46
|
-
if (!ctagsCheck.available) {
|
|
47
|
-
return {
|
|
48
|
-
success: false,
|
|
49
|
-
error: `ctags_unavailable: ${ctagsCheck.error}`,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
const stat = statSync(absolutePath);
|
|
54
41
|
|
|
55
42
|
if (stat.isFile()) {
|
|
56
|
-
// Single file complexity
|
|
57
43
|
const content = readFileSync(absolutePath, "utf-8");
|
|
58
44
|
const lines = content.split("\n").length;
|
|
59
45
|
|
|
60
|
-
// Get symbols via ctags
|
|
61
|
-
const { index } = generateCtagsIndex(projectRoot, { target: relativePath });
|
|
62
|
-
const symbols = getFileSymbols(index, relativePath);
|
|
63
|
-
|
|
64
|
-
const functions = symbols.filter(
|
|
65
|
-
(s) => s.kind === "function" || s.kind === "method"
|
|
66
|
-
).length;
|
|
67
|
-
const classes = symbols.filter((s) => s.kind === "class").length;
|
|
68
|
-
const interfaces = symbols.filter(
|
|
69
|
-
(s) => s.kind === "interface" || s.kind === "type"
|
|
70
|
-
).length;
|
|
71
|
-
|
|
72
|
-
// Count imports/exports with regex (simple heuristic)
|
|
73
|
-
const importMatches = content.match(/^import\s/gm);
|
|
74
|
-
const exportMatches = content.match(/^export\s/gm);
|
|
75
|
-
|
|
76
46
|
return {
|
|
77
47
|
success: true,
|
|
78
48
|
data: {
|
|
79
49
|
path: relativePath,
|
|
80
50
|
type: "file",
|
|
81
|
-
metrics: {
|
|
82
|
-
lines,
|
|
83
|
-
functions,
|
|
84
|
-
classes,
|
|
85
|
-
interfaces,
|
|
86
|
-
imports: importMatches?.length || 0,
|
|
87
|
-
exports: exportMatches?.length || 0,
|
|
88
|
-
total_symbols: symbols.length,
|
|
89
|
-
},
|
|
51
|
+
metrics: { lines },
|
|
90
52
|
estimated_tokens: Math.ceil(lines * 10),
|
|
91
53
|
},
|
|
92
54
|
};
|
|
93
55
|
}
|
|
94
56
|
|
|
95
57
|
// Directory complexity
|
|
96
|
-
const { index, entryCount } = generateCtagsIndex(projectRoot, {
|
|
97
|
-
target: relativePath,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// Aggregate stats
|
|
101
58
|
let totalLines = 0;
|
|
102
|
-
let totalFunctions = 0;
|
|
103
|
-
let totalClasses = 0;
|
|
104
|
-
let totalInterfaces = 0;
|
|
105
59
|
let fileCount = 0;
|
|
106
60
|
|
|
107
|
-
// Find source files and count lines
|
|
108
61
|
const countDir = (dir: string): void => {
|
|
109
62
|
const entries = readdirSync(dir);
|
|
110
63
|
for (const entry of entries) {
|
|
@@ -115,7 +68,7 @@ async function complexity(pathArg: string): Promise<CommandResult> {
|
|
|
115
68
|
countDir(fullPath);
|
|
116
69
|
} else {
|
|
117
70
|
const ext = extname(entry);
|
|
118
|
-
if (
|
|
71
|
+
if (SOURCE_EXTENSIONS.includes(ext)) {
|
|
119
72
|
fileCount++;
|
|
120
73
|
const content = readFileSync(fullPath, "utf-8");
|
|
121
74
|
totalLines += content.split("\n").length;
|
|
@@ -126,34 +79,13 @@ async function complexity(pathArg: string): Promise<CommandResult> {
|
|
|
126
79
|
|
|
127
80
|
countDir(absolutePath);
|
|
128
81
|
|
|
129
|
-
// Count symbol types from index
|
|
130
|
-
for (const fileMap of Array.from(index.values())) {
|
|
131
|
-
for (const entries of Array.from(fileMap.values())) {
|
|
132
|
-
for (const entry of entries) {
|
|
133
|
-
if (entry.kind === "function" || entry.kind === "method") {
|
|
134
|
-
totalFunctions++;
|
|
135
|
-
} else if (entry.kind === "class") {
|
|
136
|
-
totalClasses++;
|
|
137
|
-
} else if (entry.kind === "interface" || entry.kind === "type") {
|
|
138
|
-
totalInterfaces++;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
82
|
return {
|
|
145
83
|
success: true,
|
|
146
84
|
data: {
|
|
147
85
|
path: relativePath,
|
|
148
86
|
type: "directory",
|
|
149
87
|
file_count: fileCount,
|
|
150
|
-
metrics: {
|
|
151
|
-
lines: totalLines,
|
|
152
|
-
functions: totalFunctions,
|
|
153
|
-
classes: totalClasses,
|
|
154
|
-
interfaces: totalInterfaces,
|
|
155
|
-
total_symbols: entryCount,
|
|
156
|
-
},
|
|
88
|
+
metrics: { lines: totalLines },
|
|
157
89
|
estimated_tokens: Math.ceil(totalLines * 10),
|
|
158
90
|
},
|
|
159
91
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Documentation commands - validation and reference finalization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Symbols in refs are author-provided labels. Staleness is detected via
|
|
5
|
+
* file-level git blob hashes.
|
|
6
6
|
*
|
|
7
7
|
* Commands:
|
|
8
8
|
* ah docs validate [--path <path>] - Validate all refs in docs/
|
|
@@ -19,15 +19,10 @@ import {
|
|
|
19
19
|
executeCommand,
|
|
20
20
|
parseContext,
|
|
21
21
|
} from "../lib/base-command.js";
|
|
22
|
-
import {
|
|
23
|
-
checkCtagsAvailable,
|
|
24
|
-
findSymbolInFile,
|
|
25
|
-
generateCtagsIndex,
|
|
26
|
-
} from "../lib/ctags.js";
|
|
27
22
|
import {
|
|
28
23
|
batchGetBlobHashes,
|
|
29
24
|
findMarkdownFiles,
|
|
30
|
-
|
|
25
|
+
REF_PATTERN,
|
|
31
26
|
validateDocsAsync,
|
|
32
27
|
} from "../lib/docs-validation.js";
|
|
33
28
|
import { getProjectRoot } from "../lib/git.js";
|
|
@@ -44,15 +39,6 @@ async function validate(docsPath: string, options?: { useCache?: boolean }): Pro
|
|
|
44
39
|
? docsPath
|
|
45
40
|
: join(projectRoot, docsPath);
|
|
46
41
|
|
|
47
|
-
// Check ctags availability first
|
|
48
|
-
const ctagsCheck = checkCtagsAvailable();
|
|
49
|
-
if (!ctagsCheck.available) {
|
|
50
|
-
return {
|
|
51
|
-
success: false,
|
|
52
|
-
error: `ctags_unavailable: ${ctagsCheck.error}`,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
42
|
const excludePaths = EXCLUDED_DOC_PATHS.map((p) => join(projectRoot, p));
|
|
57
43
|
|
|
58
44
|
// Run validation (with optional caching)
|
|
@@ -261,25 +247,7 @@ function finalizeSingleFile(
|
|
|
261
247
|
continue;
|
|
262
248
|
}
|
|
263
249
|
|
|
264
|
-
//
|
|
265
|
-
if (!isCodeFile(placeholder.file)) {
|
|
266
|
-
const fullRef = `[ref:${placeholder.file}:${placeholder.symbol}:${hashResult.hash}]`;
|
|
267
|
-
finalizedContent = finalizedContent.replaceAll(placeholder.match, fullRef);
|
|
268
|
-
replacements.push({ from: placeholder.match, to: fullRef });
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// For code files with symbol refs, verify symbol exists via ctags
|
|
273
|
-
const entry = findSymbolInFile(absoluteFilePath, placeholder.symbol, projectRoot);
|
|
274
|
-
if (!entry) {
|
|
275
|
-
errors.push({
|
|
276
|
-
placeholder: placeholder.match,
|
|
277
|
-
reason: `Symbol '${placeholder.symbol}' not found in ${placeholder.file}`,
|
|
278
|
-
});
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Create full ref
|
|
250
|
+
// Symbol is an author-provided label — produce the full ref with hash directly
|
|
283
251
|
const fullRef = `[ref:${placeholder.file}:${placeholder.symbol}:${hashResult.hash}]`;
|
|
284
252
|
finalizedContent = finalizedContent.replaceAll(placeholder.match, fullRef);
|
|
285
253
|
replacements.push({ from: placeholder.match, to: fullRef });
|
|
@@ -371,18 +339,6 @@ async function finalize(docsPath: string, options?: { refresh?: boolean }): Prom
|
|
|
371
339
|
};
|
|
372
340
|
}
|
|
373
341
|
|
|
374
|
-
// Check ctags availability
|
|
375
|
-
const ctagsCheck = checkCtagsAvailable();
|
|
376
|
-
if (!ctagsCheck.available) {
|
|
377
|
-
return {
|
|
378
|
-
success: false,
|
|
379
|
-
error: `ctags_unavailable: ${ctagsCheck.error}`,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Generate ctags index for symbol lookup
|
|
384
|
-
generateCtagsIndex(projectRoot);
|
|
385
|
-
|
|
386
342
|
// Determine if path is a file or directory
|
|
387
343
|
const excludePaths = EXCLUDED_DOC_PATHS.map((p) => join(projectRoot, p));
|
|
388
344
|
const stat = statSync(absolutePath);
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Documentation validation utilities.
|
|
3
3
|
*
|
|
4
4
|
* Validates references in documentation files against the actual codebase
|
|
5
|
-
* using
|
|
5
|
+
* using git blob hashes for staleness detection.
|
|
6
6
|
*
|
|
7
7
|
* Reference formats:
|
|
8
|
-
* [ref:file:symbol:hash] - Symbol reference (
|
|
9
|
-
* [ref:file::hash] - File-only reference
|
|
8
|
+
* [ref:file:symbol:hash] - Symbol reference (symbol is an author-provided label)
|
|
9
|
+
* [ref:file::hash] - File-only reference
|
|
10
10
|
*
|
|
11
11
|
* Where hash = git blob hash (content-addressable, stable across merges/rebases)
|
|
12
12
|
*/
|
|
@@ -14,40 +14,8 @@
|
|
|
14
14
|
import { spawnSync } from "child_process";
|
|
15
15
|
import { createHash } from "crypto";
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
17
|
-
import {
|
|
17
|
+
import { join, relative } from "path";
|
|
18
18
|
import matter from "gray-matter";
|
|
19
|
-
import { CtagsIndex, generateCtagsIndex, generateCtagsIndexAsync, lookupSymbol } from "./ctags.js";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* File extensions that ctags can process (programming languages).
|
|
23
|
-
* Files with other extensions are treated as non-code where symbols are just labels.
|
|
24
|
-
*/
|
|
25
|
-
const CODE_EXTENSIONS = new Set([
|
|
26
|
-
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", // TypeScript/JavaScript
|
|
27
|
-
".py", ".pyw", // Python
|
|
28
|
-
".go", // Go
|
|
29
|
-
".rs", // Rust
|
|
30
|
-
".java", // Java
|
|
31
|
-
".rb", // Ruby
|
|
32
|
-
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", // C/C++
|
|
33
|
-
".cs", // C#
|
|
34
|
-
".php", // PHP
|
|
35
|
-
".kt", ".kts", // Kotlin
|
|
36
|
-
".swift", // Swift
|
|
37
|
-
".scala", // Scala
|
|
38
|
-
".lua", // Lua
|
|
39
|
-
".sh", ".bash", ".zsh", // Shell scripts
|
|
40
|
-
".vim", // Vim script
|
|
41
|
-
".el", // Emacs Lisp
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Check if a file is a code file that ctags can process.
|
|
46
|
-
*/
|
|
47
|
-
export function isCodeFile(filePath: string): boolean {
|
|
48
|
-
const ext = extname(filePath).toLowerCase();
|
|
49
|
-
return CODE_EXTENSIONS.has(ext);
|
|
50
|
-
}
|
|
51
19
|
|
|
52
20
|
/**
|
|
53
21
|
* Validation cache for faster repeated validation runs.
|
|
@@ -484,13 +452,11 @@ export function findMarkdownFiles(dir: string, excludeReadme = false, excludePat
|
|
|
484
452
|
/**
|
|
485
453
|
* Validate a single reference.
|
|
486
454
|
* @param ref - The parsed reference to validate
|
|
487
|
-
* @param ctagsIndex - The ctags index for symbol lookup
|
|
488
455
|
* @param projectRoot - The project root directory
|
|
489
456
|
* @param hashCache - Optional pre-fetched hash map for performance
|
|
490
457
|
*/
|
|
491
458
|
export function validateRef(
|
|
492
459
|
ref: ParsedRef,
|
|
493
|
-
ctagsIndex: CtagsIndex,
|
|
494
460
|
projectRoot: string,
|
|
495
461
|
hashCache?: Map<string, { hash: string; success: boolean }>
|
|
496
462
|
): ValidatedRef {
|
|
@@ -525,49 +491,12 @@ export function validateRef(
|
|
|
525
491
|
};
|
|
526
492
|
}
|
|
527
493
|
|
|
528
|
-
//
|
|
529
|
-
if (ref.isFileOnly) {
|
|
530
|
-
if (currentHash !== ref.hash) {
|
|
531
|
-
return {
|
|
532
|
-
...ref,
|
|
533
|
-
state: "stale",
|
|
534
|
-
reason: "File has been modified",
|
|
535
|
-
currentHash,
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
return { ...ref, state: "valid" };
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Non-code files (markdown, yaml, json, etc.): treat symbol as label, just check hash
|
|
542
|
-
if (!isCodeFile(ref.file)) {
|
|
543
|
-
if (currentHash !== ref.hash) {
|
|
544
|
-
return {
|
|
545
|
-
...ref,
|
|
546
|
-
state: "stale",
|
|
547
|
-
reason: "File has been modified",
|
|
548
|
-
currentHash,
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
return { ...ref, state: "valid" };
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Code file symbol reference: check symbol exists via ctags
|
|
555
|
-
const entries = lookupSymbol(ctagsIndex, ref.file, ref.symbol!);
|
|
556
|
-
|
|
557
|
-
if (entries.length === 0) {
|
|
558
|
-
return {
|
|
559
|
-
...ref,
|
|
560
|
-
state: "invalid",
|
|
561
|
-
reason: `Symbol '${ref.symbol}' not found in ${ref.file}`,
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Check hash staleness (using file-level hash for ctags approach)
|
|
494
|
+
// Check hash staleness (symbols are author-provided labels, not verified)
|
|
566
495
|
if (currentHash !== ref.hash) {
|
|
567
496
|
return {
|
|
568
497
|
...ref,
|
|
569
498
|
state: "stale",
|
|
570
|
-
reason: "File has been modified
|
|
499
|
+
reason: "File has been modified",
|
|
571
500
|
currentHash,
|
|
572
501
|
};
|
|
573
502
|
}
|
|
@@ -581,7 +510,7 @@ export function validateRef(
|
|
|
581
510
|
export function validateDocs(
|
|
582
511
|
docsPath: string,
|
|
583
512
|
projectRoot: string,
|
|
584
|
-
options?: {
|
|
513
|
+
options?: { useCache?: boolean; excludePaths?: string[] }
|
|
585
514
|
): ValidationResult {
|
|
586
515
|
// Initialize result
|
|
587
516
|
const result: ValidationResult = {
|
|
@@ -627,11 +556,6 @@ export function validateDocs(
|
|
|
627
556
|
refFileHashes: {},
|
|
628
557
|
};
|
|
629
558
|
|
|
630
|
-
// Generate ctags index (or use provided one)
|
|
631
|
-
const ctagsIndex =
|
|
632
|
-
options?.ctagsIndex ||
|
|
633
|
-
generateCtagsIndex(projectRoot).index;
|
|
634
|
-
|
|
635
559
|
// Helper to get/create doc file entry
|
|
636
560
|
const getDocEntry = (docFile: string): DocFileIssues => {
|
|
637
561
|
if (!result.by_doc_file[docFile]) {
|
|
@@ -759,7 +683,7 @@ export function validateDocs(
|
|
|
759
683
|
|
|
760
684
|
// Validate each reference (using cached hashes)
|
|
761
685
|
for (const ref of allRefs) {
|
|
762
|
-
const validated = validateRef(ref,
|
|
686
|
+
const validated = validateRef(ref, projectRoot, hashCache);
|
|
763
687
|
|
|
764
688
|
if (validated.state === "valid") {
|
|
765
689
|
result.valid_count++;
|
|
@@ -856,52 +780,12 @@ export function validateDocs(
|
|
|
856
780
|
|
|
857
781
|
/**
|
|
858
782
|
* Validate all documentation in a directory (async version).
|
|
859
|
-
*
|
|
860
|
-
* Identical to validateDocs but uses generateCtagsIndexAsync to avoid
|
|
861
|
-
* blocking the event loop during ctags execution.
|
|
783
|
+
* Kept async for API compatibility. Delegates directly to validateDocs.
|
|
862
784
|
*/
|
|
863
785
|
export async function validateDocsAsync(
|
|
864
786
|
docsPath: string,
|
|
865
787
|
projectRoot: string,
|
|
866
|
-
options?: {
|
|
788
|
+
options?: { useCache?: boolean; excludePaths?: string[] }
|
|
867
789
|
): Promise<ValidationResult> {
|
|
868
|
-
|
|
869
|
-
if (options?.ctagsIndex) {
|
|
870
|
-
return validateDocs(docsPath, projectRoot, options);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Generate ctags index asynchronously
|
|
874
|
-
const ctagsResult = await generateCtagsIndexAsync(projectRoot);
|
|
875
|
-
|
|
876
|
-
// If ctags is unavailable or failed, run validation with the empty map
|
|
877
|
-
// (to prevent a sync fallback that would also fail), then strip out the
|
|
878
|
-
// symbol-ref invalids that are false positives from the empty index.
|
|
879
|
-
if (!ctagsResult.success) {
|
|
880
|
-
const baseResult = validateDocs(docsPath, projectRoot, {
|
|
881
|
-
...options,
|
|
882
|
-
ctagsIndex: ctagsResult.index, // empty map — avoids sync fallback
|
|
883
|
-
});
|
|
884
|
-
// Remove symbol-ref invalid entries (they're false positives from empty index)
|
|
885
|
-
const symbolInvalidCount = baseResult.invalid.filter((i) =>
|
|
886
|
-
i.reason.startsWith("Symbol '")
|
|
887
|
-
).length;
|
|
888
|
-
baseResult.invalid = baseResult.invalid.filter(
|
|
889
|
-
(i) => !i.reason.startsWith("Symbol '")
|
|
890
|
-
);
|
|
891
|
-
// Also strip from per-doc entries
|
|
892
|
-
for (const entry of Object.values(baseResult.by_doc_file)) {
|
|
893
|
-
entry.invalid = entry.invalid.filter(
|
|
894
|
-
(i) => !i.reason.startsWith("Symbol '")
|
|
895
|
-
);
|
|
896
|
-
}
|
|
897
|
-
baseResult.invalid_count -= symbolInvalidCount;
|
|
898
|
-
baseResult.message = `ctags unavailable: ${ctagsResult.error ?? "unknown error"} — symbol ref validation skipped`;
|
|
899
|
-
return baseResult;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// Delegate to sync validateDocs with the pre-built index
|
|
903
|
-
return validateDocs(docsPath, projectRoot, {
|
|
904
|
-
...options,
|
|
905
|
-
ctagsIndex: ctagsResult.index,
|
|
906
|
-
});
|
|
790
|
+
return validateDocs(docsPath, projectRoot, options);
|
|
907
791
|
}
|
package/package.json
CHANGED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for ctags utilities.
|
|
3
|
-
*
|
|
4
|
-
* Note: These tests require universal-ctags to be installed.
|
|
5
|
-
* Tests are skipped if ctags is not available.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, expect, it, beforeAll } from "vitest";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
import { writeFileSync, mkdirSync, rmSync, existsSync } from "fs";
|
|
11
|
-
import {
|
|
12
|
-
checkCtagsAvailable,
|
|
13
|
-
generateCtagsIndex,
|
|
14
|
-
lookupSymbol,
|
|
15
|
-
searchSymbol,
|
|
16
|
-
getFileSymbols,
|
|
17
|
-
generateFileCtags,
|
|
18
|
-
findSymbolInFile,
|
|
19
|
-
CtagsIndex,
|
|
20
|
-
} from "../ctags.js";
|
|
21
|
-
|
|
22
|
-
// Check if ctags is available for tests
|
|
23
|
-
const ctagsCheck = checkCtagsAvailable();
|
|
24
|
-
const hasCtagsInstalled = ctagsCheck.available;
|
|
25
|
-
|
|
26
|
-
describe("checkCtagsAvailable", () => {
|
|
27
|
-
it("returns availability status", () => {
|
|
28
|
-
const result = checkCtagsAvailable();
|
|
29
|
-
expect(result).toHaveProperty("available");
|
|
30
|
-
|
|
31
|
-
if (result.available) {
|
|
32
|
-
expect(result.version).toBeDefined();
|
|
33
|
-
} else {
|
|
34
|
-
expect(result.error).toBeDefined();
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// Skip remaining tests if ctags not installed
|
|
40
|
-
describe.skipIf(!hasCtagsInstalled)("ctags with installed ctags", () => {
|
|
41
|
-
const testDir = join(process.cwd(), ".test-ctags-temp");
|
|
42
|
-
const testFile = join(testDir, "test-sample.ts");
|
|
43
|
-
|
|
44
|
-
const sampleCode = `
|
|
45
|
-
// Test TypeScript file for ctags tests
|
|
46
|
-
|
|
47
|
-
export class MyClass {
|
|
48
|
-
private value: number;
|
|
49
|
-
|
|
50
|
-
constructor(value: number) {
|
|
51
|
-
this.value = value;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
getValue(): number {
|
|
55
|
-
return this.value;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
setValue(newValue: number): void {
|
|
59
|
-
this.value = newValue;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface MyInterface {
|
|
64
|
-
name: string;
|
|
65
|
-
age: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function myFunction(arg: string): string {
|
|
69
|
-
return arg.toUpperCase();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const MY_CONSTANT = 42;
|
|
73
|
-
|
|
74
|
-
type MyType = string | number;
|
|
75
|
-
`;
|
|
76
|
-
|
|
77
|
-
beforeAll(() => {
|
|
78
|
-
// Create test directory and file
|
|
79
|
-
if (existsSync(testDir)) {
|
|
80
|
-
rmSync(testDir, { recursive: true });
|
|
81
|
-
}
|
|
82
|
-
mkdirSync(testDir, { recursive: true });
|
|
83
|
-
writeFileSync(testFile, sampleCode, "utf-8");
|
|
84
|
-
|
|
85
|
-
return () => {
|
|
86
|
-
// Cleanup
|
|
87
|
-
if (existsSync(testDir)) {
|
|
88
|
-
rmSync(testDir, { recursive: true });
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("generateFileCtags", () => {
|
|
94
|
-
it("generates ctags for a single file", () => {
|
|
95
|
-
const result = generateFileCtags(testFile, testDir);
|
|
96
|
-
|
|
97
|
-
expect(result.success).toBe(true);
|
|
98
|
-
expect(result.entries.length).toBeGreaterThan(0);
|
|
99
|
-
|
|
100
|
-
// Should find known symbols
|
|
101
|
-
const symbolNames = result.entries.map((e) => e.name);
|
|
102
|
-
expect(symbolNames).toContain("MyClass");
|
|
103
|
-
expect(symbolNames).toContain("myFunction");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("returns error for non-existent file", () => {
|
|
107
|
-
const result = generateFileCtags(join(testDir, "nonexistent.ts"), testDir);
|
|
108
|
-
|
|
109
|
-
expect(result.success).toBe(false);
|
|
110
|
-
expect(result.error).toBeDefined();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("includes symbol metadata", () => {
|
|
114
|
-
const result = generateFileCtags(testFile, testDir);
|
|
115
|
-
expect(result.success).toBe(true);
|
|
116
|
-
|
|
117
|
-
const classEntry = result.entries.find((e) => e.name === "MyClass");
|
|
118
|
-
expect(classEntry).toBeDefined();
|
|
119
|
-
expect(classEntry!.kind).toBe("class");
|
|
120
|
-
expect(classEntry!.line).toBeGreaterThan(0);
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe("generateCtagsIndex", () => {
|
|
125
|
-
it("generates index for directory", () => {
|
|
126
|
-
const result = generateCtagsIndex(testDir);
|
|
127
|
-
|
|
128
|
-
expect(result.success).toBe(true);
|
|
129
|
-
expect(result.entryCount).toBeGreaterThan(0);
|
|
130
|
-
expect(result.index.size).toBeGreaterThan(0);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("indexes symbols by file and name", () => {
|
|
134
|
-
const { index, success } = generateCtagsIndex(testDir);
|
|
135
|
-
expect(success).toBe(true);
|
|
136
|
-
|
|
137
|
-
// The file path should be relative to cwd
|
|
138
|
-
const relPath = "test-sample.ts";
|
|
139
|
-
const fileMap = index.get(relPath);
|
|
140
|
-
expect(fileMap).toBeDefined();
|
|
141
|
-
|
|
142
|
-
// Should have MyClass
|
|
143
|
-
const myClass = fileMap!.get("MyClass");
|
|
144
|
-
expect(myClass).toBeDefined();
|
|
145
|
-
expect(myClass!.length).toBeGreaterThan(0);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("lookupSymbol", () => {
|
|
150
|
-
let index: CtagsIndex;
|
|
151
|
-
|
|
152
|
-
beforeAll(() => {
|
|
153
|
-
const result = generateCtagsIndex(testDir);
|
|
154
|
-
index = result.index;
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("finds symbol in specific file", () => {
|
|
158
|
-
const entries = lookupSymbol(index, "test-sample.ts", "MyClass");
|
|
159
|
-
|
|
160
|
-
expect(entries.length).toBeGreaterThan(0);
|
|
161
|
-
expect(entries[0].name).toBe("MyClass");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("returns empty array for non-existent symbol", () => {
|
|
165
|
-
const entries = lookupSymbol(index, "test-sample.ts", "NonExistent");
|
|
166
|
-
expect(entries).toHaveLength(0);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("returns empty array for non-existent file", () => {
|
|
170
|
-
const entries = lookupSymbol(index, "nonexistent.ts", "MyClass");
|
|
171
|
-
expect(entries).toHaveLength(0);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
describe("searchSymbol", () => {
|
|
176
|
-
let index: CtagsIndex;
|
|
177
|
-
|
|
178
|
-
beforeAll(() => {
|
|
179
|
-
const result = generateCtagsIndex(testDir);
|
|
180
|
-
index = result.index;
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("finds symbol across all files", () => {
|
|
184
|
-
const results = searchSymbol(index, "MyClass");
|
|
185
|
-
|
|
186
|
-
expect(results.length).toBeGreaterThan(0);
|
|
187
|
-
expect(results[0].name).toBe("MyClass");
|
|
188
|
-
expect(results[0].file).toBeDefined();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("returns empty for non-existent symbol", () => {
|
|
192
|
-
const results = searchSymbol(index, "NonExistent");
|
|
193
|
-
expect(results).toHaveLength(0);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
describe("getFileSymbols", () => {
|
|
198
|
-
let index: CtagsIndex;
|
|
199
|
-
|
|
200
|
-
beforeAll(() => {
|
|
201
|
-
const result = generateCtagsIndex(testDir);
|
|
202
|
-
index = result.index;
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("returns all symbols in a file", () => {
|
|
206
|
-
const symbols = getFileSymbols(index, "test-sample.ts");
|
|
207
|
-
|
|
208
|
-
expect(symbols.length).toBeGreaterThan(0);
|
|
209
|
-
|
|
210
|
-
// Should include class, function, interface
|
|
211
|
-
const names = symbols.map((s) => s.name);
|
|
212
|
-
expect(names).toContain("MyClass");
|
|
213
|
-
expect(names).toContain("myFunction");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("returns symbols sorted by line number", () => {
|
|
217
|
-
const symbols = getFileSymbols(index, "test-sample.ts");
|
|
218
|
-
|
|
219
|
-
for (let i = 1; i < symbols.length; i++) {
|
|
220
|
-
expect(symbols[i].line).toBeGreaterThanOrEqual(symbols[i - 1].line);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("returns empty for non-existent file", () => {
|
|
225
|
-
const symbols = getFileSymbols(index, "nonexistent.ts");
|
|
226
|
-
expect(symbols).toHaveLength(0);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe("findSymbolInFile", () => {
|
|
231
|
-
it("finds symbol in file", () => {
|
|
232
|
-
const entry = findSymbolInFile(testFile, "MyClass", testDir);
|
|
233
|
-
|
|
234
|
-
expect(entry).not.toBeNull();
|
|
235
|
-
expect(entry!.name).toBe("MyClass");
|
|
236
|
-
expect(entry!.kind).toBe("class");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("returns null for non-existent symbol", () => {
|
|
240
|
-
const entry = findSymbolInFile(testFile, "NonExistent", testDir);
|
|
241
|
-
expect(entry).toBeNull();
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,497 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ctags utilities for symbol lookup and validation.
|
|
3
|
-
*
|
|
4
|
-
* Uses universal-ctags to generate a symbol index for fast O(1) lookups.
|
|
5
|
-
* This enables documentation reference validation without AST parsing.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { spawn, spawnSync } from "child_process";
|
|
9
|
-
import { existsSync } from "fs";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Single ctags entry representing a symbol in a file.
|
|
13
|
-
*/
|
|
14
|
-
export interface CtagsEntry {
|
|
15
|
-
name: string;
|
|
16
|
-
path: string;
|
|
17
|
-
line: number;
|
|
18
|
-
kind: string;
|
|
19
|
-
signature?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Indexed ctags data for O(1) lookups.
|
|
24
|
-
* Structure: Map<file, Map<symbol, CtagsEntry[]>>
|
|
25
|
-
* Multiple entries per symbol possible (overloads, same name in different scopes).
|
|
26
|
-
*/
|
|
27
|
-
export type CtagsIndex = Map<string, Map<string, CtagsEntry[]>>;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Common paths where universal-ctags might be installed.
|
|
31
|
-
* Checked in order before falling back to PATH.
|
|
32
|
-
*/
|
|
33
|
-
const CTAGS_PATHS = [
|
|
34
|
-
"/opt/homebrew/bin/ctags", // macOS ARM homebrew
|
|
35
|
-
"/usr/local/bin/ctags", // macOS Intel homebrew / Linux
|
|
36
|
-
"/usr/bin/ctags", // System PATH (may be BSD ctags)
|
|
37
|
-
"ctags", // Fall back to PATH
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
/** Cached path to universal-ctags binary */
|
|
41
|
-
let cachedCtagsPath: string | null = null;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Find the universal-ctags binary, checking common paths first.
|
|
45
|
-
*/
|
|
46
|
-
function findUniversalCtags(): { path: string; version: string } | null {
|
|
47
|
-
for (const ctagsPath of CTAGS_PATHS) {
|
|
48
|
-
// Skip absolute paths that don't exist
|
|
49
|
-
if (ctagsPath.startsWith("/") && !existsSync(ctagsPath)) {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const result = spawnSync(ctagsPath, ["--version"], { encoding: "utf-8" });
|
|
54
|
-
|
|
55
|
-
if (result.status !== 0) {
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const output = result.stdout || "";
|
|
60
|
-
if (output.includes("Universal Ctags") || output.includes("universal-ctags")) {
|
|
61
|
-
const versionMatch = output.match(/Universal Ctags\s+([\d.]+)/i);
|
|
62
|
-
const version = versionMatch ? versionMatch[1] : "unknown";
|
|
63
|
-
return { path: ctagsPath, version };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Get the path to universal-ctags (cached).
|
|
72
|
-
*/
|
|
73
|
-
export function getCtagsPath(): string | null {
|
|
74
|
-
if (cachedCtagsPath !== null) {
|
|
75
|
-
return cachedCtagsPath;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const found = findUniversalCtags();
|
|
79
|
-
if (found) {
|
|
80
|
-
cachedCtagsPath = found.path;
|
|
81
|
-
return cachedCtagsPath;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Check if ctags (universal-ctags) is available.
|
|
89
|
-
*/
|
|
90
|
-
export function checkCtagsAvailable(): { available: boolean; version?: string; error?: string; path?: string } {
|
|
91
|
-
const found = findUniversalCtags();
|
|
92
|
-
|
|
93
|
-
if (!found) {
|
|
94
|
-
return {
|
|
95
|
-
available: false,
|
|
96
|
-
error:
|
|
97
|
-
"Universal Ctags not found. Install with: brew install universal-ctags\n" +
|
|
98
|
-
"Note: The BSD ctags that ships with macOS is not compatible.",
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
cachedCtagsPath = found.path;
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
available: true,
|
|
106
|
-
version: found.version,
|
|
107
|
-
path: found.path,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Parse a single line of ctags JSON output.
|
|
113
|
-
*/
|
|
114
|
-
function parseCtagsLine(line: string): CtagsEntry | null {
|
|
115
|
-
try {
|
|
116
|
-
const entry = JSON.parse(line);
|
|
117
|
-
|
|
118
|
-
// Skip ptag entries (pseudo-tags with metadata)
|
|
119
|
-
if (entry._type === "ptag") {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (entry._type !== "tag" || !entry.name || !entry.path) {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
name: entry.name,
|
|
129
|
-
path: entry.path,
|
|
130
|
-
line: entry.line || 0,
|
|
131
|
-
kind: entry.kind || "unknown",
|
|
132
|
-
signature: entry.signature,
|
|
133
|
-
};
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Generate a ctags index for a directory.
|
|
141
|
-
*
|
|
142
|
-
* Uses ctags with JSON output format for reliable parsing.
|
|
143
|
-
* Excludes common non-source directories.
|
|
144
|
-
*/
|
|
145
|
-
export function generateCtagsIndex(
|
|
146
|
-
cwd: string,
|
|
147
|
-
options?: {
|
|
148
|
-
/** Additional exclude patterns */
|
|
149
|
-
exclude?: string[];
|
|
150
|
-
/** Specific file or directory to index (relative to cwd) */
|
|
151
|
-
target?: string;
|
|
152
|
-
}
|
|
153
|
-
): { index: CtagsIndex; success: boolean; error?: string; entryCount: number } {
|
|
154
|
-
const check = checkCtagsAvailable();
|
|
155
|
-
if (!check.available) {
|
|
156
|
-
return {
|
|
157
|
-
index: new Map(),
|
|
158
|
-
success: false,
|
|
159
|
-
error: check.error,
|
|
160
|
-
entryCount: 0,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const excludes = [
|
|
165
|
-
"node_modules",
|
|
166
|
-
".git",
|
|
167
|
-
"dist",
|
|
168
|
-
"build",
|
|
169
|
-
".next",
|
|
170
|
-
"coverage",
|
|
171
|
-
"__pycache__",
|
|
172
|
-
".venv",
|
|
173
|
-
"venv",
|
|
174
|
-
...(options?.exclude || []),
|
|
175
|
-
];
|
|
176
|
-
|
|
177
|
-
const args = [
|
|
178
|
-
"-R",
|
|
179
|
-
"--output-format=json",
|
|
180
|
-
"--fields=+nKS", // +n=line number, +K=kind, +S=signature
|
|
181
|
-
"-o",
|
|
182
|
-
"-", // Output to stdout
|
|
183
|
-
];
|
|
184
|
-
|
|
185
|
-
// Add exclude patterns
|
|
186
|
-
for (const exclude of excludes) {
|
|
187
|
-
args.push(`--exclude=${exclude}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Add target if specified, otherwise index current directory
|
|
191
|
-
const target = options?.target || ".";
|
|
192
|
-
args.push(target);
|
|
193
|
-
|
|
194
|
-
const ctagsPath = getCtagsPath()!;
|
|
195
|
-
const result = spawnSync(ctagsPath, args, {
|
|
196
|
-
encoding: "utf-8",
|
|
197
|
-
cwd,
|
|
198
|
-
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large repos
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
if (result.status !== 0) {
|
|
202
|
-
return {
|
|
203
|
-
index: new Map(),
|
|
204
|
-
success: false,
|
|
205
|
-
error: `ctags failed: ${result.stderr || "unknown error"}`,
|
|
206
|
-
entryCount: 0,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Parse JSON output into index
|
|
211
|
-
const index: CtagsIndex = new Map();
|
|
212
|
-
let entryCount = 0;
|
|
213
|
-
|
|
214
|
-
const lines = result.stdout.split("\n");
|
|
215
|
-
for (const line of lines) {
|
|
216
|
-
if (!line.trim()) continue;
|
|
217
|
-
|
|
218
|
-
const entry = parseCtagsLine(line);
|
|
219
|
-
if (!entry) continue;
|
|
220
|
-
|
|
221
|
-
entryCount++;
|
|
222
|
-
|
|
223
|
-
// Get or create file map
|
|
224
|
-
let fileMap = index.get(entry.path);
|
|
225
|
-
if (!fileMap) {
|
|
226
|
-
fileMap = new Map();
|
|
227
|
-
index.set(entry.path, fileMap);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Get or create symbol array
|
|
231
|
-
let symbols = fileMap.get(entry.name);
|
|
232
|
-
if (!symbols) {
|
|
233
|
-
symbols = [];
|
|
234
|
-
fileMap.set(entry.name, symbols);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
symbols.push(entry);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return { index, success: true, entryCount };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Look up a symbol in a specific file.
|
|
245
|
-
*
|
|
246
|
-
* Returns all matching entries (may have multiple for overloads).
|
|
247
|
-
*/
|
|
248
|
-
export function lookupSymbol(
|
|
249
|
-
index: CtagsIndex,
|
|
250
|
-
filePath: string,
|
|
251
|
-
symbolName: string
|
|
252
|
-
): CtagsEntry[] {
|
|
253
|
-
const fileMap = index.get(filePath);
|
|
254
|
-
if (!fileMap) {
|
|
255
|
-
return [];
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return fileMap.get(symbolName) || [];
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Look up a symbol in any file (for searching).
|
|
263
|
-
*
|
|
264
|
-
* Returns all matching entries across all files.
|
|
265
|
-
*/
|
|
266
|
-
export function searchSymbol(
|
|
267
|
-
index: CtagsIndex,
|
|
268
|
-
symbolName: string
|
|
269
|
-
): Array<CtagsEntry & { file: string }> {
|
|
270
|
-
const results: Array<CtagsEntry & { file: string }> = [];
|
|
271
|
-
|
|
272
|
-
for (const [file, fileMap] of index) {
|
|
273
|
-
const entries = fileMap.get(symbolName);
|
|
274
|
-
if (entries) {
|
|
275
|
-
for (const entry of entries) {
|
|
276
|
-
results.push({ ...entry, file });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return results;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Get all symbols in a file.
|
|
286
|
-
*/
|
|
287
|
-
export function getFileSymbols(index: CtagsIndex, filePath: string): CtagsEntry[] {
|
|
288
|
-
const fileMap = index.get(filePath);
|
|
289
|
-
if (!fileMap) {
|
|
290
|
-
return [];
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const symbols: CtagsEntry[] = [];
|
|
294
|
-
for (const entries of fileMap.values()) {
|
|
295
|
-
symbols.push(...entries);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Sort by line number
|
|
299
|
-
return symbols.sort((a, b) => a.line - b.line);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Generate ctags for a single file (faster for format-reference command).
|
|
304
|
-
*/
|
|
305
|
-
export function generateFileCtags(
|
|
306
|
-
filePath: string,
|
|
307
|
-
cwd: string
|
|
308
|
-
): { entries: CtagsEntry[]; success: boolean; error?: string } {
|
|
309
|
-
if (!existsSync(filePath)) {
|
|
310
|
-
return { entries: [], success: false, error: "File not found" };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const check = checkCtagsAvailable();
|
|
314
|
-
if (!check.available) {
|
|
315
|
-
return { entries: [], success: false, error: check.error };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const args = [
|
|
319
|
-
"--output-format=json",
|
|
320
|
-
"--fields=+nKS",
|
|
321
|
-
"-o",
|
|
322
|
-
"-",
|
|
323
|
-
filePath,
|
|
324
|
-
];
|
|
325
|
-
|
|
326
|
-
const ctagsPath = getCtagsPath()!;
|
|
327
|
-
const result = spawnSync(ctagsPath, args, {
|
|
328
|
-
encoding: "utf-8",
|
|
329
|
-
cwd,
|
|
330
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (result.status !== 0) {
|
|
334
|
-
return {
|
|
335
|
-
entries: [],
|
|
336
|
-
success: false,
|
|
337
|
-
error: `ctags failed: ${result.stderr || "unknown error"}`,
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const entries: CtagsEntry[] = [];
|
|
342
|
-
const lines = result.stdout.split("\n");
|
|
343
|
-
|
|
344
|
-
for (const line of lines) {
|
|
345
|
-
if (!line.trim()) continue;
|
|
346
|
-
|
|
347
|
-
const entry = parseCtagsLine(line);
|
|
348
|
-
if (entry) {
|
|
349
|
-
entries.push(entry);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return { entries, success: true };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Find a specific symbol in a file (convenience function).
|
|
358
|
-
* Uses single-file ctags for efficiency.
|
|
359
|
-
*/
|
|
360
|
-
export function findSymbolInFile(
|
|
361
|
-
filePath: string,
|
|
362
|
-
symbolName: string,
|
|
363
|
-
cwd: string
|
|
364
|
-
): CtagsEntry | null {
|
|
365
|
-
const { entries, success } = generateFileCtags(filePath, cwd);
|
|
366
|
-
if (!success) {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Return first match (usually there's only one)
|
|
371
|
-
return entries.find((e) => e.name === symbolName) || null;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Generate a ctags index asynchronously (non-blocking).
|
|
376
|
-
*
|
|
377
|
-
* Same behavior as generateCtagsIndex but uses spawn instead of spawnSync,
|
|
378
|
-
* keeping the event loop free during the ctags process.
|
|
379
|
-
*/
|
|
380
|
-
export async function generateCtagsIndexAsync(
|
|
381
|
-
cwd: string,
|
|
382
|
-
options?: {
|
|
383
|
-
/** Additional exclude patterns */
|
|
384
|
-
exclude?: string[];
|
|
385
|
-
/** Specific file or directory to index (relative to cwd) */
|
|
386
|
-
target?: string;
|
|
387
|
-
}
|
|
388
|
-
): Promise<{ index: CtagsIndex; success: boolean; error?: string; entryCount: number }> {
|
|
389
|
-
const check = checkCtagsAvailable();
|
|
390
|
-
if (!check.available) {
|
|
391
|
-
return {
|
|
392
|
-
index: new Map(),
|
|
393
|
-
success: false,
|
|
394
|
-
error: check.error,
|
|
395
|
-
entryCount: 0,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const excludes = [
|
|
400
|
-
"node_modules",
|
|
401
|
-
".git",
|
|
402
|
-
"dist",
|
|
403
|
-
"build",
|
|
404
|
-
".next",
|
|
405
|
-
"coverage",
|
|
406
|
-
"__pycache__",
|
|
407
|
-
".venv",
|
|
408
|
-
"venv",
|
|
409
|
-
...(options?.exclude || []),
|
|
410
|
-
];
|
|
411
|
-
|
|
412
|
-
const args = [
|
|
413
|
-
"-R",
|
|
414
|
-
"--output-format=json",
|
|
415
|
-
"--fields=+nKS",
|
|
416
|
-
"-o",
|
|
417
|
-
"-",
|
|
418
|
-
];
|
|
419
|
-
|
|
420
|
-
for (const exclude of excludes) {
|
|
421
|
-
args.push(`--exclude=${exclude}`);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const target = options?.target || ".";
|
|
425
|
-
args.push(target);
|
|
426
|
-
|
|
427
|
-
const ctagsPath = getCtagsPath()!;
|
|
428
|
-
|
|
429
|
-
return new Promise((resolve) => {
|
|
430
|
-
const child = spawn(ctagsPath, args, {
|
|
431
|
-
cwd,
|
|
432
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const chunks: Buffer[] = [];
|
|
436
|
-
let stderrOutput = "";
|
|
437
|
-
|
|
438
|
-
child.stdout?.on("data", (data: Buffer) => {
|
|
439
|
-
chunks.push(data);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
child.stderr?.on("data", (data: Buffer) => {
|
|
443
|
-
stderrOutput += data.toString();
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
child.on("close", (code) => {
|
|
447
|
-
if (code !== 0) {
|
|
448
|
-
resolve({
|
|
449
|
-
index: new Map(),
|
|
450
|
-
success: false,
|
|
451
|
-
error: `ctags failed: ${stderrOutput || "unknown error"}`,
|
|
452
|
-
entryCount: 0,
|
|
453
|
-
});
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const stdout = Buffer.concat(chunks).toString("utf-8");
|
|
458
|
-
const index: CtagsIndex = new Map();
|
|
459
|
-
let entryCount = 0;
|
|
460
|
-
|
|
461
|
-
const lines = stdout.split("\n");
|
|
462
|
-
for (const line of lines) {
|
|
463
|
-
if (!line.trim()) continue;
|
|
464
|
-
|
|
465
|
-
const entry = parseCtagsLine(line);
|
|
466
|
-
if (!entry) continue;
|
|
467
|
-
|
|
468
|
-
entryCount++;
|
|
469
|
-
|
|
470
|
-
let fileMap = index.get(entry.path);
|
|
471
|
-
if (!fileMap) {
|
|
472
|
-
fileMap = new Map();
|
|
473
|
-
index.set(entry.path, fileMap);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
let symbols = fileMap.get(entry.name);
|
|
477
|
-
if (!symbols) {
|
|
478
|
-
symbols = [];
|
|
479
|
-
fileMap.set(entry.name, symbols);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
symbols.push(entry);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
resolve({ index, success: true, entryCount });
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
child.on("error", (err) => {
|
|
489
|
-
resolve({
|
|
490
|
-
index: new Map(),
|
|
491
|
-
success: false,
|
|
492
|
-
error: `ctags spawn error: ${err.message}`,
|
|
493
|
-
entryCount: 0,
|
|
494
|
-
});
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
}
|