Skip to content

Commit 9291ffc

Browse files
jelbournalxhub
authored andcommitted
refactor(compiler): extract api docs for inherited members (angular#52389)
This commit expands docs extraction for classes and interfaces to include inherited members. This relies on the type checker to get the _resolved_ members of the type so that the extractor doesn't need to reason about inheritance rules, which can get tricky (especially with regards to method overloads). PR Close angular#52389
1 parent 740d46f commit 9291ffc

File tree

4 files changed

+277
-26
lines changed

4 files changed

+277
-26
lines changed

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

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class ClassExtractor {
5353
isAbstract: this.isAbstract(),
5454
entryType: ts.isInterfaceDeclaration(this.declaration) ? EntryType.Interface :
5555
EntryType.UndecoratedClass,
56-
members: this.extractAllClassMembers(this.declaration),
56+
members: this.extractAllClassMembers(),
5757
generics: extractGenerics(this.declaration),
5858
description: extractJsDocDescription(this.declaration),
5959
jsdocTags: extractJsDocTags(this.declaration),
@@ -62,10 +62,10 @@ class ClassExtractor {
6262
}
6363

6464
/** Extracts doc info for a class's members. */
65-
protected extractAllClassMembers(classDeclaration: ClassDeclarationLike): MemberEntry[] {
65+
protected extractAllClassMembers(): MemberEntry[] {
6666
const members: MemberEntry[] = [];
6767

68-
for (const member of classDeclaration.members) {
68+
for (const member of this.getMemberDeclarations()) {
6969
if (this.isMemberExcluded(member)) continue;
7070

7171
const memberEntry = this.extractClassMember(member);
@@ -130,9 +130,40 @@ class ClassExtractor {
130130
tags.push(MemberTags.Optional);
131131
}
132132

133+
if (member.parent !== this.declaration) {
134+
tags.push(MemberTags.Inherited);
135+
}
136+
133137
return tags;
134138
}
135139

140+
/** Gets all member declarations, including inherited members. */
141+
private getMemberDeclarations(): MemberElement[] {
142+
// We rely on TypeScript to resolve all the inherited members to their
143+
// ultimate form via `getPropertiesOfType`. This is important because child
144+
// classes may narrow types or add method overloads.
145+
const type = this.typeChecker.getTypeAtLocation(this.declaration);
146+
const members = type.getProperties();
147+
148+
// While the properties of the declaration type represent the properties that exist
149+
// on a clas *instance*, static members are properties on the class symbol itself.
150+
const typeOfConstructor = this.typeChecker.getTypeOfSymbol(type.symbol);
151+
const staticMembers = typeOfConstructor.getProperties();
152+
153+
const result: MemberElement[] = [];
154+
for (const member of [...members, ...staticMembers]) {
155+
// A member may have multiple declarations in the case of function overloads.
156+
const memberDeclarations = member.getDeclarations() ?? [];
157+
for (const memberDeclaration of memberDeclarations) {
158+
if (this.isDocumentableMember(memberDeclaration)) {
159+
result.push(memberDeclaration);
160+
}
161+
}
162+
}
163+
164+
return result;
165+
}
166+
136167
/** Get the tags for a member that come from the declaration modifiers. */
137168
private getMemberTagsFromModifiers(mods: Iterable<ts.ModifierLike>): MemberTags[] {
138169
const tags: MemberTags[] = [];
@@ -170,22 +201,22 @@ class ClassExtractor {
170201
private isMemberExcluded(member: MemberElement): boolean {
171202
return !member.name || !this.isDocumentableMember(member) ||
172203
!!member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword) ||
173-
isAngularPrivateName(member.name.getText());
204+
member.name.getText() === 'prototype' || isAngularPrivateName(member.name.getText());
174205
}
175206

176207
/** Gets whether a class member is a method, property, or accessor. */
177-
private isDocumentableMember(member: MemberElement): member is MethodLike|PropertyLike {
208+
private isDocumentableMember(member: ts.Node): member is MethodLike|PropertyLike {
178209
return this.isMethod(member) || this.isProperty(member) || ts.isAccessor(member);
179210
}
180211

181212
/** Gets whether a member is a property. */
182-
private isProperty(member: MemberElement): member is PropertyLike {
213+
private isProperty(member: ts.Node): member is PropertyLike {
183214
// Classes have declarations, interface have signatures
184215
return ts.isPropertyDeclaration(member) || ts.isPropertySignature(member);
185216
}
186217

187218
/** Gets whether a member is a method. */
188-
private isMethod(member: MemberElement): member is MethodLike {
219+
private isMethod(member: ts.Node): member is MethodLike {
189220
// Classes have declarations, interface have signatures
190221
return ts.isMethodDeclaration(member) || ts.isMethodSignature(member);
191222
}
@@ -197,20 +228,14 @@ class ClassExtractor {
197228
}
198229

199230
/** Gets whether a method is the concrete implementation for an overloaded function. */
200-
private isImplementationForOverload(method: MethodLike): boolean {
231+
private isImplementationForOverload(method: MethodLike): boolean|undefined {
201232
// Method signatures (in an interface) are never implementations.
202233
if (method.kind === ts.SyntaxKind.MethodSignature) return false;
203234

204-
const methodsWithSameName =
205-
this.declaration.members.filter(member => member.name?.getText() === method.name.getText())
206-
.sort((a, b) => a.pos - b.pos);
207-
208-
// No overloads.
209-
if (methodsWithSameName.length === 1) return false;
210-
211-
// The implementation is always the last declaration, so we know this is the
212-
// implementation if it's the last position.
213-
return method.pos === methodsWithSameName[methodsWithSameName.length - 1].pos;
235+
const signature = this.typeChecker.getSignatureFromDeclaration(method);
236+
return signature &&
237+
this.typeChecker.isImplementationOfOverload(
238+
signature.declaration as ts.SignatureDeclaration);
214239
}
215240
}
216241

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export enum MemberTags {
4747
Optional = 'optional',
4848
Input = 'input',
4949
Output = 'output',
50+
Inherited = 'override',
5051
}
5152

5253
/** Documentation entity for single JsDoc tag. */

packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ runInEachFileSystem(() => {
7373
export class UserProfile {
7474
ident(value: boolean): boolean
7575
ident(value: number): number
76-
ident(value: number|boolean): number|boolean {
77-
return value;
76+
ident(value: number|boolean|string): number|boolean {
77+
return 0;
7878
}
7979
}
8080
`);
@@ -207,13 +207,13 @@ runInEachFileSystem(() => {
207207
nameMember,
208208
ageMember,
209209
addressMember,
210-
countryMember,
211210
birthdayMember,
212211
getEyeColorMember,
213212
getNameMember,
214213
getAgeMember,
215-
getCountryMember,
216214
getBirthdayMember,
215+
countryMember,
216+
getCountryMember,
217217
] = classEntry.members;
218218

219219
// Properties
@@ -381,5 +381,114 @@ runInEachFileSystem(() => {
381381
expect(genericEntry.constraint).toBeUndefined();
382382
expect(genericEntry.default).toBeUndefined();
383383
});
384+
385+
it('should extract inherited members', () => {
386+
env.write('index.ts', `
387+
class Ancestor {
388+
id: string;
389+
value: string|number;
390+
391+
save(value: string|number): string|number { return 0; }
392+
}
393+
394+
class Parent extends Ancestor {
395+
name: string;
396+
}
397+
398+
export class Child extends Parent {
399+
age: number;
400+
value: number;
401+
402+
save(value: number): number;
403+
save(value: string|number): string|number { return 0; }
404+
}`);
405+
406+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
407+
expect(docs.length).toBe(1);
408+
409+
const classEntry = docs[0] as ClassEntry;
410+
expect(classEntry.members.length).toBe(5);
411+
412+
const [ageEntry, valueEntry, childSaveEntry, nameEntry, idEntry] = classEntry.members;
413+
414+
expect(ageEntry.name).toBe('age');
415+
expect(ageEntry.memberType).toBe(MemberType.Property);
416+
expect((ageEntry as PropertyEntry).type).toBe('number');
417+
expect(ageEntry.memberTags).not.toContain(MemberTags.Inherited);
418+
419+
expect(valueEntry.name).toBe('value');
420+
expect(valueEntry.memberType).toBe(MemberType.Property);
421+
expect((valueEntry as PropertyEntry).type).toBe('number');
422+
expect(valueEntry.memberTags).not.toContain(MemberTags.Inherited);
423+
424+
expect(childSaveEntry.name).toBe('save');
425+
expect(childSaveEntry.memberType).toBe(MemberType.Method);
426+
expect((childSaveEntry as MethodEntry).returnType).toBe('number');
427+
expect(childSaveEntry.memberTags).not.toContain(MemberTags.Inherited);
428+
429+
expect(nameEntry.name).toBe('name');
430+
expect(nameEntry.memberType).toBe(MemberType.Property);
431+
expect((nameEntry as PropertyEntry).type).toBe('string');
432+
expect(nameEntry.memberTags).toContain(MemberTags.Inherited);
433+
434+
expect(idEntry.name).toBe('id');
435+
expect(idEntry.memberType).toBe(MemberType.Property);
436+
expect((idEntry as PropertyEntry).type).toBe('string');
437+
expect(idEntry.memberTags).toContain(MemberTags.Inherited);
438+
});
439+
440+
it('should extract inherited getters/setters', () => {
441+
env.write('index.ts', `
442+
class Ancestor {
443+
get name(): string { return ''; }
444+
set name(v: string) { }
445+
446+
get id(): string { return ''; }
447+
set id(v: string) { }
448+
449+
get age(): number { return 0; }
450+
set age(v: number) { }
451+
}
452+
453+
class Parent extends Ancestor {
454+
name: string;
455+
}
456+
457+
export class Child extends Parent {
458+
get id(): string { return ''; }
459+
}`);
460+
461+
const docs: DocEntry[] = env.driveDocsExtraction('index.ts');
462+
expect(docs.length).toBe(1);
463+
464+
const classEntry = docs[0] as ClassEntry;
465+
expect(classEntry.members.length).toBe(4);
466+
467+
const [idEntry, nameEntry, ageGetterEntry, ageSetterEntry] =
468+
classEntry.members as PropertyEntry[];
469+
470+
// When the child class overrides an accessor pair with another accessor, it overrides
471+
// *both* the getter and the setter, resulting (in this case) in just a getter.
472+
expect(idEntry.name).toBe('id');
473+
expect(idEntry.memberType).toBe(MemberType.Getter);
474+
expect((idEntry as PropertyEntry).type).toBe('string');
475+
expect(idEntry.memberTags).not.toContain(MemberTags.Inherited);
476+
477+
// When the child class overrides an accessor with a property, the property takes precedence.
478+
expect(nameEntry.name).toBe('name');
479+
expect(nameEntry.memberType).toBe(MemberType.Property);
480+
expect(nameEntry.type).toBe('string');
481+
expect(nameEntry.memberTags).toContain(MemberTags.Inherited);
482+
483+
expect(ageGetterEntry.name).toBe('age');
484+
expect(ageGetterEntry.memberType).toBe(MemberType.Getter);
485+
expect(ageGetterEntry.type).toBe('number');
486+
expect(ageGetterEntry.memberTags).toContain(MemberTags.Inherited);
487+
488+
expect(ageSetterEntry.name).toBe('age');
489+
expect(ageSetterEntry.memberType).toBe(MemberType.Setter);
490+
expect(ageSetterEntry.type).toBe('number');
491+
expect(ageSetterEntry.memberTags).toContain(MemberTags.Inherited);
492+
});
384493
});
385494
});

0 commit comments

Comments
 (0)