refactor(compiler): extract decorator API docs (#52389)

This commit adds decorators to the extracted API docs. It makes some
very hard-coded assumptions about the pattern used to declare decorators
that's extremely specific to what the framework does today.

PR Close #52389
This commit is contained in:
Jeremy Elbourn
2023-10-24 16:57:27 -07:00
committed by Alex Rickabaugh
parent edea54714d
commit 740d46f93b
4 changed files with 317 additions and 4 deletions
@@ -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;
}
@@ -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;
@@ -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);
}
@@ -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.');
});
});
});