Skip to content

Commit e343847

Browse files
authored
Support namespaces/files in get_docs (#52)
And it includes a list of all exports.
1 parent 2e70001 commit e343847

11 files changed

Lines changed: 410 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ Here are some examples:
317317

318318
```bash
319319
# Local TypeScript/JavaScript files
320+
npx tidewave docs ./src/utils
320321
npx tidewave docs ./src/utils:formatDate
321322
npx tidewave docs ./components:Button#onClick
322323

src/core.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface InternalResolvedModule {
2727

2828
export interface ExtractionRequest {
2929
readonly module: string;
30-
readonly symbol: string;
30+
readonly symbol?: string;
3131
readonly member?: string;
3232
readonly isStatic?: boolean;
3333
}
@@ -42,6 +42,20 @@ export interface SymbolInfo {
4242
readonly jsDoc?: string;
4343
}
4444

45+
export interface ExportSummary {
46+
readonly name: string;
47+
readonly kind: string;
48+
readonly line: number;
49+
readonly documentation?: string;
50+
}
51+
52+
export interface FileInfo {
53+
readonly path: string;
54+
readonly overview?: string;
55+
readonly exportCount: number;
56+
readonly exports: ExportSummary[];
57+
}
58+
4559
export interface ExtractorOptions {
4660
readonly prefix?: string;
4761
}
@@ -83,7 +97,7 @@ export interface EvaluatedModuleResult {
8397
}
8498

8599
export type ResolveResult = ResolvedModule | ResolveError;
86-
export type ExtractResult = SymbolInfo | ExtractError;
100+
export type ExtractResult = SymbolInfo | FileInfo | ExtractError;
87101
export type InternalResolveResult = InternalResolvedModule | ResolveError;
88102

89103
export function isResolveError(
@@ -96,6 +110,10 @@ export function isExtractError(result: ExtractResult): result is ExtractError {
96110
return result != null && 'error' in result;
97111
}
98112

113+
export function isFileInfo(result: ExtractResult): result is FileInfo {
114+
return result != null && 'exports' in result && Array.isArray((result as FileInfo).exports);
115+
}
116+
99117
export function resolveError(
100118
specifier: ModuleRequest['specifier'],
101119
source: ModuleRequest['source'],

src/mcp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ async function handleGetDocs({ reference }: DocsInputSchema): Promise<CallToolRe
7979
};
8080
}
8181

82-
if (!docs.documentation) {
82+
// Handle both FileInfo and SymbolInfo
83+
// For SymbolInfo, check if documentation is available
84+
if ('documentation' in docs && !docs.documentation) {
8385
return {
8486
content: [
8587
{

src/resolution/formatters.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ts from 'typescript';
2-
import type { SymbolInfo } from '../core';
2+
import type { SymbolInfo, FileInfo } from '../core';
3+
import { isFileInfo } from '../core';
34

45
// Get signature
56
export function getSignature(checker: ts.TypeChecker, symbol: ts.Symbol, type: ts.Type): string {
@@ -134,8 +135,8 @@ export function getTypeString(checker: ts.TypeChecker, symbol: ts.Symbol, type:
134135
return defaultTypeString;
135136
}
136137

137-
// Format output for display
138-
export function formatOutput(info: SymbolInfo): string {
138+
// Format symbol info for display
139+
function formatSymbolInfo(info: SymbolInfo): string {
139140
const output: string[] = [];
140141

141142
output.push(`\n${info.name}`);
@@ -166,3 +167,37 @@ export function formatOutput(info: SymbolInfo): string {
166167

167168
return output.join('\n');
168169
}
170+
171+
// Format file info for display
172+
function formatFileInfo(info: FileInfo): string {
173+
const output: string[] = [];
174+
175+
output.push(`\nFile: ${info.path}`);
176+
output.push('');
177+
178+
if (info.overview) {
179+
output.push('Overview:');
180+
output.push(info.overview);
181+
output.push('');
182+
}
183+
184+
output.push(`Symbols (${info.exportCount} total):`);
185+
output.push('');
186+
187+
for (const exp of info.exports) {
188+
output.push(`${exp.name} (${exp.kind}) - line ${exp.line}`);
189+
if (exp.documentation) {
190+
output.push(exp.documentation);
191+
}
192+
}
193+
194+
return output.join('\n');
195+
}
196+
197+
// Format output dispatcher - handles both SymbolInfo and FileInfo
198+
export function formatOutput(info: SymbolInfo | FileInfo): string {
199+
if (isFileInfo(info)) {
200+
return formatFileInfo(info);
201+
}
202+
return formatSymbolInfo(info);
203+
}

src/resolution/index.ts

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@ import type {
77
ResolveResult,
88
ExtractError,
99
InternalResolveResult,
10+
ExportSummary,
1011
} from '../core';
1112
import { createExtractError, resolveError, isResolveError, isExtractError } from '../core';
1213
import { loadTsConfig, resolveModule, resolveNodeBuiltin } from './module-resolver';
1314
import { findSymbolInJavaScriptFile, getSymbolInfo } from './symbol-finder';
14-
import { formatOutput } from './formatters';
15+
import { getSymbolKind, getFileOverview } from './utils';
1516

1617
// Parse module path into extraction request
1718
function parseModulePath(modulePath: string): ExtractionRequest | ExtractError {
19+
// Handle empty or invalid paths
20+
if (!modulePath || modulePath.trim() === '') {
21+
return createExtractError('INVALID_REQUEST', 'Module path is required');
22+
}
23+
1824
// Handle node: prefix specially
1925
let module: string;
20-
let symbolPath: string;
26+
let symbolPath: string | undefined;
2127

2228
if (modulePath.startsWith('node:')) {
2329
// For node:Math -> module='node:Math', symbol='Math'
@@ -27,16 +33,31 @@ function parseModulePath(modulePath: string): ExtractionRequest | ExtractError {
2733
module = `node:${baseSymbol}`; // 'node:Math'
2834
symbolPath = nodeSymbol; // 'Math' or 'Math.min'
2935
} else {
30-
// Regular module:symbol format
31-
const [mod, symPath] = modulePath.split(':');
32-
if (!mod || !symPath) {
33-
return createExtractError(
34-
'INVALID_REQUEST',
35-
`Invalid format. Expected 'module:symbol', got '${modulePath}'`,
36-
);
36+
// Regular module[:symbol] format
37+
const colonIndex = modulePath.indexOf(':');
38+
39+
if (colonIndex === -1) {
40+
// No colon found - file-level request
41+
module = modulePath;
42+
symbolPath = undefined;
43+
} else if (colonIndex === 0) {
44+
// Only colon at start
45+
return createExtractError('INVALID_REQUEST', 'Module path is required before ":"');
46+
} else {
47+
// Split on first colon
48+
module = modulePath.substring(0, colonIndex);
49+
symbolPath = modulePath.substring(colonIndex + 1);
50+
51+
// If symbol part is empty after colon, return error
52+
if (!symbolPath || symbolPath.trim() === '') {
53+
return createExtractError('INVALID_REQUEST', 'Symbol name is required after ":"');
54+
}
3755
}
38-
module = mod;
39-
symbolPath = symPath;
56+
}
57+
58+
// If no symbol path, return file-level request
59+
if (!symbolPath) {
60+
return { module, symbol: undefined, member: undefined, isStatic: false };
4061
}
4162

4263
// Check for instance member (Constructor#instanceMember)
@@ -66,6 +87,51 @@ function parseModulePath(modulePath: string): ExtractionRequest | ExtractError {
6687
return { module, symbol: symbolPath, member: undefined, isStatic: false };
6788
}
6889

90+
// Get all exported symbols with their summaries
91+
function getExportSummaries(
92+
moduleSymbol: ts.Symbol | undefined,
93+
sourceFile: ts.SourceFile,
94+
checker: ts.TypeChecker,
95+
): ExportSummary[] {
96+
if (!moduleSymbol) {
97+
return [];
98+
}
99+
100+
try {
101+
const exports = checker.getExportsOfModule(moduleSymbol);
102+
const summaries: ExportSummary[] = [];
103+
104+
for (const exp of exports) {
105+
const name = exp.getName();
106+
const kind = getSymbolKind(exp);
107+
108+
// Get line number from declaration
109+
const decl = exp.valueDeclaration ?? exp.declarations?.[0];
110+
let line = 0;
111+
if (decl) {
112+
const pos = sourceFile.getLineAndCharacterOfPosition(decl.getStart());
113+
line = pos.line + 1; // 1-indexed
114+
}
115+
116+
// Get brief documentation (first line)
117+
const fullDoc = ts.displayPartsToString(exp.getDocumentationComment(checker));
118+
const briefDoc = fullDoc.split('\n')[0]?.trim();
119+
120+
summaries.push({
121+
name,
122+
kind,
123+
line,
124+
documentation: briefDoc || undefined,
125+
});
126+
}
127+
128+
// Sort by line number
129+
return summaries.sort((a, b) => a.line - b.line);
130+
} catch {
131+
return [];
132+
}
133+
}
134+
69135
// Extract documentation for a module:symbol path
70136
export async function extractDocs(modulePath: string): Promise<ExtractResult> {
71137
const options: ExtractorOptions = { prefix: process.cwd() };
@@ -95,6 +161,28 @@ export async function extractDocs(modulePath: string): Promise<ExtractResult> {
95161

96162
const { sourceFile, program } = resolvedModule;
97163
const checker = program.getTypeChecker();
164+
165+
// Handle file-level request (no symbol specified)
166+
if (symbol === undefined) {
167+
const overview = getFileOverview(sourceFile);
168+
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
169+
const exports = getExportSummaries(moduleSymbol, sourceFile, checker);
170+
171+
// Format path as relative (consistent with SymbolInfo.location)
172+
let relativePath = sourceFile.fileName;
173+
const cwd = process.cwd();
174+
if (relativePath.startsWith(cwd)) {
175+
relativePath = path.relative(cwd, relativePath);
176+
}
177+
178+
return {
179+
path: relativePath,
180+
overview,
181+
exportCount: exports.length,
182+
exports,
183+
};
184+
}
185+
98186
let targetSymbol: ts.Symbol | undefined;
99187

100188
if (isGlobalModule) {
@@ -196,6 +284,20 @@ export async function getSourceLocation(reference: string): Promise<ResolveResul
196284
}
197285

198286
const { module, symbol, member, isStatic } = parseResult;
287+
288+
// In this branch, we know reference contains ':', so symbol should be defined
289+
// If symbol is undefined here, it means parseModulePath returned successfully for a file-level request
290+
// which shouldn't happen in this code path
291+
if (!symbol) {
292+
return {
293+
success: false,
294+
error: {
295+
code: 'INVALID_SPECIFIER',
296+
message: 'Symbol reference required for source location lookup',
297+
},
298+
};
299+
}
300+
199301
const config = loadTsConfig(options.prefix);
200302

201303
// Try to resolve as a regular module first
@@ -365,6 +467,8 @@ export async function extractSymbol(
365467
};
366468
}
367469

470+
// After the check above, we know symbol is defined
471+
const { symbol } = request;
368472
const config = loadTsConfig(options.prefix);
369473

370474
// Resolve module with dedicated program
@@ -388,27 +492,27 @@ export async function extractSymbol(
388492
if (moduleSymbol) {
389493
// TypeScript module with proper exports
390494
const exports = checker.getExportsOfModule(moduleSymbol);
391-
targetSymbol = exports.find((exp: ts.Symbol) => exp.getName() === request.symbol);
495+
targetSymbol = exports.find((exp: ts.Symbol) => exp.getName() === symbol);
392496

393497
if (!targetSymbol) {
394498
return {
395499
error: {
396500
code: 'SYMBOL_NOT_FOUND',
397-
message: `Symbol '${request.symbol}' not found in module '${request.module}'. Available exports: ${exports
501+
message: `Symbol '${symbol}' not found in module '${request.module}'. Available exports: ${exports
398502
.map((e: ts.Symbol) => e.getName())
399503
.join(', ')}`,
400504
},
401505
};
402506
}
403507
} else {
404508
// JavaScript file - look for symbols in statements
405-
targetSymbol = findSymbolInJavaScriptFile(sourceFile, checker, request.symbol);
509+
targetSymbol = findSymbolInJavaScriptFile(sourceFile, checker, symbol);
406510

407511
if (!targetSymbol) {
408512
return {
409513
error: {
410514
code: 'SYMBOL_NOT_FOUND',
411-
message: `Symbol '${request.symbol}' not found in JavaScript module '${request.module}'`,
515+
message: `Symbol '${symbol}' not found in JavaScript module '${request.module}'`,
412516
},
413517
};
414518
}
@@ -426,5 +530,5 @@ export async function extractSymbol(
426530
}
427531
}
428532

429-
// Re-export formatOutput
430-
export { formatOutput };
533+
// Re-export formatters
534+
export { formatOutput } from './formatters';

src/resolution/symbol-finder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ts from 'typescript';
2-
import type { ExtractResult } from '../core';
2+
import type { SymbolInfo, ExtractError } from '../core';
33
import { createExtractError } from '../core';
44
import { getLocation, getDocumentation, getJSDoc, getSymbolKind } from './utils';
55
import { getSignature, getTypeString } from './formatters';
@@ -128,7 +128,7 @@ export function getSymbolInfo(
128128
symbol: ts.Symbol,
129129
member?: string,
130130
isStatic?: boolean,
131-
): ExtractResult {
131+
): SymbolInfo | ExtractError {
132132
// For symbols without valueDeclaration (like interfaces), use the first declaration
133133
const declaration = symbol.valueDeclaration || (symbol.declarations && symbol.declarations[0]);
134134
if (!declaration && (!symbol.declarations || symbol.declarations.length === 0)) {

src/resolution/utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,39 @@ export function getSymbolKind(symbol: ts.Symbol): string {
9191

9292
return 'unknown';
9393
}
94+
95+
// Get file overview from @fileoverview or @file JSDoc tags
96+
export function getFileOverview(sourceFile: ts.SourceFile): string | undefined {
97+
// Get leading comments from the source file (before first statement)
98+
const leadingComments = ts.getLeadingCommentRanges(sourceFile.text, 0);
99+
100+
if (!leadingComments || leadingComments.length === 0) {
101+
return undefined;
102+
}
103+
104+
// Extract text and parse JSDoc
105+
for (const comment of leadingComments) {
106+
const commentText = sourceFile.text.slice(comment.pos, comment.end);
107+
108+
// Parse @fileoverview or @file tags
109+
const fileoverviewMatch = commentText.match(/@fileoverview\s+([\s\S]*?)(?=@\w+|$)/);
110+
const fileMatch = commentText.match(/@file\s+([\s\S]*?)(?=@\w+|$)/);
111+
const match = fileoverviewMatch || fileMatch;
112+
113+
if (match && match[1]) {
114+
// Clean up the matched text: remove leading asterisks and whitespace
115+
const overviewText = match[1]
116+
.split('\n')
117+
.map(line => line.replace(/^\s*\*\s?/, '').trim())
118+
.filter(line => line.length > 0)
119+
.join('\n')
120+
.trim();
121+
122+
if (overviewText) {
123+
return overviewText;
124+
}
125+
}
126+
}
127+
128+
return undefined;
129+
}

0 commit comments

Comments
 (0)