The doc-sniffing dog
at main 30 kB view raw
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}