1/**
2 * Parses TypeScript declarations and JSDoc into docgen models.
3 *
4 * @since 0.6.0
5 */
6import * as doctrine from "doctrine"
7import * as Array from "effect/Array"
8import * as Context from "effect/Context"
9import * as Effect from "effect/Effect"
10import { pipe } from "effect/Function"
11import * as Option from "effect/Option"
12import * as Path from "effect/Path"
13import * as Record from "effect/Record"
14import * as String from "effect/String"
15import * as ast from "ts-morph"
16import * as Configuration from "./Configuration.ts"
17import * as Domain from "./Domain.ts"
18
19/**
20 * Source file and path currently being parsed.
21 *
22 * @category services
23 * @since 0.6.0
24 */
25export interface SourceShape {
26 readonly path: Array.NonEmptyReadonlyArray<string>
27 readonly sourceFile: ast.SourceFile
28}
29
30/** @internal */
31export class Source extends Context.Service<Source, SourceShape>()("@effect/docgen/Source") {}
32
33const sortModulesByPath: <A extends Domain.Module>(self: Iterable<A>) => Array<A> = Array
34 .sort(Domain.ByPath)
35
36const getJSDocText: (jsdocs: ReadonlyArray<ast.JSDoc>) => string = Array.matchRight({
37 onEmpty: () => "",
38 onNonEmpty: (_, last) => last.getText()
39})
40
41const getDocComment = (ranges: ReadonlyArray<ast.CommentRange>): Option.Option<ast.CommentRange> =>
42 pipe(
43 ranges,
44 Array.filter((range) => range.getText().startsWith("/**")),
45 Array.last
46 )
47
48class Comment {
49 readonly description: string | undefined
50 readonly tags: Record<string, ReadonlyArray<string> | undefined>
51 constructor(
52 description: string | undefined,
53 tags: Record<string, ReadonlyArray<string> | undefined>
54 ) {
55 this.description = description
56 this.tags = tags
57 }
58}
59
60/**
61 * @internal
62 */
63export const parseComment = (text: string): Comment => {
64 const annotation: doctrine.Annotation = doctrine.parse(text, {
65 unwrap: true
66 })
67
68 const description = pipe(
69 Option.fromNullishOr(annotation.description),
70 Option.map((s) => s.trim()),
71 Option.filter(String.isNonEmpty),
72 Option.getOrUndefined
73 )
74
75 const tags = pipe(
76 annotation.tags,
77 Array.groupBy((tag) => tag.title),
78 Record.map((values) =>
79 Array.map(values, (tag) =>
80 pipe(
81 Option.fromNullishOr(tag.description),
82 Option.map(String.trim),
83 Option.getOrElse(() => "")
84 ))
85 )
86 )
87
88 return { description, tags }
89}
90
91const isVariableDeclarationList = (
92 u: ast.VariableDeclarationList | ast.CatchClause
93): u is ast.VariableDeclarationList => u.getKind() === ast.ts.SyntaxKind.VariableDeclarationList
94
95const isVariableStatement = (
96 u:
97 | ast.VariableStatement
98 | ast.ForStatement
99 | ast.ForOfStatement
100 | ast.ForInStatement
101): u is ast.VariableStatement => u.getKind() === ast.ts.SyntaxKind.VariableStatement
102
103const parseDoc = (text: string) => {
104 const comment = parseComment(text)
105 return new Domain.Doc(
106 comment.description,
107 comment.tags["since"] ?? [],
108 comment.tags["deprecated"] ?? [],
109 comment.tags["example"] ?? [],
110 comment.tags["category"] ?? [],
111 comment.tags["throws"] ?? [],
112 comment.tags["see"] ?? [],
113 comment.tags
114 )
115}
116
117const shouldIgnore = (doc: Domain.Doc): boolean => {
118 return Record.has(doc.tags, "internal") || Record.has(doc.tags, "ignore")
119}
120
121const parseInterfaceDeclaration = (id: ast.InterfaceDeclaration) =>
122 Effect.gen(function*() {
123 const doc = parseDoc(getJSDocText(id.getJsDocs()))
124 if (shouldIgnore(doc)) {
125 return []
126 }
127 const name = id.getName()
128 const signature = id.getText()
129 const position = yield* parsePosition(id)
130 return [
131 new Domain.Interface(
132 name,
133 doc,
134 signature,
135 position
136 )
137 ]
138 })
139
140const parseInterfaceDeclarations = (interfaces: ReadonlyArray<ast.InterfaceDeclaration>) => {
141 const exportedInterfaces = Array.filter(
142 interfaces,
143 (id) => id.isExported()
144 )
145 return Effect.forEach(exportedInterfaces, parseInterfaceDeclaration).pipe(Effect.map(Array.flatten))
146}
147
148/**
149 * Parses exported interfaces from the current source file.
150 *
151 * @category parsing
152 * @since 0.6.0
153 */
154export const parseInterfaces = Effect.flatMap(
155 Source,
156 (source) => parseInterfaceDeclarations(source.sourceFile.getInterfaces())
157)
158
159const getTypeText = (node: ast.Node) =>
160 node.getType().getText(
161 node,
162 ast.ts.TypeFormatFlags.NoTruncation
163 | ast.ts.TypeFormatFlags.WriteArrayAsGenericType
164 | ast.ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope
165 | ast.ts.TypeFormatFlags.NoTypeReduction
166 | ast.ts.TypeFormatFlags.AllowUniqueESSymbolType
167 | ast.ts.TypeFormatFlags.WriteArrowStyleSignature
168 )
169
170const parseType = (node: ast.Node) => {
171 let text = getTypeText(node)
172 for (const property of node.getDescendantsOfKind(ast.ts.SyntaxKind.PropertySignature)) {
173 if (!shouldIgnore(parseDoc(getJSDocText(property.getJsDocs())))) continue
174 const readonly = property.getFirstModifierByKind(ast.ts.SyntaxKind.ReadonlyKeyword) ? "readonly " : ""
175 const optional = property.hasQuestionToken() ? "?" : ""
176 const type = property.getTypeNode()?.getText() ?? getTypeText(property)
177 const signature = `${readonly}${property.getName()}${optional}: ${type}`
178 text = text
179 .replaceAll(`; ${signature}`, "")
180 .replaceAll(`${signature}; `, "")
181 .replaceAll(signature, "")
182 }
183 return text
184}
185
186const getFunctionDeclarationJSDocs = (fd: ast.FunctionDeclaration): Array<ast.JSDoc> =>
187 Array.matchLeft(fd.getOverloads(), {
188 onEmpty: () => fd.getJsDocs(),
189 onNonEmpty: (firstOverload) => firstOverload.getJsDocs()
190 })
191
192const parsePosition = (node: ast.Node): Effect.Effect<Domain.Position, never, Source> => {
193 return Effect.gen(function*() {
194 const source = yield* Source
195 const startPos = node.getStart()
196 const position = source.sourceFile.getLineAndColumnAtPos(startPos)
197 return position
198 })
199}
200
201const parseFunctionDeclaration = (fd: ast.FunctionDeclaration) =>
202 Effect.gen(function*() {
203 const doc = parseDoc(getJSDocText(getFunctionDeclarationJSDocs(fd)))
204 if (shouldIgnore(doc)) {
205 return []
206 }
207 const name = fd.getName()
208 const type = parseType(fd)
209 const signature = `declare const ${name}: ${type}`
210 const position = yield* parsePosition(fd)
211 return [
212 new Domain.Function(
213 name ?? "",
214 doc,
215 signature,
216 position
217 )
218 ]
219 })
220
221const parseFunctionVariableDeclaration = (vd: ast.VariableDeclaration) =>
222 Effect.gen(function*() {
223 const vs: any = vd.getParent().getParent()
224 const doc = parseDoc(getJSDocText(vs.getJsDocs()))
225 if (shouldIgnore(doc)) {
226 return []
227 }
228 const name = vd.getName()
229 const type = parseType(vd)
230 const signature = `declare const ${name}: ${type}`
231 const startPos = vd.getStart()
232 const source = yield* Source
233 const position = source.sourceFile.getLineAndColumnAtPos(startPos)
234 return [
235 new Domain.Function(
236 name ?? "",
237 doc,
238 signature,
239 position
240 )
241 ]
242 })
243
244const getFunctionDeclarations = Effect.gen(function*() {
245 const source = yield* Source
246 const functions = Array.filter(
247 source.sourceFile.getFunctions(),
248 (fd) => fd.isExported()
249 )
250 const arrows = pipe(
251 Array.filter(
252 source.sourceFile.getVariableDeclarations(),
253 (vd) => {
254 if (isVariableDeclarationList(vd.getParent())) {
255 const vs: any = vd.getParent().getParent()
256 if (isVariableStatement(vs)) {
257 return vs.isExported() &&
258 Option.fromNullishOr(vd.getInitializer()).pipe(
259 Option.filter((expr) => ast.Node.isFunctionLikeDeclaration(expr)),
260 Option.isSome
261 )
262 }
263 }
264 return false
265 }
266 )
267 )
268 return { functions, arrows }
269})
270
271/**
272 * Parses exported function declarations and function-valued variables.
273 *
274 * @category parsing
275 * @since 0.6.0
276 */
277export const parseFunctions = Effect.gen(function*() {
278 const { arrows, functions } = yield* getFunctionDeclarations
279 const functionDeclarations = yield* Effect.forEach(functions, parseFunctionDeclaration).pipe(
280 Effect.map(Array.flatten)
281 )
282 const functionVariableDeclarations = yield* Effect.forEach(arrows, parseFunctionVariableDeclaration).pipe(
283 Effect.map(Array.flatten)
284 )
285 return [...functionDeclarations, ...functionVariableDeclarations]
286})
287
288const parseTypeAliasDeclaration = (ta: ast.TypeAliasDeclaration) =>
289 Effect.gen(function*() {
290 const doc = parseDoc(getJSDocText(ta.getJsDocs()))
291 if (shouldIgnore(doc)) {
292 return []
293 }
294 const name = ta.getName()
295 const typeParameters = ta.getTypeParameters().map((parameter) => parameter.getText()).join(", ")
296 const definition = ta.getTypeNode()?.getText()
297 const signature = `type ${name}${typeParameters.length > 0 ? `<${typeParameters}>` : ""} = ${definition}`
298 const position = yield* parsePosition(ta)
299 return [
300 new Domain.TypeAlias(
301 name,
302 doc,
303 signature,
304 position
305 )
306 ]
307 })
308
309const parseTypeAliasDeclarations = (typeAliases: ReadonlyArray<ast.TypeAliasDeclaration>) => {
310 const exportedTypeAliases = Array.filter(
311 typeAliases,
312 (tad) => tad.isExported()
313 )
314 return Effect.forEach(exportedTypeAliases, parseTypeAliasDeclaration).pipe(Effect.map(Array.flatten))
315}
316
317/**
318 * Parses exported type aliases from the current source file.
319 *
320 * @category parsing
321 * @since 0.6.0
322 */
323export const parseTypeAliases = Effect.flatMap(
324 Source,
325 (source) => parseTypeAliasDeclarations(source.sourceFile.getTypeAliases())
326)
327
328const parseConstantVariableDeclaration = (vd: ast.VariableDeclaration) =>
329 Effect.gen(function*() {
330 const vs: any = vd.getParent().getParent()
331 const doc = parseDoc(getJSDocText(vs.getJsDocs()))
332 if (shouldIgnore(doc)) {
333 return []
334 }
335 const name = vd.getName()
336 const type = parseType(vd)
337 const signature = `declare const ${name}: ${type}`
338 const position = yield* parsePosition(vd)
339 return [
340 new Domain.Constant(
341 name,
342 doc,
343 signature,
344 position
345 )
346 ]
347 })
348
349/**
350 * Parses exported non-function constants from the current source file.
351 *
352 * @category parsing
353 * @since 0.6.0
354 */
355export const parseConstants = Effect.gen(function*() {
356 const source = yield* Source
357 const variableDeclarations = pipe(
358 Array.filter(
359 source.sourceFile.getVariableDeclarations(),
360 (vd) => {
361 if (isVariableDeclarationList(vd.getParent())) {
362 const vs: any = vd.getParent().getParent()
363 if (isVariableStatement(vs)) {
364 return vs.isExported() &&
365 Option.fromNullishOr(vd.getInitializer()).pipe(
366 Option.filter((expr) => !ast.Node.isFunctionLikeDeclaration(expr)),
367 Option.isSome
368 )
369 }
370 }
371 return false
372 }
373 )
374 )
375 return yield* Effect.forEach(variableDeclarations, parseConstantVariableDeclaration).pipe(
376 Effect.map(Array.flatten)
377 )
378})
379
380const parseExportSpecifier = (es: ast.ExportSpecifier) =>
381 Effect.gen(function*() {
382 const name = es.compilerNode.name.text
383 const type = parseType(es)
384 const oDocComment = getDocComment(es.getLeadingCommentRanges())
385 const doc = Option.isSome(oDocComment) ? parseDoc(oDocComment.value.getText()) : parseDoc("")
386 const signature = `declare const ${name}: ${type}`
387 const position = yield* parsePosition(es)
388 return new Domain.Export(
389 name,
390 doc,
391 signature,
392 position,
393 false
394 )
395 })
396
397const parseExportStar = (ed: ast.ExportDeclaration) =>
398 Effect.gen(function*() {
399 const es = ed.getModuleSpecifier()!
400 const name = es.getText()
401 const namespace = ed.getNamespaceExport()?.getName()
402 const signature = `export *${namespace === undefined ? "" : ` as ${namespace}`} from ${name}`
403 const oDocComment = getDocComment(ed.getLeadingCommentRanges())
404 const doc = Option.isSome(oDocComment) ? parseDoc(oDocComment.value.getText()) : parseDoc("")
405 const position = yield* parsePosition(ed)
406 return new Domain.Export(
407 namespace ?? name,
408 doc.modifyDescription(
409 `Re-exports all named exports from the ${name} module${namespace === undefined ? "" : ` as \`${namespace}\``}.`
410 ),
411 signature,
412 position,
413 true
414 )
415 })
416
417const parseNamedExports = (ed: ast.ExportDeclaration) => {
418 const namedExports = ed.getNamedExports()
419 if (namedExports.length === 0) {
420 if (ed.getModuleSpecifier() !== undefined) {
421 return parseExportStar(ed).pipe(Effect.map(Array.of))
422 }
423 return Effect.succeed([])
424 }
425 return Effect.forEach(namedExports, parseExportSpecifier)
426}
427
428/**
429 * Parses explicit export declarations from the current source file.
430 *
431 * @category parsing
432 * @since 0.6.0
433 */
434export const parseExports = pipe(
435 Effect.map(Source, (source) => source.sourceFile.getExportDeclarations()),
436 Effect.flatMap((exportDeclarations) => Effect.forEach(exportDeclarations, parseNamedExports)),
437 Effect.map(Array.flatten)
438)
439
440const parseModuleDeclaration = (
441 ed: ast.ModuleDeclaration
442): Effect.Effect<Array<Domain.Namespace>, never, Source | Configuration.Configuration> => {
443 const doc = parseDoc(getJSDocText(ed.getJsDocs()))
444 if (shouldIgnore(doc)) {
445 return Effect.succeed([])
446 }
447 const name = ed.getName()
448 const getInterfaces = parseInterfaceDeclarations(ed.getInterfaces())
449 const getTypeAliases = parseTypeAliasDeclarations(ed.getTypeAliases())
450 const getNamespaces = parseModuleDeclarations(ed.getModules())
451 return Effect.gen(function*() {
452 const interfaces = yield* getInterfaces
453 const typeAliases = yield* getTypeAliases
454 const namespaces = yield* getNamespaces
455 const position = yield* parsePosition(ed)
456 return [
457 new Domain.Namespace(
458 name,
459 doc,
460 position,
461 interfaces,
462 typeAliases,
463 namespaces
464 )
465 ]
466 })
467}
468
469const parseModuleDeclarations = (namespaces: ReadonlyArray<ast.ModuleDeclaration>) => {
470 return Effect.forEach(
471 namespaces.filter((md) => md.isExported()),
472 parseModuleDeclaration
473 ).pipe(Effect.map(Array.flatten))
474}
475
476/**
477 * Parses exported namespaces from the current source file.
478 *
479 * @category parsing
480 * @since 0.6.0
481 */
482export const parseNamespaces = Effect.gen(function*() {
483 const source = yield* Source
484 return yield* parseModuleDeclarations(source.sourceFile.getModules())
485})
486
487const getTypeParameters = (tps: ReadonlyArray<ast.TypeParameterDeclaration>): string =>
488 tps.length === 0 ? "" : `<${tps.map((p) => p.getName()).join(", ")}>`
489
490const parseMethod = (md: ast.MethodDeclaration) =>
491 Effect.gen(function*() {
492 const name = md.getName()
493 const jsdocs = Array.matchLeft(md.getOverloads(), {
494 onEmpty: () => md.getJsDocs(),
495 onNonEmpty: (head) => head.getJsDocs()
496 })
497 const doc = parseDoc(getJSDocText(jsdocs))
498 if (shouldIgnore(doc)) {
499 return Option.none()
500 }
501 const type = parseType(md)
502 const signature = `declare const ${name}: ${type}`
503 const position = yield* parsePosition(md)
504 return Option.some(
505 new Domain.DocEntry(
506 name,
507 doc,
508 signature,
509 position
510 )
511 )
512 })
513
514const parseProperty = (pd: ast.PropertyDeclaration) =>
515 Effect.gen(function*() {
516 const doc = parseDoc(getJSDocText(pd.getJsDocs()))
517 if (shouldIgnore(doc)) {
518 return []
519 }
520 const name = pd.getName()
521 const type = parseType(pd)
522 const readonly = pipe(
523 Option.fromNullishOr(pd.getFirstModifierByKind(ast.ts.SyntaxKind.ReadonlyKeyword)),
524 Option.match({
525 onNone: () => "",
526 onSome: () => "readonly "
527 })
528 )
529 const signature = `${readonly}${name}: ${type}`
530 const position = yield* parsePosition(pd)
531 return [
532 new Domain.DocEntry(
533 name,
534 doc,
535 signature,
536 position
537 )
538 ]
539 })
540
541const parseProperties = (c: ast.ClassDeclaration) => {
542 const properties = Array.filter(
543 c.getProperties(),
544 (pd) =>
545 !pd.isStatic() && pipe(
546 pd.getFirstModifierByKind(ast.ts.SyntaxKind.PrivateKeyword),
547 Option.fromNullishOr,
548 Option.isNone
549 )
550 )
551 return Effect.forEach(properties, parseProperty).pipe(Effect.map(Array.flatten))
552}
553
554/**
555 * @internal
556 */
557export const getConstructorDeclarationSignature = (
558 c: ast.ConstructorDeclaration
559): string =>
560 pipe(
561 Option.fromNullishOr(c.compilerNode.body),
562 Option.match({
563 onNone: () => c.getText(),
564 onSome: (body) => {
565 const end = body.getStart() - c.getStart() - 1
566 return c.getText().substring(0, end)
567 }
568 })
569 )
570
571const getClassDeclarationSignature = (c: ast.ClassDeclaration) => {
572 const name = c.getName() ?? ""
573 return pipe(
574 Effect.succeed(getTypeParameters(c.getTypeParameters())),
575 Effect.map((typeParameters) =>
576 pipe(
577 c.getConstructors(),
578 Array.matchLeft({
579 onEmpty: () => `declare class ${name}${typeParameters}`,
580 onNonEmpty: (head) =>
581 `declare class ${name}${typeParameters} { ${
582 getConstructorDeclarationSignature(
583 head
584 )
585 } }`
586 })
587 )
588 )
589 )
590}
591
592const parseClass = (c: ast.ClassDeclaration) =>
593 Effect.gen(function*() {
594 const doc = parseDoc(getJSDocText(c.getJsDocs()))
595 if (shouldIgnore(doc)) {
596 return []
597 }
598 const name = c.getName() ?? ""
599 const signature = yield* getClassDeclarationSignature(c)
600 const methods = yield* pipe(
601 c.getInstanceMethods(),
602 Effect.forEach(parseMethod),
603 Effect.map(Array.getSomes)
604 )
605 const staticMethods = yield* pipe(
606 c.getStaticMethods(),
607 Effect.forEach(parseMethod),
608 Effect.map(Array.getSomes)
609 )
610 const properties = yield* parseProperties(c)
611 const position = yield* parsePosition(c)
612 return [
613 new Domain.Class(
614 name,
615 doc,
616 signature,
617 position,
618 methods,
619 staticMethods,
620 properties
621 )
622 ]
623 })
624
625/**
626 * Parses exported classes and their documented members from the current source file.
627 *
628 * @category parsing
629 * @since 0.6.0
630 */
631export const parseClasses = Effect.gen(function*() {
632 const source = yield* Source
633 const exportedClasses = source.sourceFile.getClasses().filter((cd) => cd.isExported())
634 return yield* Effect.forEach(exportedClasses, parseClass).pipe(Effect.map(Array.flatten))
635})
636
637/**
638 * @internal
639 */
640export const parseModuleDocumentation = Effect.gen(function*() {
641 const source = yield* Source
642 const statements = source.sourceFile.getStatements()
643 const ofirstStatement = Array.head(statements)
644 if (Option.isSome(ofirstStatement)) {
645 const oDocComment = getDocComment(ofirstStatement.value.getLeadingCommentRanges())
646 if (Option.isSome(oDocComment)) {
647 return parseDoc(oDocComment.value.getText())
648 }
649 }
650 return parseDoc("")
651})
652
653/**
654 * Parses the current source file into a module documentation model.
655 *
656 * @category parsing
657 * @since 0.6.0
658 */
659export const parseModule = Effect.gen(function*() {
660 const source = yield* Source
661 const doc = yield* parseModuleDocumentation
662 const interfaces = yield* parseInterfaces
663 const functions = yield* parseFunctions
664 const typeAliases = yield* parseTypeAliases
665 const classes = yield* parseClasses
666 const constants = yield* parseConstants
667 const exports = yield* parseExports
668 const namespaces = yield* parseNamespaces
669 const name = source.sourceFile.getBaseName()
670 return new Domain.Module(
671 source,
672 name,
673 doc,
674 source.path,
675 classes,
676 interfaces,
677 functions,
678 typeAliases,
679 constants,
680 exports,
681 namespaces
682 )
683})
684
685/**
686 * @internal
687 */
688export const parseFile =
689 (project: ast.Project) =>
690 (file: Domain.File): Effect.Effect<Domain.Module, Array<string>, Configuration.Configuration | Path.Path> => {
691 return Effect.gen(function*() {
692 const path = yield* Path.Path
693 const sourceFile = project.getSourceFile(file.path)
694 const filePath = file.path.split(path.sep)
695 if (sourceFile !== undefined && Array.isArrayNonEmpty(filePath)) {
696 return yield* Effect.provideService(parseModule, Source, { sourceFile, path: filePath })
697 }
698 return yield* Effect.fail([`Unable to locate file: ${file.path}`])
699 })
700 }
701
702const createProject = (files: ReadonlyArray<Domain.File>) =>
703 Effect.gen(function*() {
704 const config = yield* Configuration.Configuration
705 const process = yield* Domain.Process
706 const cwd = yield* process.cwd
707 // Convert the raw config into a format that TS/TS-Morph expects
708 const parsed = ast.ts.parseJsonConfigFileContent(
709 {
710 compilerOptions: {
711 strict: true,
712 moduleResolution: "node",
713 ...config.parseCompilerOptions
714 }
715 },
716 ast.ts.sys,
717 cwd
718 )
719
720 const options: ast.ProjectOptions = {
721 compilerOptions: parsed.options
722 }
723 const project = new ast.Project(options)
724 for (const file of files) {
725 project.addSourceFileAtPath(file.path)
726 }
727 return project
728 })
729
730/**
731 * Parses source files into module documentation models sorted by path.
732 *
733 * @category parsing
734 * @since 0.6.0
735 */
736export const parseFiles = (files: ReadonlyArray<Domain.File>) =>
737 createProject(files).pipe(
738 Effect.flatMap((project) =>
739 pipe(
740 files,
741 Effect.validate(parseFile(project)),
742 Effect.map(sortModulesByPath)
743 )
744 )
745 )