diff --git a/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts new file mode 100644 index 00000000000..724527f1213 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts @@ -0,0 +1,132 @@ +/** + * @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 {extractInterface} from './class_extractor'; +import {DecoratorEntry, DecoratorType, EntryType, PropertyEntry} from './entities'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor'; + +/** Extracts an API documentation entry for an Angular decorator. */ +export function extractorDecorator( + declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): DecoratorEntry { + const documentedNode = getDecoratorJsDocNode(declaration); + + const decoratorType = getDecoratorType(declaration); + if (!decoratorType) { + throw new Error(`"${declaration.name.getText()} is not a decorator."`); + } + + return { + name: declaration.name.getText(), + decoratorType: decoratorType, + entryType: EntryType.Decorator, + rawComment: extractRawJsDoc(documentedNode), + description: extractJsDocDescription(documentedNode), + jsdocTags: extractJsDocTags(documentedNode), + options: getDecoratorOptions(declaration, typeChecker), + }; +} + +/** Gets whether the given variable declaration is an Angular decorator declaration. */ +export function isDecoratorDeclaration(declaration: ts.VariableDeclaration): boolean { + return !!getDecoratorType(declaration); +} + +/** Gets whether an interface is the options interface for a decorator in the same file. */ +export function isDecoratorOptionsInterface(declaration: ts.InterfaceDeclaration): boolean { + return declaration.getSourceFile().statements.some( + s => ts.isVariableStatement(s) && + s.declarationList.declarations.some( + d => isDecoratorDeclaration(d) && d.name.getText() === declaration.name.getText())); +} + +/** Gets the type of decorator, or undefined if the declaration is not a decorator. */ +function getDecoratorType(declaration: ts.VariableDeclaration): DecoratorType|undefined { + // All Angular decorators are initialized with one of `makeDecorator`, `makePropDecorator`, + // or `makeParamDecorator`. + const initializer = declaration.initializer?.getFullText() ?? ''; + if (initializer.includes('makeDecorator')) return DecoratorType.Class; + if (initializer.includes('makePropDecorator')) return DecoratorType.Member; + if (initializer.includes('makeParamDecorator')) return DecoratorType.Parameter; + + return undefined; +} + +/** Gets the doc entry for the options object for an Angular decorator */ +function getDecoratorOptions( + declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): PropertyEntry[] { + const name = declaration.name.getText(); + + // Every decorator has an interface with its options in the same SourceFile. + // Queries, however, are defined as a type alias pointing to an interface. + const optionsDeclaration = declaration.getSourceFile().statements.find(node => { + return (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && + node.name.getText() === name; + }); + + if (!optionsDeclaration) { + throw new Error(`Decorator "${name}" has no corresponding options interface.`); + } + + let optionsInterface: ts.InterfaceDeclaration; + if (ts.isTypeAliasDeclaration(optionsDeclaration)) { + // We hard-code the assumption that if the decorator's option type is a type alias, + // it resolves to a single interface (this is true for all query decorators at time of + // this writing). + const aliasedType = typeChecker.getTypeAtLocation((optionsDeclaration.type)); + optionsInterface = (aliasedType.getSymbol()?.getDeclarations() ?? + []).find(d => ts.isInterfaceDeclaration(d)) as ts.InterfaceDeclaration; + } else { + optionsInterface = optionsDeclaration as ts.InterfaceDeclaration; + } + + if (!optionsInterface || !ts.isInterfaceDeclaration(optionsInterface)) { + throw new Error(`Options for decorator "${name}" is not an interface.`); + } + + // Take advantage of the interface extractor to pull the appropriate member info. + // Hard code the knowledge that decorator options only have properties, never methods. + return extractInterface(optionsInterface, typeChecker).members as PropertyEntry[]; +} + +/** + * Gets the call signature node that has the decorator's public JsDoc block. + * + * Every decorator has three parts: + * - A const that has the actual decorator. + * - An interface with the same name as the const that documents the decorator's options. + * - An interface suffixed with "Decorator" that has the decorator's call signature and JsDoc block. + * + * For the description and JsDoc tags, we need the interface suffixed with "Decorator". + */ +function getDecoratorJsDocNode(declaration: ts.VariableDeclaration): ts.HasJSDoc { + const name = declaration.name.getText(); + + // Assume the existence of an interface in the same file with the same name + // suffixed with "Decorator". + const decoratorInterface = declaration.getSourceFile().statements.find(s => { + return ts.isInterfaceDeclaration(s) && s.name.getText() === `${name}Decorator`; + }); + + if (!decoratorInterface || !ts.isInterfaceDeclaration(decoratorInterface)) { + throw new Error(`No interface "${name}Decorator" found.`); + } + + // The public-facing JsDoc for each decorator is on one of its interface's call signatures. + const callSignature = decoratorInterface.members.find(node => { + // The description block lives on one of the call signatures for this interface. + return ts.isCallSignatureDeclaration(node) && extractRawJsDoc(node); + }); + + if (!callSignature || !ts.isCallSignatureDeclaration(callSignature)) { + throw new Error(`No call signature with JsDoc on "${name}Decorator"`); + } + + return callSignature; +} diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index 83e755921f3..af824389ab4 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -32,6 +32,12 @@ export enum MemberType { EnumItem = 'enum_item', } +export enum DecoratorType { + Class = 'class', + Member = 'member', + Parameter = 'parameter', +} + /** Informational tags applicable to class members. */ export enum MemberTags { Abstract = 'abstract', @@ -90,6 +96,12 @@ export interface EnumEntry extends DocEntry { members: EnumMemberEntry[]; } +/** Documentation entity for an Angular decorator. */ +export interface DecoratorEntry extends DocEntry { + decoratorType: DecoratorType; + options: PropertyEntry[]; +} + /** Documentation entity for an Angular directives and components. */ export interface DirectiveEntry extends ClassEntry { selector: string; diff --git a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts index 32bcda3bab6..4888d342808 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts @@ -6,8 +6,6 @@ * 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'; import {MetadataReader} from '../../metadata'; @@ -15,8 +13,11 @@ import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflectio import {extractClass, extractInterface} from './class_extractor'; import {extractConstant, isSyntheticAngularConstant} from './constant_extractor'; +import {extractorDecorator, isDecoratorDeclaration, isDecoratorOptionsInterface} from './decorator_extractor'; import {DocEntry} from './entities'; +import {extractEnum} from './enum_extractor'; import {isAngularPrivateName} from './filters'; +import {FunctionExtractor} from './function_extractor'; import {extractTypeAlias} from './type_alias_extractor'; type DeclarationWithExportName = readonly[string, ts.Declaration]; @@ -60,7 +61,7 @@ export class DocsExtractor { return extractClass(node, this.metadataReader, this.typeChecker); } - if (ts.isInterfaceDeclaration(node)) { + if (ts.isInterfaceDeclaration(node) && !isIgnoredInterface(node)) { return extractInterface(node, this.typeChecker); } @@ -70,7 +71,8 @@ export class DocsExtractor { } if (ts.isVariableDeclaration(node) && !isSyntheticAngularConstant(node)) { - return extractConstant(node, this.typeChecker); + return isDecoratorDeclaration(node) ? extractorDecorator(node, this.typeChecker) : + extractConstant(node, this.typeChecker); } if (ts.isTypeAliasDeclaration(node)) { @@ -118,3 +120,13 @@ export class DocsExtractor { ([a, declarationA], [b, declarationB]) => declarationA.pos - declarationB.pos); } } + +/** Gets whether an interface should be ignored for docs extraction. */ +function isIgnoredInterface(node: ts.InterfaceDeclaration) { + // We filter out all interfaces that end with "Decorator" because we capture their + // types as part of the main decorator entry (which are declared as constants). + // This approach to dealing with decorators is admittedly fuzzy, but this aspect of + // the framework's source code is unlikely to change. We also filter out the interfaces + // that contain the decorator options. + return node.name.getText().endsWith('Decorator') || isDecoratorOptionsInterface(node); +} diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts new file mode 100644 index 00000000000..f3ea0484131 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts @@ -0,0 +1,157 @@ +/** + * @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 {DecoratorEntry, DecoratorType, EntryType} 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(() => { + let env!: NgtscTestEnvironment; + + describe('ngtsc decorator docs extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract class decorators that define options in an interface', () => { + env.write('index.ts', ` + export interface Component { + /** The template. */ + template: string; + } + + export interface ComponentDecorator { + /** The description. */ + (obj?: Component): any; + } + + function makeDecorator(): ComponentDecorator { return () => {}; } + + export const Component: ComponentDecorator = makeDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Component'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Class); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('template'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The template.'); + }); + + it('should extract property decorators', () => { + env.write('index.ts', ` + export interface Input { + /** The alias. */ + alias: string; + } + + export interface InputDecorator { + /** The description. */ + (alias: string): any; + } + + function makePropDecorator(): InputDecorator { return () => {}); } + + export const Input: InputDecorator = makePropDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Input'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('alias'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The alias.'); + }); + + it('should extract property decorators with a type alias', () => { + env.write('index.ts', ` + interface Query { + /** The read. */ + read: string; + } + + export type ViewChild = Query; + + export interface ViewChildDecorator { + /** The description. */ + (alias: string): any; + } + + function makePropDecorator(): ViewChildDecorator { return () => {}); } + + export const ViewChild: ViewChildDecorator = makePropDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('ViewChild'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('read'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The read.'); + }); + + it('should extract param decorators', () => { + env.write('index.ts', ` + export interface Inject { + /** The token. */ + token: string; + } + + export interface InjectDecorator { + /** The description. */ + (token: string) => any; + } + + function makePropDecorator(): InjectDecorator { return () => {}; } + + export const Inject: InjectDecorator = makeParamDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Inject'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Parameter); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('token'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The token.'); + }); + }); +});