Skip to content

Commit aeb20f9

Browse files
devversiondylhunn
authored andcommitted
refactor(compiler-cli): support extracting initializer API functions (#55053)
This commit adds support for extracting initializer API functions. Initialixer API functions are functions conceptually that can are intended to be used as class member initializers. Angular started introducing a few of these for the new signal APIs, like `input`, `model` or signal-based queries. These APIs are currently confusingly represented in the API docs because the API extraction: - does not properly account for call signatures of interfaces - does not expose information about sub-property objects and call signatures (e.g. `input.required`) - the docs rendering syntax highlighting is too bloated and confusing with all types being included. This commit adds support for initializer API functions, namely two variants: - interface-based initializer APIs. e.g. `export const input: InputFunction`- which is a pattern for `input` and `input.required`. - function-based simpler initializer APIs with overloads. e.g. `contentChildren` has many signatures but doesn't need to be an interface as there are no sub-property call signatures. PR Close #55053
1 parent 708ba81 commit aeb20f9

8 files changed

Lines changed: 567 additions & 788 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
"@actions/core": "^1.10.0",
153153
"@angular-devkit/architect-cli": "^0.1703.0-rc",
154154
"@angular/animations": "^17.3.0-rc",
155-
"@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#65d002a534a74daa3c9bd26bdea5a092cbd519df",
155+
"@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#4a72a4dd4c19416cb8204deee884500766fee9f8",
156156
"@angular/docs": "https://github.com/angular/dev-infra-private-docs-builds.git#82c4573f5c9d4fb864271a1c74259fc251457e2f",
157157
"@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#7dea535110c0215b221908e37067ee6b605db373",
158158
"@babel/helper-remap-async-to-generator": "^7.18.9",

packages/compiler-cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export {NodeJSFileSystem} from './src/ngtsc/file_system';
3939

4040
// Export documentation entities for Angular-internal API doc generation.
4141
export * from './src/ngtsc/docs/src/entities';
42+
export * from './src/ngtsc/docs';
4243

4344
// Exposed for usage in 1P Angular plugin.
4445
export {isLocalCompilationDiagnostics} from './src/ngtsc/diagnostics';

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export enum EntryType {
2121
Pipe = 'pipe',
2222
TypeAlias = 'type_alias',
2323
UndecoratedClass = 'undecorated_class',
24+
InitializerApiFunction = 'initializer_api_function',
2425
}
2526

2627
/** Types of class members */
@@ -157,3 +158,40 @@ export interface ParameterEntry {
157158
isOptional: boolean;
158159
isRestParam: boolean;
159160
}
161+
162+
/** Interface describing a function with overload signatures. */
163+
export interface FunctionWithOverloads {
164+
name: string;
165+
signatures: FunctionEntry[];
166+
implementation: FunctionEntry|null;
167+
}
168+
169+
/**
170+
* Docs entry describing an initializer API function.
171+
*
172+
* An initializer API function is a function that is invoked as
173+
* initializer of class members. The function may hold additional
174+
* sub functions, like `.required`.
175+
*
176+
* Known popular initializer APIs are `input()`, `output()`, `model()`.
177+
*
178+
* Initializer APIs are often constructed typed in complex ways so this
179+
* entry type allows for readable "parsing" and interpretation of such
180+
* constructs. Initializer APIs are explicitly denoted via a JSDoc tag.
181+
*/
182+
export interface InitializerApiFunctionEntry extends DocEntry {
183+
callFunction: FunctionWithOverloads;
184+
subFunctions: FunctionWithOverloads[];
185+
186+
__docsMetadata__?: {
187+
/**
188+
* Whether types should be shown in the signature
189+
* preview of docs.
190+
*
191+
* By default, for readability purposes, types are omitted, but
192+
* shorter initializer API functions like `output` may decide to
193+
* render these types.
194+
*/
195+
showTypesInSignaturePreview?: boolean;
196+
};
197+
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {DocEntry} from './entities';
1818
import {extractEnum} from './enum_extractor';
1919
import {isAngularPrivateName} from './filters';
2020
import {FunctionExtractor} from './function_extractor';
21+
import {extractInitializerApiFunction, isInitializerApiFunction} from './initializer_api_function_extractor';
2122
import {extractTypeAlias} from './type_alias_extractor';
2223

2324
type DeclarationWithExportName = readonly[string, ts.Declaration];
@@ -61,6 +62,10 @@ export class DocsExtractor {
6162
return extractClass(node, this.metadataReader, this.typeChecker);
6263
}
6364

65+
if (isInitializerApiFunction(node, this.typeChecker)) {
66+
return extractInitializerApiFunction(node, this.typeChecker);
67+
}
68+
6469
if (ts.isInterfaceDeclaration(node) && !isIgnoredInterface(node)) {
6570
return extractInterface(node, this.typeChecker);
6671
}
@@ -72,8 +77,10 @@ export class DocsExtractor {
7277
}
7378

7479
if (ts.isVariableDeclaration(node) && !isSyntheticAngularConstant(node)) {
75-
return isDecoratorDeclaration(node) ? extractorDecorator(node, this.typeChecker) :
76-
extractConstant(node, this.typeChecker);
80+
if (isDecoratorDeclaration(node)) {
81+
return extractorDecorator(node, this.typeChecker);
82+
}
83+
return extractConstant(node, this.typeChecker);
7784
}
7885

7986
if (ts.isTypeAliasDeclaration(node)) {

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class FunctionExtractor {
3030
'unknown';
3131

3232
return {
33-
params: this.extractAllParams(this.declaration.parameters),
33+
params: extractAllParams(this.declaration.parameters, this.typeChecker),
3434
name: this.name,
3535
isNewType: ts.isConstructSignatureDeclaration(this.declaration),
3636
returnType,
@@ -42,16 +42,6 @@ export class FunctionExtractor {
4242
};
4343
}
4444

45-
private extractAllParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParameterEntry[] {
46-
return params.map(param => ({
47-
name: param.name.getText(),
48-
description: extractJsDocDescription(param),
49-
type: extractResolvedTypeString(param, this.typeChecker),
50-
isOptional: !!(param.questionToken || param.initializer),
51-
isRestParam: !!param.dotDotDotToken,
52-
}));
53-
}
54-
5545
/** Gets all overloads for the function (excluding this extractor's FunctionDeclaration). */
5646
getOverloads(): ts.FunctionDeclaration[] {
5747
const overloads = [];
@@ -84,3 +74,15 @@ export class FunctionExtractor {
8474
.find(s => s.name === this.declaration.name?.getText());
8575
}
8676
}
77+
78+
/** Extracts parameters of the given parameter declaration AST nodes. */
79+
export function extractAllParams(
80+
params: ts.NodeArray<ts.ParameterDeclaration>, typeChecker: ts.TypeChecker): ParameterEntry[] {
81+
return params.map(param => ({
82+
name: param.name.getText(),
83+
description: extractJsDocDescription(param),
84+
type: extractResolvedTypeString(param, typeChecker),
85+
isOptional: !!(param.questionToken || param.initializer),
86+
isRestParam: !!param.dotDotDotToken,
87+
}));
88+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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 {EntryType, FunctionWithOverloads, InitializerApiFunctionEntry, JsDocTagEntry} from './entities';
12+
import {extractAllParams} from './function_extractor';
13+
import {extractGenerics} from './generics_extractor';
14+
import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor';
15+
16+
/** JSDoc used to recognize an initializer API function. */
17+
const initializerApiTag = 'initializerApiFunction';
18+
19+
/**
20+
* Checks whether the given node corresponds to an initializer API function.
21+
*
22+
* An initializer API function is a function declaration or variable declaration
23+
* that is explicitly annotated with `@initializerApiFunction`.
24+
*
25+
* Note: The node may be a function overload signature that is automatically
26+
* resolved to its implementation to detect the JSDoc tag.
27+
*/
28+
export function isInitializerApiFunction(node: ts.Node, typeChecker: ts.TypeChecker):
29+
node is ts.VariableDeclaration|ts.FunctionDeclaration {
30+
// If this is matching an overload signature, resolve to the implementation
31+
// as it would hold the `@initializerApiFunction` tag.
32+
if (ts.isFunctionDeclaration(node) && node.name !== undefined && node.body === undefined) {
33+
const implementation = findImplementationOfFunction(node, typeChecker);
34+
if (implementation !== undefined) {
35+
node = implementation;
36+
}
37+
}
38+
39+
if (!ts.isFunctionDeclaration(node) && !ts.isVariableDeclaration(node)) {
40+
return false;
41+
}
42+
43+
let tagContainer = ts.isFunctionDeclaration(node) ? node : getContainerVariableStatement(node);
44+
if (tagContainer === null) {
45+
return false;
46+
}
47+
const tags = ts.getJSDocTags(tagContainer);
48+
return tags.some(t => t.tagName.text === initializerApiTag);
49+
}
50+
51+
/**
52+
* Extracts the given node as initializer API function and returns
53+
* a docs entry that can be rendered to represent the API function.
54+
*/
55+
export function extractInitializerApiFunction(
56+
node: ts.VariableDeclaration|ts.FunctionDeclaration,
57+
typeChecker: ts.TypeChecker): InitializerApiFunctionEntry {
58+
if (node.name === undefined || !ts.isIdentifier(node.name)) {
59+
throw new Error(`Initializer API: Expected literal variable name.`);
60+
}
61+
62+
const container = ts.isFunctionDeclaration(node) ? node : getContainerVariableStatement(node);
63+
if (container === null) {
64+
throw new Error('Initializer API: Could not find container AST node of variable.');
65+
}
66+
67+
const name = node.name.text;
68+
const type = typeChecker.getTypeAtLocation(node);
69+
70+
// Top-level call signatures. E.g. `input()`, `input<ReadT>(initialValue: ReadT)`. etc.
71+
const callFunction: FunctionWithOverloads =
72+
extractFunctionWithOverloads(name, type.getCallSignatures(), typeChecker);
73+
// Sub-functions like `input.required()`.
74+
const subFunctions: FunctionWithOverloads[] = [];
75+
76+
for (const property of type.getProperties()) {
77+
const subName = property.getName();
78+
const subDecl = property.getDeclarations()?.[0];
79+
if (subDecl === undefined || !ts.isPropertySignature(subDecl)) {
80+
throw new Error(
81+
`Initializer API: Could not resolve declaration of sub-property: ${name}.${subName}`);
82+
}
83+
84+
const subType = typeChecker.getTypeAtLocation(subDecl);
85+
subFunctions.push(
86+
extractFunctionWithOverloads(subName, subType.getCallSignatures(), typeChecker));
87+
}
88+
89+
let jsdocTags: JsDocTagEntry[];
90+
let description: string;
91+
let rawComment: string;
92+
93+
// Extract container API documentation.
94+
// The container description describes the overall function, while
95+
// we allow the individual top-level call signatures to represent
96+
// their individual overloads.
97+
if (ts.isFunctionDeclaration(node)) {
98+
const implementation = findImplementationOfFunction(node, typeChecker);
99+
if (implementation === undefined) {
100+
throw new Error(`Initializer API: Could not find implementation of function: ${name}`);
101+
}
102+
103+
callFunction.implementation = {
104+
name,
105+
entryType: EntryType.Function,
106+
isNewType: false,
107+
description: extractJsDocDescription(implementation),
108+
generics: extractGenerics(implementation),
109+
jsdocTags: extractJsDocTags(implementation),
110+
params: extractAllParams(implementation.parameters, typeChecker),
111+
rawComment: extractRawJsDoc(implementation),
112+
returnType: typeChecker.typeToString(typeChecker.getReturnTypeOfSignature(
113+
typeChecker.getSignatureFromDeclaration(implementation)!)),
114+
};
115+
116+
jsdocTags = callFunction.implementation.jsdocTags;
117+
description = callFunction.implementation.description;
118+
rawComment = callFunction.implementation.description;
119+
} else {
120+
jsdocTags = extractJsDocTags(container);
121+
description = extractJsDocDescription(container);
122+
rawComment = extractRawJsDoc(container);
123+
}
124+
125+
// Extract additional docs metadata from the initializer API JSDoc tag.
126+
const metadataTag = jsdocTags.find(t => t.name === initializerApiTag);
127+
if (metadataTag === undefined) {
128+
throw new Error(
129+
'Initializer API: Detected initializer API function does ' +
130+
`not have "@initializerApiFunction" tag: ${name}`);
131+
}
132+
133+
let parsedMetadata: InitializerApiFunctionEntry['__docsMetadata__'] = undefined;
134+
if (metadataTag.comment.trim() !== '') {
135+
try {
136+
parsedMetadata = JSON.parse(metadataTag.comment) as typeof parsedMetadata;
137+
} catch (e: unknown) {
138+
throw new Error(`Could not parse initializer API function metadata: ${e}`);
139+
}
140+
}
141+
142+
return {
143+
entryType: EntryType.InitializerApiFunction,
144+
name,
145+
description,
146+
jsdocTags,
147+
rawComment,
148+
callFunction,
149+
subFunctions,
150+
__docsMetadata__: parsedMetadata,
151+
};
152+
}
153+
154+
/**
155+
* Gets the container node of the given variable declaration.
156+
*
157+
* A variable declaration may be annotated with e.g. `@initializerApiFunction`,
158+
* but the JSDoc tag is not attached to the node, but to the containing variable
159+
* statement.
160+
*/
161+
function getContainerVariableStatement(node: ts.VariableDeclaration): ts.VariableStatement|null {
162+
if (!ts.isVariableDeclarationList(node.parent)) {
163+
return null;
164+
}
165+
if (!ts.isVariableStatement(node.parent.parent)) {
166+
return null;
167+
}
168+
return node.parent.parent;
169+
}
170+
171+
/** Filters the list signatures to valid initializer API signatures. */
172+
function filterSignatureDeclarations(signatures: readonly ts.Signature[]) {
173+
const result: Array<ts.FunctionDeclaration|ts.CallSignatureDeclaration> = [];
174+
for (const signature of signatures) {
175+
const decl = signature.getDeclaration();
176+
if (ts.isFunctionDeclaration(decl) || ts.isCallSignatureDeclaration(decl)) {
177+
result.push(decl);
178+
}
179+
}
180+
return result;
181+
}
182+
183+
/**
184+
* Extracts all given signatures and returns them as a function with
185+
* overloads.
186+
*
187+
* The implementation of the function may be attached later, or may
188+
* be non-existent. E.g. initializer APIs declared using an interface
189+
* with call signatures do not have an associated implementation function
190+
* that is statically retrievable. The constant holds the overall API description.
191+
*/
192+
function extractFunctionWithOverloads(
193+
name: string, signatures: readonly ts.Signature[],
194+
typeChecker: ts.TypeChecker): FunctionWithOverloads {
195+
return {
196+
name,
197+
signatures:
198+
filterSignatureDeclarations(signatures)
199+
.map(s => ({
200+
name,
201+
entryType: EntryType.Function,
202+
description: extractJsDocDescription(s),
203+
generics: extractGenerics(s),
204+
isNewType: false,
205+
jsdocTags: extractJsDocTags(s),
206+
params: extractAllParams(s.parameters, typeChecker),
207+
rawComment: extractRawJsDoc(s),
208+
returnType: typeChecker.typeToString(typeChecker.getReturnTypeOfSignature(
209+
typeChecker.getSignatureFromDeclaration(s)!)),
210+
})),
211+
// Implementation may be populated later.
212+
implementation: null,
213+
};
214+
}
215+
216+
/** Finds the implementation of the given function declaration overload signature. */
217+
function findImplementationOfFunction(
218+
node: ts.FunctionDeclaration, typeChecker: ts.TypeChecker): ts.FunctionDeclaration|undefined {
219+
if (node.body !== undefined || node.name === undefined) {
220+
return node;
221+
}
222+
223+
const symbol = typeChecker.getSymbolAtLocation(node.name);
224+
return symbol?.declarations?.find(
225+
(s): s is ts.FunctionDeclaration => ts.isFunctionDeclaration(s) && s.body !== undefined);
226+
}

0 commit comments

Comments
 (0)