From e0b1bb33d77babe881f77f52cb1b71e345f5696b Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Sat, 9 Sep 2023 11:34:04 -0700 Subject: [PATCH] feat(compiler): extract doc info for JsDoc (#51733) Based on top of #51713 This commit adds docs extraction for information provided in JsDoc comments, including descriptions and Jsdoc tags. PR Close #51733 --- .../src/ngtsc/docs/src/class_extractor.ts | 14 +- .../src/ngtsc/docs/src/constant_extractor.ts | 14 +- .../src/ngtsc/docs/src/entities.ts | 10 + .../src/ngtsc/docs/src/function_extractor.ts | 6 +- .../src/ngtsc/docs/src/jsdoc_extractor.ts | 45 ++++ .../constant_doc_extraction_spec.ts | 2 +- .../doc_extraction/jsdoc_extraction_spec.ts | 252 ++++++++++++++++++ 7 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor.ts create mode 100644 packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts diff --git a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts index 9f0def4d63713..305448256a8b0 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts @@ -7,6 +7,7 @@ */ import {FunctionExtractor} from '@angular/compiler-cli/src/ngtsc/docs/src/function_extractor'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from '@angular/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor'; import ts from 'typescript'; import {Reference} from '../../imports'; @@ -22,7 +23,7 @@ type PropertyDeclarationLike = ts.PropertyDeclaration|ts.AccessorDeclaration; /** Extractor to pull info for API reference documentation for a TypeScript class. */ class ClassExtractor { constructor( - protected declaration: ClassDeclaration, + protected declaration: ClassDeclaration&ts.ClassDeclaration, protected reference: Reference, protected typeChecker: ts.TypeChecker, ) {} @@ -32,7 +33,10 @@ class ClassExtractor { return { name: this.declaration.name!.text, entryType: EntryType.UndecoratedClass, - members: this.extractAllClassMembers(this.declaration as ts.ClassDeclaration), + members: this.extractAllClassMembers(this.declaration), + description: extractJsDocDescription(this.declaration), + jsdocTags: extractJsDocTags(this.declaration), + rawComment: extractRawJsDoc(this.declaration), }; } @@ -84,6 +88,8 @@ class ClassExtractor { type: extractResolvedTypeString(propertyDeclaration, this.typeChecker), memberType: MemberType.Property, memberTags: this.getMemberTags(propertyDeclaration), + description: extractJsDocDescription(propertyDeclaration), + jsdocTags: extractJsDocTags(propertyDeclaration), }; } @@ -154,7 +160,7 @@ class ClassExtractor { /** Extractor to pull info for API reference documentation for an Angular directive. */ class DirectiveExtractor extends ClassExtractor { constructor( - declaration: ClassDeclaration, + declaration: ClassDeclaration&ts.ClassDeclaration, reference: Reference, protected metadata: DirectiveMeta, checker: ts.TypeChecker, @@ -207,7 +213,7 @@ class DirectiveExtractor extends ClassExtractor { /** Extracts documentation info for a class, potentially including Angular-specific info. */ export function extractClass( - classDeclaration: ClassDeclaration, metadataReader: MetadataReader, + classDeclaration: ClassDeclaration&ts.ClassDeclaration, metadataReader: MetadataReader, typeChecker: ts.TypeChecker): ClassEntry { const ref = new Reference(classDeclaration); const metadata = metadataReader.getDirectiveMetadata(ref); diff --git a/packages/compiler-cli/src/ngtsc/docs/src/constant_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/constant_extractor.ts index 08ed1f7adf468..a347010b794d7 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/constant_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/constant_extractor.ts @@ -9,23 +9,33 @@ import ts from 'typescript'; import {ConstantEntry, EntryType} from './entities'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc,} from './jsdoc_extractor'; /** Extracts documentation entry for a constant. */ export function extractConstant( declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): ConstantEntry { // For constants specifically, we want to get the base type for any literal types. - // For example, TypeScript by default extacts `const PI = 3.14` as PI having a type of the + // For example, TypeScript by default extracts `const PI = 3.14` as PI having a type of the // literal `3.14`. We don't want this behavior for constants, since generally one wants the // _value_ of the constant to be able to change between releases without changing the type. - // `VERSION` is a good example here- the version is always a `string`, but the actual value of + // `VERSION` is a good example here; the version is always a `string`, but the actual value of // the version string shouldn't matter to the type system. const resolvedType = typeChecker.getBaseTypeOfLiteralType(typeChecker.getTypeAtLocation(declaration)); + // In the TS AST, the leading comment for a variable declaration is actually + // on the ancestor `ts.VariableStatement` (since a single variable statement may + // contain multiple variable declarations). + const variableStatement = declaration.parent.parent; + const rawComment = extractRawJsDoc(declaration.parent.parent); + return { name: declaration.name.getText(), type: typeChecker.typeToString(resolvedType), entryType: EntryType.Constant, + rawComment, + description: extractJsDocDescription(declaration), + jsdocTags: extractJsDocTags(declaration), }; } diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index 060adaf3a075f..5b94a0835ed31 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -40,10 +40,18 @@ export enum MemberTags { Output = 'output', } +export interface JsDocTagEntry { + name: string; + comment: string; +} + /** Base type for all documentation entities. */ export interface DocEntry { entryType: EntryType; name: string; + description: string; + rawComment: string; + jsdocTags: JsDocTagEntry[]; } /** Documentation entity for a constant. */ @@ -73,6 +81,8 @@ export interface MemberEntry { name: string; memberType: MemberType; memberTags: MemberTags[]; + description: string; + jsdocTags: JsDocTagEntry[]; } /** Sub-entry for a class property. */ diff --git a/packages/compiler-cli/src/ngtsc/docs/src/function_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/function_extractor.ts index 38f329a09f457..0ae32d3b5006b 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/function_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/function_extractor.ts @@ -7,6 +7,7 @@ */ import {EntryType, FunctionEntry, ParameterEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from '@angular/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor'; import ts from 'typescript'; import {extractResolvedTypeString} from './type_extractor'; @@ -32,13 +33,16 @@ export class FunctionExtractor { name: this.declaration.name!.getText(), returnType, entryType: EntryType.Function, + description: extractJsDocDescription(this.declaration), + jsdocTags: extractJsDocTags(this.declaration), + rawComment: extractRawJsDoc(this.declaration), }; } private extractAllParams(params: ts.NodeArray): ParameterEntry[] { return params.map(param => ({ name: param.name.getText(), - description: 'TODO', + description: extractJsDocDescription(param), type: extractResolvedTypeString(param, this.typeChecker), isOptional: !!(param.questionToken || param.initializer), isRestParam: !!param.dotDotDotToken, diff --git a/packages/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor.ts new file mode 100644 index 0000000000000..224bbe1a8616a --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import ts from 'typescript'; + +import {JsDocTagEntry} from './entities'; + + +/** Gets the set of JsDoc tags applied to a node. */ +export function extractJsDocTags(node: ts.HasJSDoc): JsDocTagEntry[] { + return ts.getJSDocTags(node).map(t => ({ + name: t.tagName.getText(), + comment: ts.getTextOfJSDocComment(t.comment) ?? '', + })); +} + +/** + * Gets the JsDoc description for a node. If the node does not have + * a description, returns the empty string. + */ +export function extractJsDocDescription(node: ts.HasJSDoc): string { + // If the node is a top-level statement (const, class, function, etc.), we will get + // a `ts.JSDoc` here. If the node is a `ts.ParameterDeclaration`, we will get + // a `ts.JSDocParameterTag`. + const commentOrTag = ts.getJSDocCommentsAndTags(node).find(d => { + return ts.isJSDoc(d) || ts.isJSDocParameterTag(d); + }); + + const comment = commentOrTag?.comment ?? ''; + return typeof comment === 'string' ? comment : ts.getTextOfJSDocComment(comment) ?? ''; +} + +/** + * Gets the raw JsDoc applied to a node. If the node does not have a JsDoc block, + * returns the empty string. + */ +export function extractRawJsDoc(node: ts.HasJSDoc): string { + // Assume that any node has at most one JsDoc block. + return ts.getJSDocCommentsAndTags(node).find(ts.isJSDoc)?.getFullText() ?? ''; +} diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/constant_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/constant_doc_extraction_spec.ts index 14b6837a1c425..f70bc596fcc5c 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/constant_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/constant_doc_extraction_spec.ts @@ -38,7 +38,7 @@ runInEachFileSystem(os => { expect(constantEntry.type).toBe('string'); }); - it('should extract mutliple constant declarations in a single statement', () => { + it('should extract multiple constant declarations in a single statement', () => { env.write('test.ts', ` export const PI = 3.14, VERSION = '16.0.0'; `); diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts new file mode 100644 index 0000000000000..a48e2d7cb7758 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs'; +import {ConstantEntry, EntryType, FunctionEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing'; + +import {NgtscTestEnvironment} from '../env'; + +const testFiles = loadStandardTestFiles({fakeCore: true, fakeCommon: true}); + +runInEachFileSystem(os => { + let env!: NgtscTestEnvironment; + + describe('ngtsc jsdoc extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract jsdoc from all types of top-level statement', () => { + env.write('test.ts', ` + /** This is a constant. */ + export const PI = 3.14; + + /** This is a class. */ + export class UserProfile { } + + /** This is a function. */ + export function save() { } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(3); + + const [piEntry, userProfileEntry, saveEntry] = docs; + expect(piEntry.description).toBe('This is a constant.'); + expect(userProfileEntry.description).toBe('This is a class.'); + expect(saveEntry.description).toBe('This is a function.'); + }); + + it('should extract raw comment blocks', () => { + env.write('test.ts', ` + /** This is a constant. */ + export const PI = 3.14; + + /** + * Long comment + * with multiple lines. + */ + export class UserProfile { } + + /** + * This is a long JsDoc block + * that extends multiple lines. + * + * @deprecated in includes multiple tags. + * @experimental here is another one + */ + export function save() { } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(3); + + const [piEntry, userProfileEntry, saveEntry] = docs; + expect(piEntry.rawComment).toBe('/** This is a constant. */'); + expect(userProfileEntry.rawComment).toBe(` + /** + * Long comment + * with multiple lines. + */`.trim()); + expect(saveEntry.rawComment).toBe(` + /** + * This is a long JsDoc block + * that extends multiple lines. + * + * @deprecated in includes multiple tags. + * @experimental here is another one + */`.trim()); + }); + + it('should extract a description from a single-line jsdoc', () => { + env.write('test.ts', ` + /** Framework version. */ + export const VERSION = '16'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Framework version.'); + expect(docs[0].jsdocTags.length).toBe(0); + }); + + it('should extract a description from a multi-line jsdoc', () => { + env.write('test.ts', ` + /** + * This is a really long description that needs + * to wrap over multiple lines. + */ + export const LONG_VERSION = '16.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description) + .toBe('This is a really long description that needs\nto wrap over multiple lines.'); + expect(docs[0].jsdocTags.length).toBe(0); + }); + + it('should extract jsdoc with an empty tag', () => { + env.write('test.ts', ` + /** + * Unsupported version. + * @deprecated + */ + export const OLD_VERSION = '1.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Unsupported version.'); + expect(docs[0].jsdocTags.length).toBe(1); + expect(docs[0].jsdocTags[0]).toEqual({name: 'deprecated', comment: ''}); + }); + + it('should extract jsdoc with a single-line tag', () => { + env.write('test.ts', ` + /** + * Unsupported version. + * @deprecated Use the newer one. + */ + export const OLD_VERSION = '1.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Unsupported version.'); + expect(docs[0].jsdocTags.length).toBe(1); + expect(docs[0].jsdocTags[0]).toEqual({name: 'deprecated', comment: 'Use the newer one.'}); + }); + + it('should extract jsdoc with a multi-line tags', () => { + env.write('test.ts', ` + /** + * Unsupported version. + * @deprecated Use the newer one. + * Or use something else. + * @experimental This is another + * long comment that wraps. + */ + export const OLD_VERSION = '1.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Unsupported version.'); + expect(docs[0].jsdocTags.length).toBe(2); + + const [deprecatedEntry, experimentalEntry] = docs[0].jsdocTags; + expect(deprecatedEntry).toEqual({ + name: 'deprecated', + comment: 'Use the newer one.\nOr use something else.', + }); + expect(experimentalEntry).toEqual({ + name: 'experimental', + comment: 'This is another\nlong comment that wraps.', + }); + }); + + it('should extract jsdoc with custom tags', () => { + env.write('test.ts', ` + /** + * Unsupported version. + * @ancient Use the newer one. + * Or use something else. + */ + export const OLD_VERSION = '1.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Unsupported version.'); + expect(docs[0].jsdocTags.length).toBe(1); + expect(docs[0].jsdocTags[0]).toEqual({ + name: 'ancient', + comment: 'Use the newer one.\nOr use something else.', + }); + }); + + it('should extract a @see jsdoc tag', () => { + // "@see" has special behavior with links, so we have tests + // specifically for this tag. + env.write('test.ts', ` + import {Component} from '@angular/core'; + + /** + * Future version. + * @see {@link Component} + */ + export const NEW_VERSION = '99.0.0'; + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + expect(docs[0].description).toBe('Future version.'); + expect(docs[0].jsdocTags.length).toBe(1); + + // It's not clear why TypeScript's JsDoc handling puts a space after + // "Component" here, but we'll accept this as-is. + expect(docs[0].jsdocTags[0]).toEqual({ + name: 'see', + comment: '{@link Component }', + }); + }); + + it('should extract function parameter descriptions', () => { + env.write('test.ts', ` + /** + * Save some data. + * @param data The data to save. + * @param timing Long description + * with multiple lines. + */ + export function save(data: object, timing: number): void { } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + + const functionEntry = docs[0] as FunctionEntry; + expect(functionEntry.description).toBe('Save some data.'); + + const [dataEntry, timingEntry] = functionEntry.params; + expect(dataEntry.description).toBe('The data to save.'); + expect(timingEntry.description).toBe('Long description\nwith multiple lines.'); + }); + }); +});