The doc-sniffing dog
1/**
2 * Core module for JSDoc coverage analysis
3 * @module
4 */
5
6import { walk } from "@std/fs";
7import { dirname, join, relative, resolve } from "@std/path";
8
9export interface ExportedSymbol {
10 name: string;
11 type:
12 | "function"
13 | "class"
14 | "interface"
15 | "type"
16 | "const"
17 | "variable"
18 | "enum";
19 file: string;
20 line: number;
21 hasJSDoc: boolean;
22 jsDocContent?: string;
23 exportType: "named" | "default";
24}
25
26export interface DocumentationStats {
27 total: number;
28 documented: number;
29 undocumented: number;
30 percentage: number;
31 byType: Record<string, { total: number; documented: number }>;
32}
33
34export interface AnalysisResult {
35 path: string;
36 hasDenoJson: boolean;
37 hasExports: boolean;
38 exportPath?: string;
39 symbols: ExportedSymbol[];
40 stats: DocumentationStats;
41}
42
43interface DenoConfig {
44 exports?: string | Record<string, string>;
45 name?: string;
46 version?: string;
47}
48
49interface TracedExport {
50 originalName: string;
51 exportedName: string;
52 sourcePath: string;
53 line: number;
54}
55
56/**
57 * Analyzes a directory or file for JSDoc coverage.
58 * @param targetPath The path to analyze
59 * @returns Analysis results including symbols and statistics
60 */
61export async function analyzeDirectory(
62 targetPath: string,
63): Promise<AnalysisResult> {
64 const rootPath = resolve(targetPath);
65 const symbols: ExportedSymbol[] = [];
66 const analyzedFiles = new Set<string>();
67 const exportMap = new Map<string, TracedExport[]>();
68
69 // Check for deno.json
70 const denoConfig = await loadDenoConfig(rootPath);
71 const hasDenoJson = denoConfig !== null;
72 const hasExports = !!(denoConfig?.exports);
73 const exportPath = typeof denoConfig?.exports === "string"
74 ? denoConfig.exports
75 : denoConfig?.exports
76 ? Object.values(denoConfig.exports)[0]
77 : undefined;
78
79 if (denoConfig?.exports) {
80 // Use exports field as entry point
81 await analyzeFromExports(
82 rootPath,
83 denoConfig.exports,
84 symbols,
85 analyzedFiles,
86 exportMap,
87 );
88 } else {
89 // Fall back to analyzing all files
90 await analyzeAllFiles(rootPath, symbols);
91 }
92
93 // Calculate statistics
94 const stats = calculateStats(symbols);
95
96 return {
97 path: rootPath,
98 hasDenoJson,
99 hasExports,
100 exportPath,
101 symbols,
102 stats,
103 };
104}
105
106async function loadDenoConfig(rootPath: string): Promise<DenoConfig | null> {
107 // Check if rootPath is a file
108 const stat = await Deno.stat(rootPath);
109 if (stat.isFile) {
110 // If analyzing a single file, check parent directory for deno.json
111 rootPath = dirname(rootPath);
112 }
113
114 const configPath = join(rootPath, "deno.json");
115 try {
116 const configContent = await Deno.readTextFile(configPath);
117 const config = JSON.parse(configContent) as DenoConfig;
118 return config;
119 } catch {
120 // Try deno.jsonc
121 const configPathJsonc = join(rootPath, "deno.jsonc");
122 try {
123 const configContent = await Deno.readTextFile(configPathJsonc);
124 // Simple JSONC parsing - remove comments
125 const jsonContent = configContent
126 .split("\n")
127 .map((line) => {
128 const commentIndex = line.indexOf("//");
129 return commentIndex > -1 ? line.slice(0, commentIndex) : line;
130 })
131 .join("\n")
132 .replace(/\/\*[\s\S]*?\*\//g, "");
133 const config = JSON.parse(jsonContent) as DenoConfig;
134 return config;
135 } catch {
136 return null;
137 }
138 }
139}
140
141async function analyzeFromExports(
142 rootPath: string,
143 exports: string | Record<string, string>,
144 symbols: ExportedSymbol[],
145 analyzedFiles: Set<string>,
146 exportMap: Map<string, TracedExport[]>,
147): Promise<void> {
148 // Handle string or object exports
149 const exportPaths: string[] = [];
150
151 if (typeof exports === "string") {
152 exportPaths.push(exports);
153 } else {
154 // For object exports, analyze all export paths
155 exportPaths.push(...Object.values(exports));
156 }
157
158 for (const exportPath of exportPaths) {
159 const fullPath = join(rootPath, exportPath);
160 await traceExportsFromFile(
161 fullPath,
162 analyzedFiles,
163 exportMap,
164 rootPath,
165 );
166 }
167
168 // Now analyze JSDoc for all traced symbols
169 // Collect all unique exports by their source to avoid duplicates
170 const uniqueExports = new Map<string, TracedExport>();
171 for (const [_filePath, exports] of exportMap.entries()) {
172 for (const exp of exports) {
173 const key = `${exp.sourcePath}:${exp.originalName}:${exp.exportedName}`;
174 if (!uniqueExports.has(key)) {
175 uniqueExports.set(key, exp);
176 }
177 }
178 }
179
180 // Analyze only unique exports
181 await analyzeFileForJSDoc(
182 Array.from(uniqueExports.values()),
183 symbols,
184 rootPath,
185 );
186}
187
188async function traceExportsFromFile(
189 filePath: string,
190 analyzedFiles: Set<string>,
191 exportMap: Map<string, TracedExport[]>,
192 rootPath: string,
193): Promise<void> {
194 if (analyzedFiles.has(filePath)) {
195 return;
196 }
197 analyzedFiles.add(filePath);
198
199 try {
200 const content = await Deno.readTextFile(filePath);
201 const lines = content.split("\n");
202 const currentDir = dirname(filePath);
203
204 for (let i = 0; i < lines.length; i++) {
205 const line = lines[i];
206 const trimmed = line.trim();
207
208 // Handle re-exports: export * from "./module.ts" or export { ... } from "./module.ts" or export type { ... } from "./module.ts"
209 if (
210 trimmed.startsWith("export *") || trimmed.startsWith("export {") ||
211 trimmed.startsWith("export type {")
212 ) {
213 const fromMatch = trimmed.match(/from\s+["']([^"']+)["']/);
214 if (fromMatch) {
215 // Re-export with from clause
216 const importPath = fromMatch[1];
217 const resolvedPath = await resolveImportPath(importPath, currentDir);
218 if (resolvedPath) {
219 // If it's export *, we need to find all exports from that file
220 if (trimmed.startsWith("export *")) {
221 // For export *, we need to recursively trace that file
222 await traceExportsFromFile(
223 resolvedPath,
224 analyzedFiles,
225 exportMap,
226 rootPath,
227 );
228 // Copy all exports from that file
229 const sourceExports = exportMap.get(resolvedPath) || [];
230 for (const exp of sourceExports) {
231 addExport(exportMap, filePath, {
232 ...exp,
233 exportedName: exp.exportedName,
234 });
235 }
236 } else {
237 // Handle selective re-exports: export { a, b as c } from "./module" or export type { ... } from "./module"
238 const exportsMatch = trimmed.match(
239 /export\s*(?:type\s+)?{\s*([^}]+)\s*}/,
240 );
241 if (exportsMatch) {
242 const exportsList = exportsMatch[1].split(",").map((e) =>
243 e.trim()
244 );
245
246 // For selective exports, only track the specific symbols
247 for (const exportItem of exportsList) {
248 const [originalName, exportedName] = exportItem
249 .split(/\s+as\s+/)
250 .map((s) => s.trim());
251
252 // Find the line number in the source file where this symbol is defined
253 const lineNum = await findSymbolInFile(
254 resolvedPath,
255 originalName,
256 );
257
258 addExport(exportMap, filePath, {
259 originalName,
260 exportedName: exportedName || originalName,
261 sourcePath: resolvedPath,
262 line: lineNum || i + 1,
263 });
264 }
265 }
266 }
267 }
268 } else if (
269 trimmed.startsWith("export {") || trimmed.startsWith("export type {")
270 ) {
271 // Export without from clause (e.g., export { foo, bar } or export type { foo })
272 // These are re-exports of previously imported symbols
273 const exportsMatch = trimmed.match(
274 /export\s*(?:type\s+)?{\s*([^}]+)\s*}/,
275 );
276 if (exportsMatch) {
277 const exportsList = exportsMatch[1].split(",").map((e) => e.trim());
278
279 for (const exportItem of exportsList) {
280 const [originalName, exportedName] = exportItem
281 .split(/\s+as\s+/)
282 .map((s) => s.trim());
283
284 // For import-then-export pattern, we need to find where these were imported from
285 const importSource = await findImportSource(
286 content,
287 originalName,
288 currentDir,
289 );
290
291 if (importSource) {
292 // Found the import source, trace that file
293 const lineNum = await findSymbolInFile(
294 importSource,
295 originalName,
296 );
297
298 addExport(exportMap, filePath, {
299 originalName,
300 exportedName: exportedName || originalName,
301 sourcePath: importSource,
302 line: lineNum || i + 1,
303 });
304 } else {
305 // Treat as a local export if we can't find the import
306 // Find the actual definition line in this file
307 const lineNum = await findSymbolInFile(
308 filePath,
309 originalName,
310 );
311 addExport(exportMap, filePath, {
312 originalName,
313 exportedName: exportedName || originalName,
314 sourcePath: filePath,
315 line: lineNum || i + 1,
316 });
317 }
318 }
319 }
320 }
321 } // Handle direct exports in this file
322 else if (isDirectExport(trimmed)) {
323 const symbolName = extractExportName(trimmed);
324 if (symbolName) {
325 addExport(exportMap, filePath, {
326 originalName: symbolName,
327 exportedName: symbolName,
328 sourcePath: filePath,
329 line: i + 1,
330 });
331 }
332 }
333 }
334 } catch {
335 // Silently ignore errors for missing files
336 }
337}
338
339async function findImportSource(
340 fileContent: string,
341 symbolName: string,
342 currentDir: string,
343): Promise<string | null> {
344 const lines = fileContent.split("\n");
345
346 for (const line of lines) {
347 const trimmed = line.trim();
348
349 // Look for import statements
350 if (trimmed.startsWith("import ")) {
351 // Check for named imports: import { symbolName } from "..."
352 const namedImportMatch = trimmed.match(
353 /import\s*{([^}]+)}\s*from\s*["']([^"']+)["']/,
354 );
355
356 if (namedImportMatch) {
357 const imports = namedImportMatch[1].split(",").map((i) => i.trim());
358
359 for (const imp of imports) {
360 const [imported, alias] = imp.split(/\s+as\s+/).map((s) => s.trim());
361
362 // Check if this import includes our symbol
363 if (imported === symbolName || alias === symbolName) {
364 const importPath = namedImportMatch[2];
365 return await resolveImportPath(importPath, currentDir);
366 }
367 }
368 }
369
370 // Check for default import: import symbolName from "..."
371 const defaultImportMatch = trimmed.match(
372 /import\s+(\w+)\s+from\s*["']([^"']+)["']/,
373 );
374
375 if (defaultImportMatch && defaultImportMatch[1] === symbolName) {
376 const importPath = defaultImportMatch[2];
377 return await resolveImportPath(importPath, currentDir);
378 }
379
380 // Check for namespace import: import * as symbolName from "..."
381 const namespaceImportMatch = trimmed.match(
382 /import\s*\*\s+as\s+(\w+)\s+from\s*["']([^"']+)["']/,
383 );
384
385 if (namespaceImportMatch && namespaceImportMatch[1] === symbolName) {
386 const importPath = namespaceImportMatch[2];
387 return await resolveImportPath(importPath, currentDir);
388 }
389 }
390 }
391
392 return null;
393}
394
395async function resolveImportPath(
396 importPath: string,
397 fromDir: string,
398): Promise<string | null> {
399 // Handle relative imports
400 if (importPath.startsWith(".")) {
401 const basePath = join(fromDir, importPath);
402
403 // Try with common extensions
404 const extensions = [
405 ".ts",
406 ".tsx",
407 ".js",
408 ".jsx",
409 ".mjs",
410 "/mod.ts",
411 "/index.ts",
412 ];
413
414 // First try exact path
415 try {
416 await Deno.stat(basePath);
417 return resolve(basePath);
418 } catch {
419 // Try with extensions
420 for (const ext of extensions) {
421 try {
422 const fullPath = basePath.endsWith(".ts") || basePath.endsWith(".js")
423 ? basePath
424 : basePath + ext;
425 await Deno.stat(fullPath);
426 return resolve(fullPath);
427 } catch {
428 continue;
429 }
430 }
431 }
432 }
433
434 return null;
435}
436
437function escapeRegExp(str: string): string {
438 return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
439}
440
441async function findSymbolInFile(
442 filePath: string,
443 symbolName: string,
444): Promise<number | null> {
445 try {
446 const content = await Deno.readTextFile(filePath);
447 const lines = content.split("\n");
448
449 for (let i = 0; i < lines.length; i++) {
450 const line = lines[i].trim();
451
452 // Check if this line exports the symbol we're looking for
453 if (isDirectExport(line)) {
454 const exportedName = extractExportName(line);
455 if (exportedName === symbolName) {
456 return i + 1;
457 }
458 }
459
460 // Also check for non-exported declarations (class, function, interface, type, enum, const)
461 // that might be exported later with export { ... }
462 const patterns = [
463 new RegExp(`^(?:async\\s+)?function\\s+${escapeRegExp(symbolName)}\\b`),
464 new RegExp(`^class\\s+${escapeRegExp(symbolName)}\\b`),
465 new RegExp(`^interface\\s+${escapeRegExp(symbolName)}\\b`),
466 new RegExp(`^type\\s+${escapeRegExp(symbolName)}\\b`),
467 new RegExp(`^enum\\s+${escapeRegExp(symbolName)}\\b`),
468 new RegExp(`^(?:const|let|var)\\s+${escapeRegExp(symbolName)}\\b`),
469 ];
470
471 for (const pattern of patterns) {
472 if (pattern.test(line)) {
473 return i + 1;
474 }
475 }
476 }
477 } catch {
478 // Ignore errors
479 }
480
481 return null;
482}
483
484function isDirectExport(line: string): boolean {
485 return (
486 line.startsWith("export function") ||
487 line.startsWith("export async function") ||
488 line.startsWith("export class") ||
489 line.startsWith("export interface") ||
490 line.startsWith("export type") ||
491 line.startsWith("export enum") ||
492 line.startsWith("export const") ||
493 line.startsWith("export let") ||
494 line.startsWith("export var") ||
495 line.startsWith("export default")
496 );
497}
498
499function extractExportName(line: string): string | null {
500 // Extract the symbol name from various export patterns
501 const patterns = [
502 /export\s+(?:async\s+)?function\s+(\w+)/,
503 /export\s+class\s+(\w+)/,
504 /export\s+interface\s+(\w+)/,
505 /export\s+type\s+(\w+)/,
506 /export\s+enum\s+(\w+)/,
507 /export\s+(?:const|let|var)\s+(\w+)/,
508 ];
509
510 for (const pattern of patterns) {
511 const match = line.match(pattern);
512 if (match) {
513 return match[1];
514 }
515 }
516
517 if (line.includes("export default")) {
518 return "default";
519 }
520
521 return null;
522}
523
524function addExport(
525 exportMap: Map<string, TracedExport[]>,
526 filePath: string,
527 exportInfo: TracedExport,
528): void {
529 if (!exportMap.has(filePath)) {
530 exportMap.set(filePath, []);
531 }
532
533 // Avoid duplicates
534 const existing = exportMap.get(filePath)!;
535 const isDuplicate = existing.some(
536 (e) =>
537 e.exportedName === exportInfo.exportedName &&
538 e.sourcePath === exportInfo.sourcePath,
539 );
540
541 if (!isDuplicate) {
542 existing.push(exportInfo);
543 }
544}
545
546async function analyzeFileForJSDoc(
547 exports: TracedExport[],
548 symbols: ExportedSymbol[],
549 rootPath: string,
550): Promise<void> {
551 // Group exports by their source file and deduplicate
552 const exportsBySource = new Map<string, TracedExport[]>();
553 const seenExports = new Set<string>();
554
555 for (const exp of exports) {
556 // Create a unique key for deduplication
557 const key = `${exp.sourcePath}:${exp.exportedName}:${exp.originalName}`;
558 if (seenExports.has(key)) {
559 continue; // Skip duplicates
560 }
561 seenExports.add(key);
562
563 if (!exportsBySource.has(exp.sourcePath)) {
564 exportsBySource.set(exp.sourcePath, []);
565 }
566 exportsBySource.get(exp.sourcePath)!.push(exp);
567 }
568
569 // Analyze each source file
570 for (const [sourcePath, sourceExports] of exportsBySource.entries()) {
571 try {
572 const content = await Deno.readTextFile(sourcePath);
573 const lines = content.split("\n");
574 const relativePath = relative(rootPath, sourcePath);
575
576 // Track JSDoc blocks
577 const jsDocBlocks: Map<number, string> = new Map();
578 let currentJSDoc: string[] = [];
579
580 for (let i = 0; i < lines.length; i++) {
581 const line = lines[i];
582 const trimmed = line.trim();
583
584 // Track JSDoc blocks
585 if (trimmed.startsWith("/**")) {
586 if (trimmed.endsWith("*/")) {
587 const jsDocContent = trimmed;
588 if (!jsDocContent.includes("@module")) {
589 for (let j = i + 1; j < lines.length; j++) {
590 const nextLine = lines[j].trim();
591 if (nextLine && !nextLine.startsWith("//")) {
592 if (
593 isDirectExport(nextLine) || nextLine.startsWith("export ")
594 ) {
595 jsDocBlocks.set(j, jsDocContent);
596 for (let k = j + 1; k <= j + 5 && k < lines.length; k++) {
597 jsDocBlocks.set(k, jsDocContent);
598 }
599 }
600 break;
601 }
602 }
603 }
604 } else {
605 currentJSDoc = [trimmed];
606 }
607 } else if (currentJSDoc.length > 0) {
608 currentJSDoc.push(line);
609 if (trimmed.endsWith("*/")) {
610 const jsDocContent = currentJSDoc.join("\n");
611 if (jsDocContent.includes("@module")) {
612 currentJSDoc = [];
613 continue;
614 }
615 for (let j = i + 1; j < lines.length; j++) {
616 const nextLine = lines[j].trim();
617 if (nextLine && !nextLine.startsWith("//")) {
618 if (
619 isDirectExport(nextLine) || nextLine.startsWith("export ")
620 ) {
621 jsDocBlocks.set(j, jsDocContent);
622 for (let k = j + 1; k <= j + 5 && k < lines.length; k++) {
623 jsDocBlocks.set(k, jsDocContent);
624 }
625 }
626 break;
627 }
628 }
629 currentJSDoc = [];
630 }
631 }
632
633 // Check if this line starts an export declaration
634 if (isDirectExport(trimmed)) {
635 // For multi-line declarations, we need to extract the full declaration
636 let fullDeclaration = trimmed;
637 const declarationStartLine = i;
638
639 // If the line doesn't contain a complete function/class signature, gather more lines
640 if (!trimmed.includes("{") && !trimmed.includes(";")) {
641 for (let j = i + 1; j < lines.length && j < i + 10; j++) {
642 fullDeclaration += " " + lines[j].trim();
643 if (lines[j].includes("{") || lines[j].includes(";")) {
644 break;
645 }
646 }
647 }
648
649 const lineExportName = extractExportName(fullDeclaration);
650 if (lineExportName) {
651 // Find if we're tracking this specific export
652 for (const exp of sourceExports) {
653 if (
654 exp.originalName === lineExportName &&
655 exp.sourcePath === sourcePath
656 ) {
657 const symbol = parseExportedSymbol(
658 fullDeclaration,
659 declarationStartLine,
660 relativePath,
661 jsDocBlocks,
662 );
663 if (symbol) {
664 // Use the exported name from our trace
665 symbol.name = exp.exportedName;
666 symbols.push(symbol);
667 }
668 break;
669 }
670 }
671 }
672 } else {
673 // Check for non-exported declarations that match symbols in sourceExports
674 for (const exp of sourceExports) {
675 const patterns = [
676 new RegExp(`^class\\s+${escapeRegExp(exp.originalName)}\\b`),
677 new RegExp(`^(?:async\\s+)?function\\s+${escapeRegExp(exp.originalName)}\\b`),
678 new RegExp(`^const\\s+${escapeRegExp(exp.originalName)}\\b`),
679 new RegExp(`^let\\s+${escapeRegExp(exp.originalName)}\\b`),
680 new RegExp(`^var\\s+${escapeRegExp(exp.originalName)}\\b`),
681 new RegExp(`^interface\\s+${escapeRegExp(exp.originalName)}\\b`),
682 new RegExp(`^type\\s+${escapeRegExp(exp.originalName)}\\b`),
683 new RegExp(`^enum\\s+${escapeRegExp(exp.originalName)}\\b`),
684 ];
685
686 for (const pattern of patterns) {
687 if (pattern.test(trimmed)) {
688 let fullDeclaration = trimmed;
689 const declarationStartLine = i;
690
691 if (!trimmed.includes("{") && !trimmed.includes(";")) {
692 for (let j = i + 1; j < lines.length && j < i + 10; j++) {
693 fullDeclaration += " " + lines[j].trim();
694 if (lines[j].includes("{") || lines[j].includes(";")) {
695 break;
696 }
697 }
698 }
699
700 const symbol = parseExportedSymbol(
701 fullDeclaration,
702 declarationStartLine,
703 relativePath,
704 jsDocBlocks,
705 );
706 if (symbol) {
707 symbol.name = exp.exportedName;
708 symbols.push(symbol);
709 }
710 break;
711 }
712 }
713 }
714 }
715 }
716 } catch {
717 // Silently ignore errors
718 }
719 }
720}
721
722async function analyzeAllFiles(
723 rootPath: string,
724 symbols: ExportedSymbol[],
725): Promise<void> {
726 const files = await findSourceFiles(rootPath);
727
728 for (const file of files) {
729 await analyzeFile(file, symbols, rootPath);
730 }
731}
732
733async function findSourceFiles(rootPath: string): Promise<string[]> {
734 const files: string[] = [];
735
736 // Check if the path is a file or directory
737 const stat = await Deno.stat(rootPath);
738
739 if (stat.isFile) {
740 // If it's a single file, check if it's a source file
741 const validExts = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
742 if (validExts.some((ext) => rootPath.endsWith(ext))) {
743 files.push(rootPath);
744 }
745 } else if (stat.isDirectory) {
746 // If it's a directory, walk through it
747 const entries = walk(rootPath, {
748 exts: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
749 skip: [
750 /node_modules/,
751 /\.git/,
752 /dist/,
753 /build/,
754 /coverage/,
755 /\.test\./,
756 /\.spec\./,
757 /test\//,
758 /tests\//,
759 /_test\./,
760 ],
761 });
762
763 for await (const entry of entries) {
764 if (entry.isFile) {
765 files.push(entry.path);
766 }
767 }
768 }
769
770 return files;
771}
772
773async function analyzeFile(
774 filePath: string,
775 symbols: ExportedSymbol[],
776 rootPath: string,
777): Promise<void> {
778 const content = await Deno.readTextFile(filePath);
779 const lines = content.split("\n");
780
781 // Handle both file and directory paths
782 const stat = await Deno.stat(rootPath);
783 const relativePath = stat.isFile
784 ? relative(Deno.cwd(), filePath)
785 : relative(rootPath, filePath);
786
787 // Track JSDoc blocks
788 const jsDocBlocks: Map<number, string> = new Map();
789 let currentJSDoc: string[] = [];
790
791 for (let i = 0; i < lines.length; i++) {
792 const line = lines[i];
793 const trimmed = line.trim();
794
795 // Track JSDoc blocks
796 if (trimmed.startsWith("/**")) {
797 if (trimmed.endsWith("*/")) {
798 const jsDocContent = trimmed;
799 for (let j = i + 1; j < lines.length; j++) {
800 if (lines[j].trim() && !lines[j].trim().startsWith("//")) {
801 jsDocBlocks.set(j, jsDocContent);
802 break;
803 }
804 }
805 } else {
806 currentJSDoc = [trimmed];
807 }
808 } else if (currentJSDoc.length > 0) {
809 currentJSDoc.push(line);
810 if (trimmed.endsWith("*/")) {
811 const jsDocContent = currentJSDoc.join("\n");
812 for (let j = i + 1; j < lines.length; j++) {
813 if (lines[j].trim() && !lines[j].trim().startsWith("//")) {
814 jsDocBlocks.set(j, jsDocContent);
815 break;
816 }
817 }
818 currentJSDoc = [];
819 }
820 }
821
822 // Check for exports
823 if (isExportLine(trimmed)) {
824 const symbol = parseExportedSymbol(
825 trimmed,
826 i,
827 relativePath,
828 jsDocBlocks,
829 );
830 if (symbol) {
831 symbols.push(symbol);
832 }
833 }
834 }
835}
836
837function isExportLine(line: string): boolean {
838 return (
839 line.startsWith("export ") ||
840 (line.includes("export {") && !line.includes("export type {")) ||
841 line.includes("export default")
842 );
843}
844
845function parseExportedSymbol(
846 line: string,
847 lineIndex: number,
848 filePath: string,
849 jsDocBlocks: Map<number, string>,
850): ExportedSymbol | null {
851 const trimmed = line.trim();
852 let name = "";
853 let type: ExportedSymbol["type"] = "variable";
854 let exportType: "named" | "default" = "named";
855
856 // Check for JSDoc
857 const hasJSDoc = jsDocBlocks.has(lineIndex);
858 const jsDocContent = jsDocBlocks.get(lineIndex);
859
860 // Parse export default
861 if (trimmed.includes("export default")) {
862 exportType = "default";
863
864 if (trimmed.includes("function")) {
865 const match = trimmed.match(/function\s+(\w+)/);
866 name = match ? match[1] : "default";
867 type = "function";
868 } else if (trimmed.includes("class")) {
869 const match = trimmed.match(/class\s+(\w+)/);
870 name = match ? match[1] : "default";
871 type = "class";
872 } else {
873 name = "default";
874 type = "variable";
875 }
876 } // Parse export function
877 else if (
878 trimmed.startsWith("export function") ||
879 trimmed.startsWith("export async function")
880 ) {
881 const match = trimmed.match(/function\s+(\w+)/);
882 if (match) {
883 name = match[1];
884 type = "function";
885 }
886 } // Parse export class
887 else if (trimmed.startsWith("export class")) {
888 const match = trimmed.match(/class\s+(\w+)/);
889 if (match) {
890 name = match[1];
891 type = "class";
892 }
893 } // Parse export interface
894 else if (trimmed.startsWith("export interface")) {
895 const match = trimmed.match(/interface\s+(\w+)/);
896 if (match) {
897 name = match[1];
898 type = "interface";
899 }
900 } // Parse export type
901 else if (trimmed.startsWith("export type")) {
902 const match = trimmed.match(/type\s+(\w+)/);
903 if (match) {
904 name = match[1];
905 type = "type";
906 }
907 } // Parse export enum
908 else if (trimmed.startsWith("export enum")) {
909 const match = trimmed.match(/enum\s+(\w+)/);
910 if (match) {
911 name = match[1];
912 type = "enum";
913 }
914 } // Parse export const/let/var
915 else if (
916 trimmed.startsWith("export const") ||
917 trimmed.startsWith("export let") ||
918 trimmed.startsWith("export var")
919 ) {
920 const match = trimmed.match(/(?:const|let|var)\s+(\w+)/);
921 if (match) {
922 name = match[1];
923 type = trimmed.includes("const") ? "const" : "variable";
924 }
925 } // Parse export { ... }
926 else if (trimmed.includes("export {") && !trimmed.includes("from")) {
927 // Only handle direct export { ... } without from clause in this function
928 // Re-exports are handled elsewhere
929 const match = trimmed.match(/export\s*{\s*([^}]+)\s*}/);
930 if (match) {
931 const exports = match[1].split(",").map((e) => e.trim());
932 // For simplicity, we'll just track the first one
933 // In a real implementation, you'd want to handle all of them
934 if (exports.length > 0) {
935 name = exports[0].split(/\s+as\s+/)[0];
936 type = "variable"; // We'd need more context to determine the actual type
937 }
938 }
939 } // Parse non-exported declarations
940 else if (trimmed.startsWith("class ")) {
941 const match = trimmed.match(/class\s+(\w+)/);
942 if (match) {
943 name = match[1];
944 type = "class";
945 }
946 } else if (
947 trimmed.startsWith("function ") || trimmed.startsWith("async function ")
948 ) {
949 const match = trimmed.match(/function\s+(\w+)/);
950 if (match) {
951 name = match[1];
952 type = "function";
953 }
954 } else if (trimmed.startsWith("interface ")) {
955 const match = trimmed.match(/interface\s+(\w+)/);
956 if (match) {
957 name = match[1];
958 type = "interface";
959 }
960 } else if (trimmed.startsWith("type ")) {
961 const match = trimmed.match(/type\s+(\w+)/);
962 if (match) {
963 name = match[1];
964 type = "type";
965 }
966 } else if (trimmed.startsWith("enum ")) {
967 const match = trimmed.match(/enum\s+(\w+)/);
968 if (match) {
969 name = match[1];
970 type = "enum";
971 }
972 } else if (
973 trimmed.startsWith("const ") || trimmed.startsWith("let ") ||
974 trimmed.startsWith("var ")
975 ) {
976 const match = trimmed.match(/(?:const|let|var)\s+(\w+)/);
977 if (match) {
978 name = match[1];
979 type = trimmed.startsWith("const") ? "const" : "variable";
980 }
981 }
982
983 if (name) {
984 return {
985 name,
986 type,
987 file: filePath,
988 line: lineIndex + 1,
989 hasJSDoc,
990 jsDocContent,
991 exportType,
992 };
993 }
994
995 return null;
996}
997
998export function calculateStats(symbols: ExportedSymbol[]): DocumentationStats {
999 const stats: DocumentationStats = {
1000 total: symbols.length,
1001 documented: symbols.filter((s) => s.hasJSDoc).length,
1002 undocumented: symbols.filter((s) => !s.hasJSDoc).length,
1003 percentage: 0,
1004 byType: {},
1005 };
1006
1007 stats.percentage = stats.total > 0
1008 ? Math.round((stats.documented / stats.total) * 100)
1009 : 100;
1010
1011 // Calculate stats by type
1012 for (const symbol of symbols) {
1013 if (!stats.byType[symbol.type]) {
1014 stats.byType[symbol.type] = { total: 0, documented: 0 };
1015 }
1016 stats.byType[symbol.type].total++;
1017 if (symbol.hasJSDoc) {
1018 stats.byType[symbol.type].documented++;
1019 }
1020 }
1021
1022 return stats;
1023}