From 2e41488296879685b19dfba8d78037690347bda3 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Mon, 11 Sep 2023 20:05:37 -0700 Subject: [PATCH] feat(compiler): extract docs info for enums, pipes, and NgModules (#51733) Based on top of #51717 This commit adds extraction for enums, pipes, and NgModules. It also adds a couple of tests for JsDoc extraction that weren't covered in the previous commit. PR Close #51733 --- .../src/ngtsc/docs/src/class_extractor.ts | 70 ++++++++++++-- .../src/ngtsc/docs/src/entities.ts | 21 ++++- .../src/ngtsc/docs/src/enum_extractor.ts | 49 ++++++++++ .../src/ngtsc/docs/src/extractor.ts | 5 + .../enum_doc_extraction_spec.ts | 91 +++++++++++++++++++ .../doc_extraction/jsdoc_extraction_spec.ts | 42 ++++++++- .../ng_module_doc_extraction_spec.ts | 47 ++++++++++ .../pipe_doc_extraction_spec.ts | 73 +++++++++++++++ 8 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/docs/src/enum_extractor.ts create mode 100644 packages/compiler-cli/test/ngtsc/doc_extraction/enum_doc_extraction_spec.ts create mode 100644 packages/compiler-cli/test/ngtsc/doc_extraction/ng_module_doc_extraction_spec.ts create mode 100644 packages/compiler-cli/test/ngtsc/doc_extraction/pipe_doc_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 305448256a8b0..f3744880a6bb3 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts @@ -11,10 +11,10 @@ import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from '@angul import ts from 'typescript'; import {Reference} from '../../imports'; -import {DirectiveMeta, InputMapping, InputOrOutput, MetadataReader} from '../../metadata'; +import {DirectiveMeta, InputMapping, InputOrOutput, MetadataReader, NgModuleMeta, PipeMeta} from '../../metadata'; import {ClassDeclaration} from '../../reflection'; -import {ClassEntry, DirectiveEntry, EntryType, MemberEntry, MemberTags, MemberType, MethodEntry, PropertyEntry} from './entities'; +import {ClassEntry, DirectiveEntry, EntryType, MemberEntry, MemberTags, MemberType, MethodEntry, PipeEntry, PropertyEntry} from './entities'; import {extractResolvedTypeString} from './type_extractor'; /** A class member declaration that is *like* a property (including accessors) */ @@ -211,15 +211,69 @@ class DirectiveExtractor extends ClassExtractor { } } +/** Extractor to pull info for API reference documentation for an Angular pipe. */ +class PipeExtractor extends ClassExtractor { + constructor( + declaration: ClassDeclaration&ts.ClassDeclaration, + reference: Reference, + private metadata: PipeMeta, + typeChecker: ts.TypeChecker, + ) { + super(declaration, reference, typeChecker); + } + + override extract(): PipeEntry { + return { + ...super.extract(), + pipeName: this.metadata.name, + entryType: EntryType.Pipe, + isStandalone: this.metadata.isStandalone, + }; + } +} + +/** Extractor to pull info for API reference documentation for an Angular pipe. */ +class NgModuleExtractor extends ClassExtractor { + constructor( + declaration: ClassDeclaration&ts.ClassDeclaration, + reference: Reference, + private metadata: NgModuleMeta, + typeChecker: ts.TypeChecker, + ) { + super(declaration, reference, typeChecker); + } + + override extract(): ClassEntry { + return { + ...super.extract(), + entryType: EntryType.NgModule, + }; + } +} + /** Extracts documentation info for a class, potentially including Angular-specific info. */ export function extractClass( - classDeclaration: ClassDeclaration&ts.ClassDeclaration, metadataReader: MetadataReader, - typeChecker: ts.TypeChecker): ClassEntry { + classDeclaration: ClassDeclaration&ts.ClassDeclaration, + metadataReader: MetadataReader, + typeChecker: ts.TypeChecker, + ): ClassEntry { const ref = new Reference(classDeclaration); - const metadata = metadataReader.getDirectiveMetadata(ref); - const extractor = metadata ? - new DirectiveExtractor(classDeclaration, ref, metadata, typeChecker) : - new ClassExtractor(classDeclaration, ref, typeChecker); + + let extractor: ClassExtractor; + + let directiveMetadata = metadataReader.getDirectiveMetadata(ref); + let pipeMetadata = metadataReader.getPipeMetadata(ref); + let ngModuleMetadata = metadataReader.getNgModuleMetadata(ref); + + if (directiveMetadata) { + extractor = new DirectiveExtractor(classDeclaration, ref, directiveMetadata, typeChecker); + } else if (pipeMetadata) { + extractor = new PipeExtractor(classDeclaration, ref, pipeMetadata, typeChecker); + } else if (ngModuleMetadata) { + extractor = new NgModuleExtractor(classDeclaration, ref, ngModuleMetadata, typeChecker); + } else { + extractor = new ClassExtractor(classDeclaration, ref, typeChecker); + } return extractor.extract(); } diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index 5b94a0835ed31..51b58ad6c481e 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -17,6 +17,7 @@ export enum EntryType { Enum = 'enum', Function = 'function', Interface = 'interface', + NgModule = 'ng_module', Pipe = 'pipe', TypeAlias = 'type_alias', UndecoratedClass = 'undecorated_class', @@ -28,6 +29,7 @@ export enum MemberType { Method = 'method', Getter = 'getter', Setter = 'setter', + EnumItem = 'enum_item', } /** Informational tags applicable to class members. */ @@ -64,6 +66,11 @@ export interface ClassEntry extends DocEntry { members: MemberEntry[]; } +/** Documentation entity for a TypeScript enum. */ +export interface EnumEntry extends DocEntry { + members: EnumMemberEntry[]; +} + /** Documentation entity for an Angular directives and components. */ export interface DirectiveEntry extends ClassEntry { selector: string; @@ -71,12 +78,18 @@ export interface DirectiveEntry extends ClassEntry { isStandalone: boolean; } +export interface PipeEntry extends ClassEntry { + pipeName: string; + isStandalone: boolean; + // TODO: add `isPure`. +} + export interface FunctionEntry extends DocEntry { params: ParameterEntry[]; returnType: string; } -/** Sub-entry for a single class member. */ +/** Sub-entry for a single class or enum member. */ export interface MemberEntry { name: string; memberType: MemberType; @@ -85,6 +98,12 @@ export interface MemberEntry { jsdocTags: JsDocTagEntry[]; } +/** Sub-entry for an enum member. */ +export interface EnumMemberEntry extends MemberEntry { + type: string; + value: string; +} + /** Sub-entry for a class property. */ export interface PropertyEntry extends MemberEntry { type: string; diff --git a/packages/compiler-cli/src/ngtsc/docs/src/enum_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/enum_extractor.ts new file mode 100644 index 0000000000000..e9b7d3171d6cb --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/docs/src/enum_extractor.ts @@ -0,0 +1,49 @@ +/** + * @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 {EntryType, EnumEntry, EnumMemberEntry, MemberType} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from '@angular/compiler-cli/src/ngtsc/docs/src/jsdoc_extractor'; +import {extractResolvedTypeString} from '@angular/compiler-cli/src/ngtsc/docs/src/type_extractor'; +import ts from 'typescript'; + + +/** Extracts documentation entry for an enum. */ +export function extractEnum( + declaration: ts.EnumDeclaration, typeChecker: ts.TypeChecker): EnumEntry { + return { + name: declaration.name.getText(), + entryType: EntryType.Enum, + members: extractEnumMembers(declaration, typeChecker), + rawComment: extractRawJsDoc(declaration), + description: extractJsDocDescription(declaration), + jsdocTags: extractJsDocTags(declaration), + }; +} + +/** Extracts doc info for an enum's members. */ +function extractEnumMembers( + declaration: ts.EnumDeclaration, checker: ts.TypeChecker): EnumMemberEntry[] { + return declaration.members.map(member => ({ + name: member.name.getText(), + type: extractResolvedTypeString(member, checker), + value: getEnumMemberValue(member), + memberType: MemberType.EnumItem, + jsdocTags: extractJsDocTags(member), + description: extractJsDocDescription(member), + memberTags: [], + })); +} + +/** Gets the explicitly assigned value for an enum member, or an empty string if there is none. */ +function getEnumMemberValue(memberNode: ts.EnumMember): string { + // If the enum member has a child number literal or string literal, + // we use that literal as the "value" of the member. + const literal = + memberNode.getChildren().find(n => ts.isNumericLiteral(n) || ts.isStringLiteral(n)); + return literal?.getText() ?? ''; +} diff --git a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts index 49cea2ed44c5b..d65ca77a63836 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {extractEnum} from '@angular/compiler-cli/src/ngtsc/docs/src/enum_extractor'; import {FunctionExtractor} from '@angular/compiler-cli/src/ngtsc/docs/src/function_extractor'; import ts from 'typescript'; @@ -51,6 +52,10 @@ export class DocsExtractor { } }); } + + if (ts.isEnumDeclaration(statement)) { + entries.push(extractEnum(statement, this.typeChecker)); + } } return entries; diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/enum_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/enum_doc_extraction_spec.ts new file mode 100644 index 0000000000000..10178d40c68d1 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/enum_doc_extraction_spec.ts @@ -0,0 +1,91 @@ +/** + * @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 {ClassEntry, DirectiveEntry, EntryType, EnumEntry, MemberTags, PropertyEntry} 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 enum docs extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract enum info without explicit values', () => { + env.write('test.ts', ` + export enum PizzaTopping { + /** It is cheese */ + Cheese, + + /** Or "tomato" if you are British */ + Tomato, + } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + + expect(docs.length).toBe(1); + expect(docs[0].entryType).toBe(EntryType.Enum); + + const enumEntry = docs[0] as EnumEntry; + expect(enumEntry.name).toBe('PizzaTopping'); + expect(enumEntry.members.length).toBe(2); + + const [cheeseEntry, tomatoEntry] = enumEntry.members; + + expect(cheeseEntry.name).toBe('Cheese'); + expect(cheeseEntry.description).toBe('It is cheese'); + expect(cheeseEntry.value).toBe(''); + + expect(tomatoEntry.name).toBe('Tomato'); + expect(tomatoEntry.description).toBe('Or "tomato" if you are British'); + expect(tomatoEntry.value).toBe(''); + }); + + it('should extract enum info with explicit values', () => { + env.write('test.ts', ` + export enum PizzaTopping { + /** It is cheese */ + Cheese = 0, + + /** Or "tomato" if you are British */ + Tomato = "tomato", + } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + + expect(docs.length).toBe(1); + expect(docs[0].entryType).toBe(EntryType.Enum); + + const enumEntry = docs[0] as EnumEntry; + expect(enumEntry.name).toBe('PizzaTopping'); + expect(enumEntry.members.length).toBe(2); + + const [cheeseEntry, tomatoEntry] = enumEntry.members; + + expect(cheeseEntry.name).toBe('Cheese'); + expect(cheeseEntry.description).toBe('It is cheese'); + expect(cheeseEntry.value).toBe('0'); + expect(cheeseEntry.type).toBe('PizzaTopping.Cheese'); + + expect(tomatoEntry.name).toBe('Tomato'); + expect(tomatoEntry.description).toBe('Or "tomato" if you are British'); + expect(tomatoEntry.value).toBe('"tomato"'); + expect(tomatoEntry.type).toBe('PizzaTopping.Tomato'); + }); + }); +}); 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 index a48e2d7cb7758..562c4d996c085 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/jsdoc_extraction_spec.ts @@ -7,7 +7,7 @@ */ import {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs'; -import {ConstantEntry, EntryType, FunctionEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {ClassEntry, FunctionEntry, MethodEntry} 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'; @@ -248,5 +248,45 @@ runInEachFileSystem(os => { expect(dataEntry.description).toBe('The data to save.'); expect(timingEntry.description).toBe('Long description\nwith multiple lines.'); }); + + it('should extract class member descriptions', () => { + env.write('test.ts', ` + export class UserProfile { + /** A user identifier. */ + userId: number = 0; + + /** Name of the user */ + get name(): string { return ''; } + + /** Name of the user */ + set name(value: string) { } + + /** + * Save the user. + * @param config Setting for saving. + * @returns Whether it succeeded + */ + save(config: object): boolean { return false; } + } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + expect(docs.length).toBe(1); + const classEntry = docs[0] as ClassEntry; + + expect(classEntry.members.length).toBe(4); + const [userIdEntry, nameGetterEntry, nameSetterEntry, ] = classEntry.members; + + expect(userIdEntry.description).toBe('A user identifier.'); + expect(nameGetterEntry.description).toBe('Name of the user'); + expect(nameSetterEntry.description).toBe('Name of the user'); + + const saveEntry = classEntry.members[3] as MethodEntry; + expect(saveEntry.description).toBe('Save the user.'); + + expect(saveEntry.params[0].description).toBe('Setting for saving.'); + expect(saveEntry.jsdocTags.length).toBe(2); + expect(saveEntry.jsdocTags[1]).toEqual({name: 'returns', comment: 'Whether it succeeded'}); + }); }); }); diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/ng_module_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/ng_module_doc_extraction_spec.ts new file mode 100644 index 0000000000000..8147a3a6c4703 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/ng_module_doc_extraction_spec.ts @@ -0,0 +1,47 @@ +/** + * @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 {ClassEntry, DirectiveEntry, EntryType, MemberTags, PropertyEntry} 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 NgModule docs extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract NgModule info', () => { + env.write('test.ts', ` + import {Directive, NgModule} from '@angular/core'; + + @Directive({selector: 'some-tag'}) + export class SomeDirective { } + + @NgModule({declarations: [SomeDirective]}) + export class SomeNgModule { } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + + expect(docs.length).toBe(2); + expect(docs[1].entryType).toBe(EntryType.NgModule); + + const ngModuleEntry = docs[1] as ClassEntry; + expect(ngModuleEntry.name).toBe('SomeNgModule'); + }); + }); +}); diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/pipe_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/pipe_doc_extraction_spec.ts new file mode 100644 index 0000000000000..c344e6b05c36e --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/pipe_doc_extraction_spec.ts @@ -0,0 +1,73 @@ +/** + * @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 {ClassEntry, DirectiveEntry, EntryType, MemberTags, PipeEntry, PropertyEntry} 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 pipe docs extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract standalone pipe info', () => { + env.write('test.ts', ` + import {Pipe} from '@angular/core'; + @Pipe({ + standalone: true, + name: 'shorten', + }) + export class ShortenPipe { + transform(value: string): string { return ''; } + } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + + expect(docs.length).toBe(1); + expect(docs[0].entryType).toBe(EntryType.Pipe); + + const directiveEntry = docs[0] as PipeEntry; + expect(directiveEntry.isStandalone).toBe(true); + expect(directiveEntry.name).toBe('ShortenPipe'); + expect(directiveEntry.pipeName).toBe('shorten'); + }); + + it('should extract NgModule pipe info', () => { + env.write('test.ts', ` + import {Pipe, NgModule} from '@angular/core'; + @Pipe({name: 'shorten'}) + export class ShortenPipe { + transform(value: string): string { return ''; } + } + + @NgModule({declarations: [ShortenPipe]}) + export class PipeModule { } + `); + + const docs: DocEntry[] = env.driveDocsExtraction(); + + expect(docs.length).toBe(2); + expect(docs[0].entryType).toBe(EntryType.Pipe); + + const directiveEntry = docs[0] as PipeEntry; + expect(directiveEntry.isStandalone).toBe(false); + expect(directiveEntry.name).toBe('ShortenPipe'); + expect(directiveEntry.pipeName).toBe('shorten'); + }); + }); +});