Skip to content

Commit 7f6d9a7

Browse files
jelbournpkozlowski-opensource
authored andcommitted
feat(compiler): expand class api doc extraction (#51733)
Based on top of #51682 This expands on the skeleton previously added to extract docs info for classes, including properties, methods, and method parameters. Type information and Angular-specific info (e.g. inputs) will come in future PRs. PR Close #51733
1 parent 7e82df4 commit 7f6d9a7

File tree

4 files changed

+320
-7
lines changed

4 files changed

+320
-7
lines changed

packages/compiler-cli/src/ngtsc/core/src/compiler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ export class NgCompiler {
672672
// We don't want to generate docs for `.d.ts` files.
673673
if (sourceFile.isDeclarationFile) continue;
674674

675-
entries.push(...docsExtractor.extract(sourceFile));
675+
entries.push(...docsExtractor.extractAll(sourceFile));
676676
}
677677
return entries;
678678
}

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

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

9+
/** Type of top-level documentation entry. */
10+
export enum EntryType {
11+
block = 'block',
12+
component = 'component',
13+
decorator = 'decorator',
14+
directive = 'directive',
15+
element = 'element',
16+
enum = 'enum',
17+
function = 'function',
18+
interface = 'interface',
19+
pipe = 'pipe',
20+
type_alias = 'type_alias',
21+
undecorated_class = 'undecorated_class',
22+
}
23+
24+
/** Types of class members */
25+
export enum MemberType {
26+
property = 'property',
27+
method = 'method',
28+
}
29+
30+
/** Informational tags applicable to class members. */
31+
export enum MemberTags {
32+
static = 'static',
33+
readonly = 'readonly',
34+
protected = 'protected',
35+
optional = 'optional',
36+
input = 'input',
37+
output = 'output',
38+
}
39+
940
/** Base type for all documentation entities. */
1041
export interface DocEntry {
42+
entryType: EntryType;
43+
name: string;
44+
}
45+
46+
/** Documentation entity for a TypeScript class. */
47+
export interface ClassEntry extends DocEntry {
48+
members: MemberEntry[];
49+
}
50+
51+
export interface FunctionEntry extends DocEntry {
52+
params: ParameterEntry[];
53+
returnType: string;
54+
}
55+
56+
/** Sub-entry for a single class member. */
57+
export interface MemberEntry {
58+
name: string;
59+
memberType: MemberType;
60+
memberTags: MemberTags[];
61+
}
62+
63+
/** Sub-entry for a class property. */
64+
export interface PropertyEntry extends MemberEntry {
65+
getType: string;
66+
setType: string;
67+
}
68+
69+
/** Sub-entry for a class method. */
70+
export type MethodEntry = MemberEntry&FunctionEntry;
71+
72+
/** Sub-entry for a single function parameter. */
73+
export interface ParameterEntry {
1174
name: string;
75+
description: string;
76+
type: string;
77+
isOptional: boolean;
1278
}

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

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ts from 'typescript';
1010

1111
import {MetadataReader} from '../../metadata';
1212

13-
import {DocEntry} from './entities';
13+
import {ClassEntry, DocEntry, EntryType, FunctionEntry, MemberEntry, MemberTags, MemberType, MethodEntry, ParameterEntry, PropertyEntry} from './entities';
1414

1515

1616
/**
@@ -24,25 +24,151 @@ export class DocsExtractor {
2424
* Gets the set of all documentable entries from a source file.
2525
* @param sourceFile The file from which to extract documentable entries.
2626
*/
27-
extract(sourceFile: ts.SourceFile): DocEntry[] {
28-
let entries: DocEntry[] = [];
27+
extractAll(sourceFile: ts.SourceFile): DocEntry[] {
28+
const entries: DocEntry[] = [];
2929

3030
for (const statement of sourceFile.statements) {
3131
// TODO(jelbourn): get all of rest of the docs
32+
// TODO(jelbourn): ignore un-exported nodes
3233
if (ts.isClassDeclaration(statement)) {
3334
// Assume that anonymous classes should not be part of public documentation.
3435
if (!statement.name) continue;
3536

36-
entries = entries.concat(this.extractClassDocs(statement));
37+
entries.push(this.extractClass(statement));
3738
}
3839
}
3940

4041
return entries;
4142
}
4243

4344
/** Extract docs info specific to classes. */
44-
private extractClassDocs(statement: ts.ClassDeclaration): DocEntry {
45+
private extractClass(classDeclaration: ts.ClassDeclaration): ClassEntry {
4546
// TODO(jelbourn): get all of the rest of the docs
46-
return {name: statement.name!.text};
47+
return {
48+
name: classDeclaration.name!.text,
49+
entryType: EntryType.undecorated_class,
50+
members: this.extractAllClassMembers(classDeclaration),
51+
};
52+
}
53+
54+
/** Extracts doc info for a class's members. */
55+
private extractAllClassMembers(classDeclaration: ts.ClassDeclaration): MemberEntry[] {
56+
const members: MemberEntry[] = [];
57+
58+
for (const member of classDeclaration.members) {
59+
if (this.isMemberExcluded(member)) continue;
60+
61+
const memberEntry = this.extractClassMember(member);
62+
if (memberEntry) {
63+
members.push(memberEntry);
64+
}
65+
}
66+
67+
return members;
68+
}
69+
70+
/** Extract docs for a class's members (methods and properties). */
71+
private extractClassMember(memberDeclaration: ts.ClassElement): MemberEntry|undefined {
72+
if (ts.isMethodDeclaration(memberDeclaration)) {
73+
return this.extractMethod(memberDeclaration);
74+
} else if (ts.isPropertyDeclaration(memberDeclaration)) {
75+
return this.extractClassProperty(memberDeclaration);
76+
}
77+
78+
// We only expect methods and properties. If we encounter something else,
79+
// return undefined and let the rest of the program filter it out.
80+
return undefined;
81+
}
82+
83+
/** Extracts docs for a class method. */
84+
private extractMethod(methodDeclaration: ts.MethodDeclaration): MethodEntry {
85+
return {
86+
...this.extractFunction(methodDeclaration),
87+
memberType: MemberType.method,
88+
memberTags: this.getMemberTags(methodDeclaration),
89+
};
90+
}
91+
92+
/** Extracts docs for a function, including class method declarations. */
93+
private extractFunction(fn: ts.FunctionDeclaration|ts.MethodDeclaration): FunctionEntry {
94+
return {
95+
params: this.extractAllParams(fn.parameters),
96+
// We know that the function has a name here because we would have skipped it
97+
// already before getting to this point if it was anonymous.
98+
name: fn.name!.getText(),
99+
returnType: 'TODO',
100+
entryType: EntryType.function,
101+
};
102+
}
103+
104+
/** Extracts doc info for a collection of function parameters. */
105+
private extractAllParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParameterEntry[] {
106+
// TODO: handle var args
107+
return params.map(param => ({
108+
name: param.name.getText(),
109+
description: 'TODO',
110+
type: 'TODO',
111+
isOptional: !!(param.questionToken || param.initializer),
112+
}));
113+
}
114+
115+
/** Extracts doc info for a property declaration. */
116+
private extractClassProperty(propertyDeclaration: ts.PropertyDeclaration): PropertyEntry {
117+
return {
118+
name: propertyDeclaration.name.getText(),
119+
getType: 'TODO',
120+
setType: 'TODO',
121+
memberType: MemberType.property,
122+
memberTags: this.getMemberTags(propertyDeclaration),
123+
};
124+
}
125+
126+
/** Gets the tags for a member (protected, readonly, static, etc.) */
127+
private getMemberTags(member: ts.MethodDeclaration|ts.PropertyDeclaration): MemberTags[] {
128+
const tags: MemberTags[] = [];
129+
for (const mod of member.modifiers ?? []) {
130+
const tag = this.getTagForMemberModifier(mod);
131+
if (tag) tags.push(tag);
132+
}
133+
134+
if (member.questionToken) {
135+
tags.push(MemberTags.optional);
136+
}
137+
138+
// TODO: mark inputs and outputs
139+
140+
return tags;
141+
}
142+
143+
/** Gets the doc tag corresponding to a class member modifier (readonly, protected, etc.). */
144+
private getTagForMemberModifier(mod: ts.ModifierLike): MemberTags|undefined {
145+
switch (mod.kind) {
146+
case ts.SyntaxKind.StaticKeyword:
147+
return MemberTags.static;
148+
case ts.SyntaxKind.ReadonlyKeyword:
149+
return MemberTags.readonly;
150+
case ts.SyntaxKind.ProtectedKeyword:
151+
return MemberTags.protected;
152+
default:
153+
return undefined;
154+
}
155+
}
156+
157+
/**
158+
* Gets whether a given class member should be excluded from public API docs.
159+
* This is the case if:
160+
* - The member does not have a name
161+
* - The member is neither a method nor property
162+
* - The member is private
163+
*/
164+
private isMemberExcluded(member: ts.ClassElement): boolean {
165+
return !member.name || !this.isMethodOrProperty(member) ||
166+
!!member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
167+
}
168+
169+
/** Gets whether a class member is either a member or a property. */
170+
private isMethodOrProperty(member: ts.ClassElement): member is ts.MethodDeclaration
171+
|ts.PropertyDeclaration {
172+
return ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member);
47173
}
48174
}

packages/compiler-cli/test/ngtsc/docs_spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs';
10+
import {ClassEntry, EntryType, MemberTags, MemberType, MethodEntry, PropertyEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities';
1011
import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
1112
import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing';
1213

@@ -33,7 +34,127 @@ runInEachFileSystem(os => {
3334
const docs: DocEntry[] = env.driveDocsExtraction();
3435
expect(docs.length).toBe(2);
3536
expect(docs[0].name).toBe('UserProfile');
37+
expect(docs[0].entryType).toBe(EntryType.undecorated_class);
3638
expect(docs[1].name).toBe('CustomSlider');
39+
expect(docs[1].entryType).toBe(EntryType.undecorated_class);
40+
});
41+
42+
it('should extract class members', () => {
43+
env.write('test.ts', `
44+
class UserProfile {
45+
firstName(): string { return 'Morgan'; }
46+
age: number = 25;
47+
}
48+
`);
49+
50+
const docs: DocEntry[] = env.driveDocsExtraction();
51+
const classEntry = docs[0] as ClassEntry;
52+
expect(classEntry.members.length).toBe(2);
53+
54+
const methodEntry = classEntry.members[0] as MethodEntry;
55+
expect(methodEntry.memberType).toBe(MemberType.method);
56+
expect(methodEntry.name).toBe('firstName');
57+
58+
const propertyEntry = classEntry.members[1] as PropertyEntry;
59+
expect(propertyEntry.memberType).toBe(MemberType.property);
60+
expect(propertyEntry.name).toBe('age');
61+
});
62+
63+
it('should extract class method params', () => {
64+
env.write('test.ts', `
65+
class UserProfile {
66+
setPhone(num: string, intl: string = '1', area?: string): void {}
67+
}
68+
`);
69+
70+
const docs: DocEntry[] = env.driveDocsExtraction();
71+
72+
const classEntry = docs[0] as ClassEntry;
73+
expect(classEntry.members.length).toBe(1);
74+
75+
const methodEntry = classEntry.members[0] as MethodEntry;
76+
expect(methodEntry.memberType).toBe(MemberType.method);
77+
expect(methodEntry.name).toBe('setPhone');
78+
expect(methodEntry.params.length).toBe(3);
79+
80+
const [numParam, intlParam, areaParam] = methodEntry.params;
81+
expect(numParam.name).toBe('num');
82+
expect(numParam.isOptional).toBe(false);
83+
expect(intlParam.name).toBe('intl');
84+
expect(intlParam.isOptional).toBe(true);
85+
expect(areaParam.name).toBe('area');
86+
expect(areaParam.isOptional).toBe(true);
87+
});
88+
89+
it('should not extract private class members', () => {
90+
env.write('test.ts', `
91+
class UserProfile {
92+
private ssn: string;
93+
private getSsn(): string { return ''; }
94+
private static printSsn(): void { }
95+
}
96+
`);
97+
98+
const docs: DocEntry[] = env.driveDocsExtraction();
99+
100+
const classEntry = docs[0] as ClassEntry;
101+
expect(classEntry.members.length).toBe(0);
102+
});
103+
104+
it('should extract member tags', () => {
105+
// Test both properties and methods with zero, one, and multiple tags.
106+
env.write('test.ts', `
107+
class UserProfile {
108+
eyeColor = 'brown';
109+
protected name: string;
110+
readonly age = 25;
111+
address?: string;
112+
static country = 'USA';
113+
protected readonly birthday = '1/1/2000';
114+
115+
getEyeColor(): string { return 'brown'; }
116+
protected getName(): string { return 'Morgan'; }
117+
getAge?(): number { return 25; }
118+
static getCountry(): string { return 'USA'; }
119+
protected getBirthday?(): string { return '1/1/2000'; }
120+
}
121+
`);
122+
123+
const docs: DocEntry[] = env.driveDocsExtraction();
124+
125+
const classEntry = docs[0] as ClassEntry;
126+
expect(classEntry.members.length).toBe(11);
127+
128+
const [
129+
eyeColorMember,
130+
nameMember,
131+
ageMember,
132+
addressMember,
133+
countryMember,
134+
birthdayMember,
135+
getEyeColorMember,
136+
getNameMember,
137+
getAgeMember,
138+
getCountryMember,
139+
getBirthdayMember,
140+
] = classEntry.members;
141+
142+
// Properties
143+
expect(eyeColorMember.memberTags.length).toBe(0);
144+
expect(nameMember.memberTags).toEqual([MemberTags.protected]);
145+
expect(ageMember.memberTags).toEqual([MemberTags.readonly]);
146+
expect(addressMember.memberTags).toEqual([MemberTags.optional]);
147+
expect(countryMember.memberTags).toEqual([MemberTags.static]);
148+
expect(birthdayMember.memberTags).toContain(MemberTags.protected);
149+
expect(birthdayMember.memberTags).toContain(MemberTags.readonly);
150+
151+
// Methods
152+
expect(getEyeColorMember.memberTags.length).toBe(0);
153+
expect(getNameMember.memberTags).toEqual([MemberTags.protected]);
154+
expect(getAgeMember.memberTags).toEqual([MemberTags.optional]);
155+
expect(getCountryMember.memberTags).toEqual([MemberTags.static]);
156+
expect(getBirthdayMember.memberTags).toContain(MemberTags.protected);
157+
expect(getBirthdayMember.memberTags).toContain(MemberTags.optional);
37158
});
38159
});
39160
});

0 commit comments

Comments
 (0)