| 1 | /** |
| 2 | * Parses TypeScript declarations and JSDoc into docgen models. |
| 3 | * |
| 4 | * @since 0.6.0 |
| 5 | */ |
| 6 | import * as doctrine from "doctrine" |
| 7 | import * as Array from "effect/Array" |
| 8 | import * as Context from "effect/Context" |
| 9 | import * as Effect from "effect/Effect" |
| 10 | import { pipe } from "effect/Function" |
| 11 | import * as Option from "effect/Option" |
| 12 | import * as Path from "effect/Path" |
| 13 | import * as Record from "effect/Record" |
| 14 | import * as String from "effect/String" |
| 15 | import * as ast from "ts-morph" |
| 16 | import * as Configuration from "./Configuration.ts" |
| 17 | import * 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 | */ |
| 25 | export interface SourceShape { |
| 26 | readonly path: Array.NonEmptyReadonlyArray<string> |
| 27 | readonly sourceFile: ast.SourceFile |
| 28 | } |
| 29 | |
| 30 | /** @internal */ |
| 31 | export class Source extends Context.Service<Source, SourceShape>()("@effect/docgen/Source") {} |
| 32 | |
| 33 | const sortModulesByPath: <A extends Domain.Module>(self: Iterable<A>) => Array<A> = Array |
| 34 | .sort(Domain.ByPath) |
| 35 | |
| 36 | const getJSDocText: (jsdocs: ReadonlyArray<ast.JSDoc>) => string = Array.matchRight({ |
| 37 | onEmpty: () => "", |
| 38 | onNonEmpty: (_, last) => last.getText() |
| 39 | }) |
| 40 | |
| 41 | const 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 | |
| 48 | class 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 | */ |
| 63 | export 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 | |
| 91 | const isVariableDeclarationList = ( |
| 92 | u: ast.VariableDeclarationList | ast.CatchClause |
| 93 | ): u is ast.VariableDeclarationList => u.getKind() === ast.ts.SyntaxKind.VariableDeclarationList |
| 94 | |
| 95 | const 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 | |
| 103 | const 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 | |
| 117 | const shouldIgnore = (doc: Domain.Doc): boolean => { |
| 118 | return Record.has(doc.tags, "internal") || Record.has(doc.tags, "ignore") |
| 119 | } |
| 120 | |
| 121 | const 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 | |
| 140 | const 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 | */ |
| 154 | export const parseInterfaces = Effect.flatMap( |
| 155 | Source, |
| 156 | (source) => parseInterfaceDeclarations(source.sourceFile.getInterfaces()) |
| 157 | ) |
| 158 | |
| 159 | const 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 | |
| 170 | const 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 | |
| 186 | const getFunctionDeclarationJSDocs = (fd: ast.FunctionDeclaration): Array<ast.JSDoc> => |
| 187 | Array.matchLeft(fd.getOverloads(), { |
| 188 | onEmpty: () => fd.getJsDocs(), |
| 189 | onNonEmpty: (firstOverload) => firstOverload.getJsDocs() |
| 190 | }) |
| 191 | |
| 192 | const 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 | |
| 201 | const 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 | |
| 221 | const 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 | |
| 244 | const 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 | */ |
| 277 | export 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 | |
| 288 | const 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 | |
| 309 | const 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 | */ |
| 323 | export const parseTypeAliases = Effect.flatMap( |
| 324 | Source, |
| 325 | (source) => parseTypeAliasDeclarations(source.sourceFile.getTypeAliases()) |
| 326 | ) |
| 327 | |
| 328 | const 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 | */ |
| 355 | export 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 | |
| 380 | const 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 | |
| 397 | const 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 | |
| 417 | const 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 | */ |
| 434 | export 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 | |
| 440 | const 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 | |
| 469 | const 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 | */ |
| 482 | export const parseNamespaces = Effect.gen(function*() { |
| 483 | const source = yield* Source |
| 484 | return yield* parseModuleDeclarations(source.sourceFile.getModules()) |
| 485 | }) |
| 486 | |
| 487 | const getTypeParameters = (tps: ReadonlyArray<ast.TypeParameterDeclaration>): string => |
| 488 | tps.length === 0 ? "" : `<${tps.map((p) => p.getName()).join(", ")}>` |
| 489 | |
| 490 | const 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 | |
| 514 | const 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 | |
| 541 | const 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 | */ |
| 557 | export 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 | |
| 571 | const 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 | |
| 592 | const 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 | */ |
| 631 | export 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 | */ |
| 640 | export 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 | */ |
| 659 | export 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 | */ |
| 688 | export 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 | |
| 702 | const 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 | */ |
| 736 | export 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 | ) |