Skip to content

Commit 740d46f

Browse files
jelbournalxhub
authored andcommitted
refactor(compiler): extract decorator API docs (angular#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 angular#52389
1 parent edea547 commit 740d46f

File tree

4 files changed

+317
-4
lines changed

4 files changed

+317
-4
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import ts from 'typescript';
10+
11+
import {extractInterface} from './class_extractor';
12+
import {DecoratorEntry, DecoratorType, EntryType, PropertyEntry} from './entities';
13+
import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor';
14+
15+
/** Extracts an API documentation entry for an Angular decorator. */
16+
export function extractorDecorator(
17+
declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): DecoratorEntry {
18+
const documentedNode = getDecoratorJsDocNode(declaration);
19+
20+
const decoratorType = getDecoratorType(declaration);
21+
if (!decoratorType) {
22+
throw new Error(`"${declaration.name.getText()} is not a decorator."`);
23+
}
24+
25+
return {
26+
name: declaration.name.getText(),
27+
decoratorType: decoratorType,
28+
entryType: EntryType.Decorator,
29+
rawComment: extractRawJsDoc(documentedNode),
30+
description: extractJsDocDescription(documentedNode),
31+
jsdocTags: extractJsDocTags(documentedNode),
32+
options: getDecoratorOptions(declaration, typeChecker),
33+
};
34+
}
35+
36+
/** Gets whether the given variable declaration is an Angular decorator declaration. */
37+
export function isDecoratorDeclaration(declaration: ts.VariableDeclaration): boolean {
38+
return !!getDecoratorType(declaration);
39+
}
40+
41+
/** Gets whether an interface is the options interface for a decorator in the same file. */
42+
export function isDecoratorOptionsInterface(declaration: ts.InterfaceDeclaration): boolean {
43+
return declaration.getSourceFile().statements.some(
44+
s => ts.isVariableStatement(s) &&
45+
s.declarationList.declarations.some(
46+
d => isDecoratorDeclaration(d) && d.name.getText() === declaration.name.getText()));
47+
}
48+
49+
/** Gets the type of decorator, or undefined if the declaration is not a decorator. */
50+
function getDecoratorType(declaration: ts.VariableDeclaration): DecoratorType|undefined {
51+
// All Angular decorators are initialized with one of `makeDecorator`, `makePropDecorator`,
52+
// or `makeParamDecorator`.
53+
const initializer = declaration.initializer?.getFullText() ?? '';
54+
if (initializer.includes('makeDecorator')) return DecoratorType.Class;
55+
if (initializer.includes('makePropDecorator')) return DecoratorType.Member;
56+
if (initializer.includes('makeParamDecorator')) return DecoratorType.Parameter;
57+
58+
return undefined;
59+
}
60+
61+
/** Gets the doc entry for the options object for an Angular decorator */
62+
function getDecoratorOptions(
63+
declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): PropertyEntry[] {
64+
const name = declaration.name.getText();
65+
66+
// Every decorator has an interface with its options in the same SourceFile.
67+
// Queries, however, are defined as a type alias pointing to an interface.
68+
const optionsDeclaration = declaration.getSourceFile().statements.find(node => {
69+
return (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) &&
70+
node.name.getText() === name;
71+
});
72+
73+
if (!optionsDeclaration) {
74+
throw new Error(`Decorator "${name}" has no corresponding options interface.`);
75+
}
76+
77+
let optionsInterface: ts.InterfaceDeclaration;
78+
if (ts.isTypeAliasDeclaration(optionsDeclaration)) {
79+
// We hard-code the assumption that if the decorator's option type is a type alias,
80+
// it resolves to a single interface (this is true for all query decorators at time of
81+
// this writing).
82+
const aliasedType = typeChecker.getTypeAtLocation((optionsDeclaration.type));
83+
optionsInterface = (aliasedType.getSymbol()?.getDeclarations() ??
84+
[]).find(d => ts.isInterfaceDeclaration(d)) as ts.InterfaceDeclaration;
85+
} else {
86+
optionsInterface = optionsDeclaration as ts.InterfaceDeclaration;
87+
}
88+
89+
if (!optionsInterface || !ts.isInterfaceDeclaration(optionsInterface)) {
90+
throw new Error(`Options for decorator "${name}" is not an interface.`);
91+
}
92+
93+
// Take advantage of the interface extractor to pull the appropriate member info.
94+
// Hard code the knowledge that decorator options only have properties, never methods.
95+
return extractInterface(optionsInterface, typeChecker).members as PropertyEntry[];
96+
}
97+
98+
/**
99+
* Gets the call signature node that has the decorator's public JsDoc block.
100+
*
101+
* Every decorator has three parts:
102+
* - A const that has the actual decorator.
103+
* - An interface with the same name as the const that documents the decorator's options.
104+
* - An interface suffixed with "Decorator" that has the decorator's call signature and JsDoc block.
105+
*
106+
* For the description and JsDoc tags, we need the interface suffixed with "Decorator".
107+
*/
108+
function getDecoratorJsDocNode(declaration: ts.VariableDeclaration): ts.HasJSDoc {
109+
const name = declaration.name.getText();
110+
111+
// Assume the existence of an interface in the same file with the same name
112+
// suffixed with "Decorator".
113+
const decoratorInterface = declaration.getSourceFile().statements.find(s => {
114+
return ts.isInterfaceDeclaration(s) && s.name.getText() === `${name}Decorator`;
115+
});
116+
117+
if (!decoratorInterface || !ts.isInterfaceDeclaration(decoratorInterface)) {
118+
throw new Error(`No interface "${name}Decorator" found.`);
119+
}
120+
121+
// The public-facing JsDoc for each decorator is on one of its interface's call signatures.
122+
const callSignature = decoratorInterface.members.find(node => {
123+
// The description block lives on one of the call signatures for this interface.
124+
return ts.isCallSignatureDeclaration(node) && extractRawJsDoc(node);
125+
});
126+
127+
if (!callSignature || !ts.isCallSignatureDeclaration(callSignature)) {
128+
throw new Error(`No call signature with JsDoc on "${name}Decorator"`);
129+
}
130+
131+
return callSignature;
132+
}

packages/compiler-cli/src/ngtsc/docs/src/entities.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export enum MemberType {
3232
EnumItem = 'enum_item',
3333
}
3434

35+
export enum DecoratorType {
36+
Class = 'class',
37+
Member = 'member',
38+
Parameter = 'parameter',
39+
}
40+
3541
/** Informational tags applicable to class members. */
3642
export enum MemberTags {
3743
Abstract = 'abstract',
@@ -90,6 +96,12 @@ export interface EnumEntry extends DocEntry {
9096
members: EnumMemberEntry[];
9197
}
9298

99+
/** Documentation entity for an Angular decorator. */
100+
export interface DecoratorEntry extends DocEntry {
101+
decoratorType: DecoratorType;
102+
options: PropertyEntry[];
103+
}
104+
93105
/** Documentation entity for an Angular directives and components. */
94106
export interface DirectiveEntry extends ClassEntry {
95107
selector: string;

packages/compiler-cli/src/ngtsc/docs/src/extractor.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {extractEnum} from '@angular/compiler-cli/src/ngtsc/docs/src/enum_extractor';
10-
import {FunctionExtractor} from '@angular/compiler-cli/src/ngtsc/docs/src/function_extractor';
119
import ts from 'typescript';
1210

1311
import {MetadataReader} from '../../metadata';
1412
import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
1513

1614
import {extractClass, extractInterface} from './class_extractor';
1715
import {extractConstant, isSyntheticAngularConstant} from './constant_extractor';
16+
import {extractorDecorator, isDecoratorDeclaration, isDecoratorOptionsInterface} from './decorator_extractor';
1817
import {DocEntry} from './entities';
18+
import {extractEnum} from './enum_extractor';
1919
import {isAngularPrivateName} from './filters';
20+
import {FunctionExtractor} from './function_extractor';
2021
import {extractTypeAlias} from './type_alias_extractor';
2122

2223
type DeclarationWithExportName = readonly[string, ts.Declaration];
@@ -60,7 +61,7 @@ export class DocsExtractor {
6061
return extractClass(node, this.metadataReader, this.typeChecker);
6162
}
6263

63-
if (ts.isInterfaceDeclaration(node)) {
64+
if (ts.isInterfaceDeclaration(node) && !isIgnoredInterface(node)) {
6465
return extractInterface(node, this.typeChecker);
6566
}
6667

@@ -70,7 +71,8 @@ export class DocsExtractor {
7071
}
7172

7273
if (ts.isVariableDeclaration(node) && !isSyntheticAngularConstant(node)) {
73-
return extractConstant(node, this.typeChecker);
74+
return isDecoratorDeclaration(node) ? extractorDecorator(node, this.typeChecker) :
75+
extractConstant(node, this.typeChecker);
7476
}
7577

7678
if (ts.isTypeAliasDeclaration(node)) {
@@ -118,3 +120,13 @@ export class DocsExtractor {
118120
([a, declarationA], [b, declarationB]) => declarationA.pos - declarationB.pos);
119121
}
120122
}
123+
124+
/** Gets whether an interface should be ignored for docs extraction. */
125+
function isIgnoredInterface(node: ts.InterfaceDeclaration) {
126+
// We filter out all interfaces that end with "Decorator" because we capture their
127+
// types as part of the main decorator entry (which are declared as constants).
128+
// This approach to dealing with decorators is admittedly fuzzy, but this aspect of
129+
// the framework's source code is unlikely to change. We also filter out the interfaces
130+
// that contain the decorator options.
131+
return node.name.getText().endsWith('Decorator') || isDecoratorOptionsInterface(node);
132+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs';
10+
import {DecoratorEntry, DecoratorType, EntryType} from '@angular/compiler-cli/src/ngtsc/docs/src/entities';
11+
import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
12+
import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing';
13+
14+
import {NgtscTestEnvironment} from '../env';
15+
16+
const testFiles = loadStandardTestFiles({fakeCore: true, fakeCommon: true});
17+
18+
runInEachFileSystem(() => {
19+
let env!: NgtscTestEnvironment;
20+
21+
describe('ngtsc decorator docs extraction', () => {
22+
beforeEach(() => {
23+
env = NgtscTestEnvironment.setup(testFiles);
24+
env.tsconfig();
25+
});
26+
27+
it('should extract class decorators that define options in an interface', () => {
28+
env.write('index.ts', `
29+
export interface Component {
30+
/** The template. */
31+
template: string;
32+
}
33+
34+
export interface ComponentDecorator {
35+
/** The description. */
36+
(obj?: Component): any;
37+
}
38+
39+
function makeDecorator(): ComponentDecorator { return () => {}; }
40+
41+
export const Component: ComponentDecorator = makeDecorator();
42+
`);
43+
44+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
45+
expect(docs.length).toBe(1);
46+
47+
const decoratorEntry = docs[0] as DecoratorEntry;
48+
expect(decoratorEntry.name).toBe('Component');
49+
expect(decoratorEntry.description).toBe('The description.');
50+
expect(decoratorEntry.entryType).toBe(EntryType.Decorator);
51+
expect(decoratorEntry.decoratorType).toBe(DecoratorType.Class);
52+
53+
expect(decoratorEntry.options.length).toBe(1);
54+
expect(decoratorEntry.options[0].name).toBe('template');
55+
expect(decoratorEntry.options[0].type).toBe('string');
56+
expect(decoratorEntry.options[0].description).toBe('The template.');
57+
});
58+
59+
it('should extract property decorators', () => {
60+
env.write('index.ts', `
61+
export interface Input {
62+
/** The alias. */
63+
alias: string;
64+
}
65+
66+
export interface InputDecorator {
67+
/** The description. */
68+
(alias: string): any;
69+
}
70+
71+
function makePropDecorator(): InputDecorator { return () => {}); }
72+
73+
export const Input: InputDecorator = makePropDecorator();
74+
`);
75+
76+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
77+
expect(docs.length).toBe(1);
78+
79+
const decoratorEntry = docs[0] as DecoratorEntry;
80+
expect(decoratorEntry.name).toBe('Input');
81+
expect(decoratorEntry.description).toBe('The description.');
82+
expect(decoratorEntry.entryType).toBe(EntryType.Decorator);
83+
expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member);
84+
85+
expect(decoratorEntry.options.length).toBe(1);
86+
expect(decoratorEntry.options[0].name).toBe('alias');
87+
expect(decoratorEntry.options[0].type).toBe('string');
88+
expect(decoratorEntry.options[0].description).toBe('The alias.');
89+
});
90+
91+
it('should extract property decorators with a type alias', () => {
92+
env.write('index.ts', `
93+
interface Query {
94+
/** The read. */
95+
read: string;
96+
}
97+
98+
export type ViewChild = Query;
99+
100+
export interface ViewChildDecorator {
101+
/** The description. */
102+
(alias: string): any;
103+
}
104+
105+
function makePropDecorator(): ViewChildDecorator { return () => {}); }
106+
107+
export const ViewChild: ViewChildDecorator = makePropDecorator();
108+
`);
109+
110+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
111+
expect(docs.length).toBe(1);
112+
113+
const decoratorEntry = docs[0] as DecoratorEntry;
114+
expect(decoratorEntry.name).toBe('ViewChild');
115+
expect(decoratorEntry.description).toBe('The description.');
116+
expect(decoratorEntry.entryType).toBe(EntryType.Decorator);
117+
expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member);
118+
119+
expect(decoratorEntry.options.length).toBe(1);
120+
expect(decoratorEntry.options[0].name).toBe('read');
121+
expect(decoratorEntry.options[0].type).toBe('string');
122+
expect(decoratorEntry.options[0].description).toBe('The read.');
123+
});
124+
125+
it('should extract param decorators', () => {
126+
env.write('index.ts', `
127+
export interface Inject {
128+
/** The token. */
129+
token: string;
130+
}
131+
132+
export interface InjectDecorator {
133+
/** The description. */
134+
(token: string) => any;
135+
}
136+
137+
function makePropDecorator(): InjectDecorator { return () => {}; }
138+
139+
export const Inject: InjectDecorator = makeParamDecorator();
140+
`);
141+
142+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
143+
expect(docs.length).toBe(1);
144+
145+
const decoratorEntry = docs[0] as DecoratorEntry;
146+
expect(decoratorEntry.name).toBe('Inject');
147+
expect(decoratorEntry.description).toBe('The description.');
148+
expect(decoratorEntry.entryType).toBe(EntryType.Decorator);
149+
expect(decoratorEntry.decoratorType).toBe(DecoratorType.Parameter);
150+
151+
expect(decoratorEntry.options.length).toBe(1);
152+
expect(decoratorEntry.options[0].name).toBe('token');
153+
expect(decoratorEntry.options[0].type).toBe('string');
154+
expect(decoratorEntry.options[0].description).toBe('The token.');
155+
});
156+
});
157+
});

0 commit comments

Comments
 (0)