diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index d0b0bf7fe8aab..0bf07ef188826 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -2311,6 +2311,53 @@ function encodeNumberLiteralValue(value: number): number | string { return value; } +/** + * Filter for {@link Checker.getMembersOfType}: neutral predicates the caller combines + * to describe the members it will render (see `MemberFilter` for each field). + */ +export interface MemberFilterInput { + /** Visibilities to include; empty means all. ECMAScript private names count as private. */ + visibility?: readonly ("public" | "protected" | "private")[] | undefined; + /** Member kinds to include; empty means all. */ + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[] | undefined; + /** Drop members whose JSDoc carries any of these tags. */ + excludeJsDocTags?: readonly string[] | undefined; + /** + * Drop members whose (wire-form) name matches this RE2 pattern. A pattern that is + * passed is always applied, so `""` drops every member; `undefined` means no name filter. + */ + excludeNamePattern?: string | undefined; + /** + * Which declaring types to take members from: `undefined` means all, `{}` means the + * queried type only, `{ declaringTypes }` adds members declared on those types. + * Members without a declaration (tuple elements, `length`, ...) are always kept. + */ + includeInheritedFrom?: { all?: boolean | undefined; declaringTypes?: readonly Symbol[] | undefined; } | undefined; + includeDeclaredType?: boolean | undefined; +} + +/** The documentation of one symbol, as returned by {@link Checker.getSymbolDocumentations}. */ +export interface SymbolDocumentation { + tags: readonly JSDocTagInfo[]; + comment: string; +} + +/** One member returned by {@link Checker.getMembersOfType}. */ +export interface TypeMember { + symbol: Symbol; + /** + * The symbol of the type declaring this member, when it could be determined; + * members without a declaration have none. + */ + owner: Symbol | undefined; + isMethod: boolean; + isParameterProperty: boolean; + tags: readonly JSDocTagInfo[]; + comment: string; + /** `getTypeOfSymbol(symbol)` for non-method members; only with `includeDeclaredType`. */ + declaredType: Type | undefined; +} + export class Checker { private snapshotId: number; private project: Project; @@ -3273,6 +3320,67 @@ export class Checker { }); } + /** + * The JSDoc tags and documentation comment of many symbols in one round trip. + * Element i corresponds to `symbols[i]` and equals what `getJsDocTagsOfSymbol` + * and `getDocumentationCommentOfSymbol` return for that symbol. + */ + async getSymbolDocumentations(symbols: readonly Symbol[]): Promise { + if (symbols.length === 0) { + return []; + } + const data = await this.client.apiRequest("getSymbolDocumentations", { + snapshot: this.snapshotId, + project: this.project.handle, + symbols: symbols.map(symbol => symbol.reference), + }); + return data.map(d => ({ tags: d.tags ?? [], comment: d.comment ?? "" })); + } + + /** + * The properties of a type that pass `filter`, each with its documentation, the + * symbol of the type declaring it and (when requested) its declared type, in one + * round trip. Designed for traversals that render every surviving member: the + * filter lets the caller describe exactly which members it will visit so no type + * is materialized for a member that would have been skipped. + */ + async getMembersOfType(type: Type, filter: MemberFilterInput = {}): Promise { + const data = await this.client.apiRequest("getMembersOfType", { + snapshot: this.snapshotId, + project: this.project.handle, + type: type.id, + filter: { + visibility: filter.visibility ? [...filter.visibility] : undefined, + kinds: filter.kinds ? [...filter.kinds] : undefined, + excludeJsDocTags: filter.excludeJsDocTags ? [...filter.excludeJsDocTags] : undefined, + excludeNamePattern: filter.excludeNamePattern, + includeInheritedFrom: filter.includeInheritedFrom + ? { + all: filter.includeInheritedFrom.all, + declaringTypes: filter.includeInheritedFrom.declaringTypes?.map(symbol => symbol.reference), + } + : undefined, + includeDeclaredType: filter.includeDeclaredType, + }, + }); + const members: TypeMember[] = []; + for (const m of data ?? []) { + if (!m.symbol) { + continue; + } + members.push({ + symbol: this.objectRegistry.getOrCreateSymbol(m.symbol), + owner: m.owner ? this.objectRegistry.getOrCreateSymbol(m.owner) : undefined, + isMethod: m.isMethod ?? false, + isParameterProperty: m.isParameterProperty ?? false, + tags: m.tags ?? [], + comment: m.comment ?? "", + declaredType: m.declaredType ? this.objectRegistry.getOrCreateType(m.declaredType) : undefined, + }); + } + return members; + } + /** * Get the type arguments of a type reference (e.g. the `string` in `Array`). */ diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 2fc617bcbfc39..56d1d00bb03e8 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -157,6 +157,7 @@ export interface APIMethodInfo { getTypePredicateOfSignature: APIMethod; getBaseTypes: APIMethod; getPropertiesOfType: APIMethod; + getMembersOfType: APIMethod; getApparentPropertiesOfType: APIMethod; getApparentType: APIMethod; getReducedType: APIMethod; @@ -186,6 +187,7 @@ export interface APIMethodInfo { getExportsOfModule: APIMethod; getMemberInModuleExports: APIMethod; getJsDocTags: APIMethod; + getSymbolDocumentations: APIMethod; getDocumentationComment: APIMethod; isArrayType: APIMethod; isReadonlySymbol: APIMethod; @@ -943,6 +945,33 @@ export interface TypePredicateResponse { type?: TypeResponse | undefined; } +/** GetMembersOfTypeParams are parameters for getMembersOfType. */ +export interface GetMembersOfTypeParams { + snapshot: number; + project: number; + type: number; + filter: MemberFilter; +} + +/** + * MemberResponse is one property of a type with the information a traversal needs to + * decide how to render it, in a single round trip. + */ +export interface MemberResponse { + symbol: SymbolResponse | null; + /** + * Owner is the symbol of the type declaring the member (see InheritedMemberFilter), + * when it could be determined; members without a declaration (tuple elements, + * `length`, ...) have none. + */ + owner?: SymbolResponse | undefined; + isMethod?: boolean | undefined; + isParameterProperty?: boolean | undefined; + tags?: JSDocTagInfo[] | undefined; + comment?: string | undefined; + declaredType?: TypeResponse | undefined; +} + /** GetPropertyOfTypeParams are parameters for getPropertyOfType (a named property of a type). */ export interface GetPropertyOfTypeParams { snapshot: number; @@ -1008,6 +1037,25 @@ export interface JSDocTagInfo { text?: string | undefined; } +/** + * GetSymbolDocumentationsParams are parameters for getSymbolDocumentations: the JSDoc + * tags and documentation comment of many symbols in one request. + */ +export interface GetSymbolDocumentationsParams { + snapshot: number; + project: number; + symbols: readonly SymbolReference[] | null; +} + +/** + * SymbolDocumentation is one element of a getSymbolDocumentations response; it equals + * the results of getJsDocTags and getDocumentationComment for the same symbol. + */ +export interface SymbolDocumentation { + tags?: JSDocTagInfo[] | undefined; + comment?: string | undefined; +} + /** GetReferencesToSymbolInFileParams are the parameters for the getReferencesToSymbolInFile method. */ export interface GetReferencesToSymbolInFileParams { snapshot: number; @@ -1276,6 +1324,7 @@ export interface BatchRequest { | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" + | "getMembersOfType" | "getMergedSymbol" | "getModeForResolutionAtIndex" | "getModeForUsageLocation" @@ -1323,6 +1372,7 @@ export interface BatchRequest { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolDocumentations" | "getSymbolOfDeclaration" | "getSymbolOfDeclarationForChecker" | "getSymbolOfNode" @@ -1470,6 +1520,7 @@ export interface BatchResponse { | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" + | "getMembersOfType" | "getMergedSymbol" | "getModeForResolutionAtIndex" | "getModeForUsageLocation" @@ -1517,6 +1568,7 @@ export interface BatchResponse { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolDocumentations" | "getSymbolOfDeclaration" | "getSymbolOfDeclarationForChecker" | "getSymbolOfNode" @@ -1752,6 +1804,42 @@ export interface PackageId { peerDependencies: string; } +/** + * MemberFilter selects which properties of a type getMembersOfType returns. It is a + * set of neutral predicates the caller combines (like a WHERE clause) to describe the + * members it will render, so that no type is materialized for a member it would skip. + * Tool-specific policies (jsii's @internal/underscore convention, TypeDoc's @hidden, ...) + * are expressed by the caller, not encoded here. + */ +export interface MemberFilter { + /** + * Visibilities to include ("public", "protected", "private"); empty means all. + * ECMAScript private names (#x) count as private. + */ + visibility?: string[] | undefined; + /** + * Member kinds to include ("property", "accessor", "method", "parameterProperty", + * the last being a constructor parameter property); empty means all. + */ + kinds?: string[] | undefined; + /** Drop members whose JSDoc carries any of these tags (e.g. "internal", "deprecated"). */ + excludeJsDocTags?: string[] | undefined; + /** + * Drop members whose name (in its wire form, as the client sees it) matches this + * RE2 pattern, e.g. "^_" or "^(_|prototype$)". Absent means no name filter; a + * pattern that is passed is always compiled, so the empty pattern matches every + * member. + */ + excludeNamePattern?: string | undefined; + /** + * Which declaring types to take members from. Nil means all (plain + * getPropertiesOfType semantics). + */ + includeInheritedFrom?: InheritedMemberFilter | undefined; + /** Include the declared type of each non-method member (getTypeOfSymbol). */ + includeDeclaredType?: boolean | undefined; +} + export interface ImportAdderAction { kind: "importSymbol"; symbol?: SymbolReference | undefined; @@ -1848,6 +1936,18 @@ export interface ModuleResolutionEntry { result: StaticModuleResolution; } +/** + * InheritedMemberFilter restricts members by the type that declares them: members + * declared on the queried type itself are always kept; with All every base + * contributes; otherwise only members declared on one of DeclaringTypes (by symbol) + * are kept. The declaring type is the type at the declaration's owner node (its + * parent, or grandparent for a constructor parameter property). + */ +export interface InheritedMemberFilter { + all?: boolean | undefined; + declaringTypes?: SymbolReference[] | undefined; +} + /** CompletionEntryLabelDetailsResponse holds additional label display text for a completion entry. */ export interface CompletionEntryLabelDetailsResponse { detail?: string | undefined; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 95db763b82b00..b67aff413ddaa 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -4177,6 +4177,53 @@ function encodeNumberLiteralValue(value: number): number | string { return value; } +/** + * Filter for {@link Checker.getMembersOfType}: neutral predicates the caller combines + * to describe the members it will render (see `MemberFilter` for each field). + */ +export interface MemberFilterInput { + /** Visibilities to include; empty means all. ECMAScript private names count as private. */ + visibility?: readonly ("public" | "protected" | "private")[] | undefined; + /** Member kinds to include; empty means all. */ + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[] | undefined; + /** Drop members whose JSDoc carries any of these tags. */ + excludeJsDocTags?: readonly string[] | undefined; + /** + * Drop members whose (wire-form) name matches this RE2 pattern. A pattern that is + * passed is always applied, so `""` drops every member; `undefined` means no name filter. + */ + excludeNamePattern?: string | undefined; + /** + * Which declaring types to take members from: `undefined` means all, `{}` means the + * queried type only, `{ declaringTypes }` adds members declared on those types. + * Members without a declaration (tuple elements, `length`, ...) are always kept. + */ + includeInheritedFrom?: { all?: boolean | undefined; declaringTypes?: readonly Symbol[] | undefined; } | undefined; + includeDeclaredType?: boolean | undefined; +} + +/** The documentation of one symbol, as returned by {@link Checker.getSymbolDocumentations}. */ +export interface SymbolDocumentation { + tags: readonly JSDocTagInfo[]; + comment: string; +} + +/** One member returned by {@link Checker.getMembersOfType}. */ +export interface TypeMember { + symbol: Symbol; + /** + * The symbol of the type declaring this member, when it could be determined; + * members without a declaration have none. + */ + owner: Symbol | undefined; + isMethod: boolean; + isParameterProperty: boolean; + tags: readonly JSDocTagInfo[]; + comment: string; + /** `getTypeOfSymbol(symbol)` for non-method members; only with `includeDeclaredType`. */ + declaredType: Type | undefined; +} + export class Checker { private snapshotId: number; private project: Project; @@ -6799,6 +6846,134 @@ export class Checker { ); } + /** + * The JSDoc tags and documentation comment of many symbols in one round trip. + * Element i corresponds to `symbols[i]` and equals what `getJsDocTagsOfSymbol` + * and `getDocumentationCommentOfSymbol` return for that symbol. + */ + get getSymbolDocumentations(): { + (symbols: readonly Symbol[]): SymbolDocumentation[]; + gen(symbols: readonly Symbol[]): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSymbolDocumentations", + function (symbols: readonly Symbol[]): SymbolDocumentation[] { + if (symbols.length === 0) { + return []; + } + const data = owner.client.apiRequest("getSymbolDocumentations", { + snapshot: owner.snapshotId, + project: owner.project.handle, + symbols: symbols.map(symbol => symbol.reference), + }); + return data.map(d => ({ tags: d.tags ?? [], comment: d.comment ?? "" })); + }, + function* (symbols: readonly Symbol[]): Generator { + if (symbols.length === 0) { + return []; + } + const data = yield* apiRequest("getSymbolDocumentations", { + snapshot: owner.snapshotId, + project: owner.project.handle, + symbols: symbols.map(symbol => symbol.reference), + }); + return data.map(d => ({ tags: d.tags ?? [], comment: d.comment ?? "" })); + }, + ); + } + + /** + * The properties of a type that pass `filter`, each with its documentation, the + * symbol of the type declaring it and (when requested) its declared type, in one + * round trip. Designed for traversals that render every surviving member: the + * filter lets the caller describe exactly which members it will visit so no type + * is materialized for a member that would have been skipped. + */ + get getMembersOfType(): { + (type: Type, filter?: MemberFilterInput): TypeMember[]; + gen(type: Type, filter?: MemberFilterInput): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getMembersOfType", + function (type: Type, filter: MemberFilterInput = {}): TypeMember[] { + const data = owner.client.apiRequest("getMembersOfType", { + snapshot: owner.snapshotId, + project: owner.project.handle, + type: type.id, + filter: { + visibility: filter.visibility ? [...filter.visibility] : undefined, + kinds: filter.kinds ? [...filter.kinds] : undefined, + excludeJsDocTags: filter.excludeJsDocTags ? [...filter.excludeJsDocTags] : undefined, + excludeNamePattern: filter.excludeNamePattern, + includeInheritedFrom: filter.includeInheritedFrom + ? { + all: filter.includeInheritedFrom.all, + declaringTypes: filter.includeInheritedFrom.declaringTypes?.map(symbol => symbol.reference), + } + : undefined, + includeDeclaredType: filter.includeDeclaredType, + }, + }); + const members: TypeMember[] = []; + for (const m of data ?? []) { + if (!m.symbol) { + continue; + } + members.push({ + symbol: owner.objectRegistry.getOrCreateSymbol(m.symbol), + owner: m.owner ? owner.objectRegistry.getOrCreateSymbol(m.owner) : undefined, + isMethod: m.isMethod ?? false, + isParameterProperty: m.isParameterProperty ?? false, + tags: m.tags ?? [], + comment: m.comment ?? "", + declaredType: m.declaredType ? owner.objectRegistry.getOrCreateType(m.declaredType) : undefined, + }); + } + return members; + }, + function* (type: Type, filter: MemberFilterInput = {}): Generator { + const data = yield* apiRequest("getMembersOfType", { + snapshot: owner.snapshotId, + project: owner.project.handle, + type: type.id, + filter: { + visibility: filter.visibility ? [...filter.visibility] : undefined, + kinds: filter.kinds ? [...filter.kinds] : undefined, + excludeJsDocTags: filter.excludeJsDocTags ? [...filter.excludeJsDocTags] : undefined, + excludeNamePattern: filter.excludeNamePattern, + includeInheritedFrom: filter.includeInheritedFrom + ? { + all: filter.includeInheritedFrom.all, + declaringTypes: filter.includeInheritedFrom.declaringTypes?.map(symbol => symbol.reference), + } + : undefined, + includeDeclaredType: filter.includeDeclaredType, + }, + }); + const members: TypeMember[] = []; + for (const m of data ?? []) { + if (!m.symbol) { + continue; + } + members.push({ + symbol: owner.objectRegistry.getOrCreateSymbol(m.symbol), + owner: m.owner ? owner.objectRegistry.getOrCreateSymbol(m.owner) : undefined, + isMethod: m.isMethod ?? false, + isParameterProperty: m.isParameterProperty ?? false, + tags: m.tags ?? [], + comment: m.comment ?? "", + declaredType: m.declaredType ? owner.objectRegistry.getOrCreateType(m.declaredType) : undefined, + }); + } + return members; + }, + ); + } + /** * Get the type arguments of a type reference (e.g. the `string` in `Array`). */ diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 3d00791be523d..0dd72ea41fc23 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -6617,6 +6617,131 @@ function f(x: number | string): number | string { }); }); +describe("Checker - getMembersOfType and getSymbolDocumentations", { concurrency }, () => { + const source = ` +export class Base { + /** base doc */ + readonly baseProp: string = ""; +} +export class Derived extends Base { + /** @internal */ + readonly hidden = 2; + readonly _under = 4; + /** own doc */ + readonly own: number = 1; + private secret = 3; + /** accessor doc */ + get size(): number { return 0; } + method(): void {} + constructor(public readonly fromCtor: string) { super(); } +} +`; + + test("getMembersOfType returns the surviving members with their documentation, owner and declared type", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": source, + }); + + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const [base, derived] = sourceFile.statements.filter(isClassDeclaration); + const baseSymbol = await project.checker.getSymbolAtLocation(base.name!); + const derivedSymbol = await project.checker.getSymbolAtLocation(derived.name!); + assert.ok(baseSymbol); + assert.ok(derivedSymbol); + const type = await project.checker.getTypeAtLocation(derived); + const properties = await project.checker.getPropertiesOfType(type); + + const members = await project.checker.getMembersOfType(type, { + visibility: ["public", "protected"], + excludeJsDocTags: ["internal"], + excludeNamePattern: "^_", + includeInheritedFrom: { declaringTypes: [baseSymbol] }, + includeDeclaredType: true, + }); + const survivors = new Set(["own", "size", "method", "fromCtor", "baseProp"]); + assert.deepEqual( + members.map(m => m.symbol.name), + properties.filter(p => survivors.has(p.name)).map(p => p.name), + "the filter drops @internal, underscore and private members and keeps property order", + ); + for (const member of members) { + assert.strictEqual(member.symbol, properties.find(p => p.name === member.symbol.name), "member symbols are the symbols getPropertiesOfType returns"); + } + const byName = new Map(members.map(m => [m.symbol.name, m])); + + const own = byName.get("own")!; + assert.equal(own.comment, "own doc"); + assert.deepEqual(own.tags, []); + assert.equal(own.isMethod, false); + assert.equal(own.isParameterProperty, false); + assert.strictEqual(own.owner, derivedSymbol); + assert.strictEqual(own.declaredType, await project.checker.getTypeOfSymbol(own.symbol)); + + const size = byName.get("size")!; + assert.equal(size.isMethod, false, "an accessor is not a method"); + assert.equal(size.comment, "accessor doc"); + assert.ok(size.declaredType); + + const method = byName.get("method")!; + assert.equal(method.isMethod, true); + assert.equal(method.declaredType, undefined, "methods carry no declared type"); + + const fromCtor = byName.get("fromCtor")!; + assert.equal(fromCtor.isParameterProperty, true); + assert.strictEqual(fromCtor.owner, derivedSymbol); + + const baseProp = byName.get("baseProp")!; + assert.strictEqual(baseProp.owner, baseSymbol); + assert.equal(baseProp.comment, "base doc"); + + const ownOnly = await project.checker.getMembersOfType(type, { includeInheritedFrom: {} }); + assert.ok(!ownOnly.some(m => m.symbol.name === "baseProp"), "an empty includeInheritedFrom keeps the queried type's own members only"); + const everything = await project.checker.getMembersOfType(type); + assert.deepEqual(everything.map(m => m.symbol), properties, "no filter means getPropertiesOfType"); + assert.ok(everything.every(m => m.declaredType === undefined), "declared types only on request"); + + // Optional filter fields accept an explicit `undefined` under exactOptionalPropertyTypes. + const explicit = await project.checker.getMembersOfType(type, { + visibility: undefined, + kinds: undefined, + excludeJsDocTags: undefined, + excludeNamePattern: undefined, + includeInheritedFrom: { all: undefined, declaringTypes: undefined }, + includeDeclaredType: undefined, + }); + assert.deepEqual(explicit.map(m => m.symbol), ownOnly.map(m => m.symbol), "explicit undefined fields mean the same as absent ones"); + assert.equal((await project.checker.getMembersOfType(type, { excludeNamePattern: "" })).length, 0, "an empty pattern is a pattern, not an absent one"); + }); + + test("getSymbolDocumentations equals getJsDocTagsOfSymbol and getDocumentationCommentOfSymbol per symbol", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": source, + }); + + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const derived = sourceFile.statements.filter(isClassDeclaration)[1]; + const properties = await project.checker.getPropertiesOfType(await project.checker.getTypeAtLocation(derived)); + assert.ok(properties.length > 3); + + const docs = await project.checker.getSymbolDocumentations(properties); + assert.equal(docs.length, properties.length); + for (let i = 0; i < properties.length; i++) { + assert.deepEqual(docs[i].tags, await project.checker.getJsDocTagsOfSymbol(properties[i]), properties[i].name); + assert.equal(docs[i].comment, await project.checker.getDocumentationCommentOfSymbol(properties[i]), properties[i].name); + } + assert.deepEqual(docs.find((_, i) => properties[i].name === "hidden")!.tags.map(t => t.name), ["internal"]); + assert.deepEqual(await project.checker.getSymbolDocumentations([]), []); + }); +}); + describe("Checker - getReturnTypeOfSignature", { concurrency }, () => { test("returns the return type of a function signature", async () => { await using api = spawnAPI({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 776b3c2a23283..143a03996e3d1 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -59,6 +59,7 @@ import { type TimingInfo, type Type, TypeFlags, + type TypeMember, type TypeParameter, type TypePredicate, type TypeReference, @@ -273,6 +274,19 @@ function assertTypeArraysEquivalent(actual: readonly Type[], expected: readonly assertArrayElementsEquivalent(actual, expected, assertTypesEquivalent, message); } +function assertTypeMembersEquivalent(actual: readonly TypeMember[], expected: readonly TypeMember[], message?: string): void { + assertArrayElementsEquivalent(actual, expected, (actualMember, expectedMember, elementMessage) => { + assertSymbolsEquivalent(actualMember.symbol, expectedMember.symbol, elementMessage); + assertOptionalSymbolsEquivalent(actualMember.owner, expectedMember.owner, elementMessage); + assertOptionalTypesEquivalent(actualMember.declaredType, expectedMember.declaredType, elementMessage); + assert.deepEqual( + { isMethod: actualMember.isMethod, isParameterProperty: actualMember.isParameterProperty, tags: actualMember.tags, comment: actualMember.comment }, + { isMethod: expectedMember.isMethod, isParameterProperty: expectedMember.isParameterProperty, tags: expectedMember.tags, comment: expectedMember.comment }, + elementMessage, + ); + }, message); +} + function assertOptionalTypeArraysEquivalent(actual: readonly (Type | undefined)[], expected: readonly (Type | undefined)[], message?: string): void { assertArrayElementsEquivalent(actual, expected, assertOptionalTypesEquivalent, message); } @@ -1726,6 +1740,8 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Checker", "getMemberInModuleExports", checker.getMemberInModuleExports, assertOptionalSymbolsEquivalent, moduleSymbol, "Derived"), parityCase("Checker", "getJsDocTagsOfSymbol", checker.getJsDocTagsOfSymbol, assertDeepEquivalent, combineSymbol), parityCase("Checker", "getDocumentationCommentOfSymbol", checker.getDocumentationCommentOfSymbol, assertDeepEquivalent, combineSymbol), + parityCase("Checker", "getSymbolDocumentations", checker.getSymbolDocumentations, assertDeepEquivalent, [combineSymbol, derivedMemberSymbol]), + parityCase("Checker", "getMembersOfType", checker.getMembersOfType, assertTypeMembersEquivalent, derivedType, { includeDeclaredType: true }), parityCase("Checker", "getTypeArguments", checker.getTypeArguments, assertTypeArraysEquivalent, boxedType), parityCase("Checker", "getNonMissingTypeOfSymbol", checker.getNonMissingTypeOfSymbol, assertTypesEquivalent, boxedOptSymbol), parityCase("Checker", "isReadonlySymbol", checker.isReadonlySymbol, assertDeepEquivalent, boxedOptSymbol), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 7f7e2e31c6e0e..dd821f34eaf49 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -6417,6 +6417,131 @@ function f(x: number | string): number | string { }); }); +describe("Checker - getMembersOfType and getSymbolDocumentations", { concurrency }, () => { + const source = ` +export class Base { + /** base doc */ + readonly baseProp: string = ""; +} +export class Derived extends Base { + /** @internal */ + readonly hidden = 2; + readonly _under = 4; + /** own doc */ + readonly own: number = 1; + private secret = 3; + /** accessor doc */ + get size(): number { return 0; } + method(): void {} + constructor(public readonly fromCtor: string) { super(); } +} +`; + + test("getMembersOfType returns the surviving members with their documentation, owner and declared type", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": source, + }); + + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const [base, derived] = sourceFile.statements.filter(isClassDeclaration); + const baseSymbol = project.checker.getSymbolAtLocation(base.name!); + const derivedSymbol = project.checker.getSymbolAtLocation(derived.name!); + assert.ok(baseSymbol); + assert.ok(derivedSymbol); + const type = project.checker.getTypeAtLocation(derived); + const properties = project.checker.getPropertiesOfType(type); + + const members = project.checker.getMembersOfType(type, { + visibility: ["public", "protected"], + excludeJsDocTags: ["internal"], + excludeNamePattern: "^_", + includeInheritedFrom: { declaringTypes: [baseSymbol] }, + includeDeclaredType: true, + }); + const survivors = new Set(["own", "size", "method", "fromCtor", "baseProp"]); + assert.deepEqual( + members.map(m => m.symbol.name), + properties.filter(p => survivors.has(p.name)).map(p => p.name), + "the filter drops @internal, underscore and private members and keeps property order", + ); + for (const member of members) { + assert.strictEqual(member.symbol, properties.find(p => p.name === member.symbol.name), "member symbols are the symbols getPropertiesOfType returns"); + } + const byName = new Map(members.map(m => [m.symbol.name, m])); + + const own = byName.get("own")!; + assert.equal(own.comment, "own doc"); + assert.deepEqual(own.tags, []); + assert.equal(own.isMethod, false); + assert.equal(own.isParameterProperty, false); + assert.strictEqual(own.owner, derivedSymbol); + assert.strictEqual(own.declaredType, project.checker.getTypeOfSymbol(own.symbol)); + + const size = byName.get("size")!; + assert.equal(size.isMethod, false, "an accessor is not a method"); + assert.equal(size.comment, "accessor doc"); + assert.ok(size.declaredType); + + const method = byName.get("method")!; + assert.equal(method.isMethod, true); + assert.equal(method.declaredType, undefined, "methods carry no declared type"); + + const fromCtor = byName.get("fromCtor")!; + assert.equal(fromCtor.isParameterProperty, true); + assert.strictEqual(fromCtor.owner, derivedSymbol); + + const baseProp = byName.get("baseProp")!; + assert.strictEqual(baseProp.owner, baseSymbol); + assert.equal(baseProp.comment, "base doc"); + + const ownOnly = project.checker.getMembersOfType(type, { includeInheritedFrom: {} }); + assert.ok(!ownOnly.some(m => m.symbol.name === "baseProp"), "an empty includeInheritedFrom keeps the queried type's own members only"); + const everything = project.checker.getMembersOfType(type); + assert.deepEqual(everything.map(m => m.symbol), properties, "no filter means getPropertiesOfType"); + assert.ok(everything.every(m => m.declaredType === undefined), "declared types only on request"); + + // Optional filter fields accept an explicit `undefined` under exactOptionalPropertyTypes. + const explicit = project.checker.getMembersOfType(type, { + visibility: undefined, + kinds: undefined, + excludeJsDocTags: undefined, + excludeNamePattern: undefined, + includeInheritedFrom: { all: undefined, declaringTypes: undefined }, + includeDeclaredType: undefined, + }); + assert.deepEqual(explicit.map(m => m.symbol), ownOnly.map(m => m.symbol), "explicit undefined fields mean the same as absent ones"); + assert.equal((project.checker.getMembersOfType(type, { excludeNamePattern: "" })).length, 0, "an empty pattern is a pattern, not an absent one"); + }); + + test("getSymbolDocumentations equals getJsDocTagsOfSymbol and getDocumentationCommentOfSymbol per symbol", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": source, + }); + + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const derived = sourceFile.statements.filter(isClassDeclaration)[1]; + const properties = project.checker.getPropertiesOfType(project.checker.getTypeAtLocation(derived)); + assert.ok(properties.length > 3); + + const docs = project.checker.getSymbolDocumentations(properties); + assert.equal(docs.length, properties.length); + for (let i = 0; i < properties.length; i++) { + assert.deepEqual(docs[i].tags, project.checker.getJsDocTagsOfSymbol(properties[i]), properties[i].name); + assert.equal(docs[i].comment, project.checker.getDocumentationCommentOfSymbol(properties[i]), properties[i].name); + } + assert.deepEqual(docs.find((_, i) => properties[i].name === "hidden")!.tags.map(t => t.name), ["internal"]); + assert.deepEqual(project.checker.getSymbolDocumentations([]), []); + }); +}); + describe("Checker - getReturnTypeOfSignature", { concurrency }, () => { test("returns the return type of a function signature", () => { using api = spawnAPI({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 1a832578c0968..268459b8cd61a 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -196,6 +196,7 @@ const ( MethodGetTypePredicateOfSignature Method = "getTypePredicateOfSignature" MethodGetBaseTypes Method = "getBaseTypes" MethodGetPropertiesOfType Method = "getPropertiesOfType" + MethodGetMembersOfType Method = "getMembersOfType" MethodGetApparentPropertiesOfType Method = "getApparentPropertiesOfType" MethodGetApparentType Method = "getApparentType" MethodGetReducedType Method = "getReducedType" @@ -225,6 +226,7 @@ const ( MethodGetExportsOfModule Method = "getExportsOfModule" MethodGetMemberInModuleExports Method = "getMemberInModuleExports" MethodGetJSDocTags Method = "getJsDocTags" + MethodGetSymbolDocumentations Method = "getSymbolDocumentations" MethodGetDocumentationComment Method = "getDocumentationComment" MethodIsArrayType Method = "isArrayType" MethodIsReadonlySymbol Method = "isReadonlySymbol" @@ -733,6 +735,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetTypePredicateOfSignature: unmarshallerFor[CheckerSignatureParams], MethodGetBaseTypes: unmarshallerFor[CheckerTypeParams], MethodGetPropertiesOfType: unmarshallerFor[CheckerTypeParams], + MethodGetMembersOfType: unmarshallerFor[GetMembersOfTypeParams], MethodGetApparentPropertiesOfType: unmarshallerFor[GetTypePropertyParams], MethodGetApparentType: unmarshallerFor[GetTypePropertyParams], MethodGetReducedType: unmarshallerFor[GetTypePropertyParams], @@ -760,6 +763,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetExportsOfModule: unmarshallerFor[CheckerSymbolParams], MethodGetMemberInModuleExports: unmarshallerFor[GetMemberInModuleExportsParams], MethodGetJSDocTags: unmarshallerFor[CheckerSymbolParams], + MethodGetSymbolDocumentations: unmarshallerFor[GetSymbolDocumentationsParams], MethodGetDocumentationComment: unmarshallerFor[CheckerSymbolParams], MethodIsArrayType: unmarshallerFor[CheckerTypeParams], MethodIsReadonlySymbol: unmarshallerFor[CheckerSymbolParams], @@ -1956,6 +1960,80 @@ type JSDocTagInfo struct { Text string `json:"text,omitempty"` } +// GetSymbolDocumentationsParams are parameters for getSymbolDocumentations: the JSDoc +// tags and documentation comment of many symbols in one request. +type GetSymbolDocumentationsParams struct { + Snapshot SnapshotID `json:"snapshot"` + Project project.Handle `json:"project"` + Symbols []SymbolReference `json:"symbols"` +} + +// SymbolDocumentation is one element of a getSymbolDocumentations response; it equals +// the results of getJsDocTags and getDocumentationComment for the same symbol. +type SymbolDocumentation struct { + Tags []*JSDocTagInfo `json:"tags,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// MemberFilter selects which properties of a type getMembersOfType returns. It is a +// set of neutral predicates the caller combines (like a WHERE clause) to describe the +// members it will render, so that no type is materialized for a member it would skip. +// Tool-specific policies (jsii's @internal/underscore convention, TypeDoc's @hidden, ...) +// are expressed by the caller, not encoded here. +type MemberFilter struct { + // Visibilities to include ("public", "protected", "private"); empty means all. + // ECMAScript private names (#x) count as private. + Visibility []string `json:"visibility,omitempty"` + // Member kinds to include ("property", "accessor", "method", "parameterProperty", + // the last being a constructor parameter property); empty means all. + Kinds []string `json:"kinds,omitempty"` + // Drop members whose JSDoc carries any of these tags (e.g. "internal", "deprecated"). + ExcludeJsDocTags []string `json:"excludeJsDocTags,omitempty"` + // Drop members whose name (in its wire form, as the client sees it) matches this + // RE2 pattern, e.g. "^_" or "^(_|prototype$)". Absent means no name filter; a + // pattern that is passed is always compiled, so the empty pattern matches every + // member. + ExcludeNamePattern *string `json:"excludeNamePattern,omitempty"` + // Which declaring types to take members from. Nil means all (plain + // getPropertiesOfType semantics). + IncludeInheritedFrom *InheritedMemberFilter `json:"includeInheritedFrom,omitempty"` + // Include the declared type of each non-method member (getTypeOfSymbol). + IncludeDeclaredType bool `json:"includeDeclaredType,omitempty"` +} + +// InheritedMemberFilter restricts members by the type that declares them: members +// declared on the queried type itself are always kept; with All every base +// contributes; otherwise only members declared on one of DeclaringTypes (by symbol) +// are kept. The declaring type is the type at the declaration's owner node (its +// parent, or grandparent for a constructor parameter property). +type InheritedMemberFilter struct { + All bool `json:"all,omitempty"` + DeclaringTypes []SymbolReference `json:"declaringTypes,omitempty"` +} + +// GetMembersOfTypeParams are parameters for getMembersOfType. +type GetMembersOfTypeParams struct { + Snapshot SnapshotID `json:"snapshot"` + Project project.Handle `json:"project"` + Type TypeID `json:"type"` + Filter MemberFilter `json:"filter"` +} + +// MemberResponse is one property of a type with the information a traversal needs to +// decide how to render it, in a single round trip. +type MemberResponse struct { + Symbol *SymbolResponse `json:"symbol"` + // Owner is the symbol of the type declaring the member (see InheritedMemberFilter), + // when it could be determined; members without a declaration (tuple elements, + // `length`, ...) have none. + Owner *SymbolResponse `json:"owner,omitempty"` + IsMethod bool `json:"isMethod,omitempty"` + IsParameterProperty bool `json:"isParameterProperty,omitempty"` + Tags []*JSDocTagInfo `json:"tags,omitempty"` + Comment string `json:"comment,omitempty"` + DeclaredType *TypeResponse `json:"declaredType,omitempty"` +} + // CheckerSignatureParams are parameters for checker methods that operate on a signature. type CheckerSignatureParams struct { Snapshot SnapshotID `json:"snapshot"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index c29058ece0df7..b3857b6021469 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "regexp" "runtime/debug" "slices" "strconv" @@ -1135,6 +1136,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleGetBaseTypes(ctx, parsed.(*CheckerTypeParams)) case string(MethodGetPropertiesOfType): return s.handleGetPropertiesOfType(ctx, parsed.(*CheckerTypeParams)) + case string(MethodGetMembersOfType): + return s.handleGetMembersOfType(ctx, parsed.(*GetMembersOfTypeParams)) case string(MethodGetApparentPropertiesOfType): return s.handleGetApparentPropertiesOfType(ctx, parsed.(*GetTypePropertyParams)) case string(MethodGetApparentType): @@ -1189,6 +1192,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleGetMemberInModuleExports(ctx, parsed.(*GetMemberInModuleExportsParams)) case string(MethodGetJSDocTags): return s.handleGetJSDocTags(ctx, parsed.(*CheckerSymbolParams)) + case string(MethodGetSymbolDocumentations): + return s.handleGetSymbolDocumentations(ctx, parsed.(*GetSymbolDocumentationsParams)) case string(MethodGetDocumentationComment): return s.handleGetDocumentationComment(ctx, parsed.(*CheckerSymbolParams)) case string(MethodIsArrayType): @@ -5181,6 +5186,209 @@ func (s *Session) handleGetJSDocTags(ctx context.Context, params *CheckerSymbolP return results, nil } +// symbolDocumentation collects the JSDoc tags and documentation comment of a symbol +// exactly as getJsDocTags and getDocumentationComment report them. +func symbolDocumentation(c *checker.Checker, symbol *ast.Symbol) SymbolDocumentation { + tags := ls.GetSymbolJSDocTags(symbol) + var infos []*JSDocTagInfo + if len(tags) > 0 { + infos = make([]*JSDocTagInfo, len(tags)) + for i, tag := range tags { + infos[i] = &JSDocTagInfo{Name: tag.Name, Text: tag.Text} + } + } + return SymbolDocumentation{Tags: infos, Comment: ls.GetSymbolDocumentationComment(c, symbol)} +} + +// handleGetSymbolDocumentations returns the JSDoc tags and documentation comment of many +// symbols in one round trip (element i corresponds to Symbols[i]; a symbol that does not +// resolve yields an empty element). +func (s *Session) handleGetSymbolDocumentations(ctx context.Context, params *GetSymbolDocumentationsParams) ([]SymbolDocumentation, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return nil, err + } + defer setup.done() + + results := make([]SymbolDocumentation, len(params.Symbols)) + for i, ref := range params.Symbols { + symbol, err := setup.resolveSymbolHandle(ref) + if err != nil { + return nil, err + } + if symbol == nil { + continue + } + results[i] = symbolDocumentation(setup.checker, symbol) + } + return results, nil +} + +// handleGetMembersOfType returns the properties of a type that pass the filter, each with +// its documentation, declaring-type symbol and (optionally) declared type, so that a +// traversal of a type's members costs one round trip instead of several per member. +func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembersOfTypeParams) ([]*MemberResponse, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return nil, err + } + defer setup.done() + + t, err := setup.resolveTypeHandle(params.Type) + if err != nil { + return nil, err + } + filter := ¶ms.Filter + + visibilities := make(map[string]struct{}, len(filter.Visibility)) + for _, v := range filter.Visibility { + visibilities[v] = struct{}{} + } + kinds := make(map[string]struct{}, len(filter.Kinds)) + for _, k := range filter.Kinds { + kinds[k] = struct{}{} + } + excludedTags := make(map[string]struct{}, len(filter.ExcludeJsDocTags)) + for _, tag := range filter.ExcludeJsDocTags { + excludedTags[tag] = struct{}{} + } + var excludeName *regexp.Regexp + if filter.ExcludeNamePattern != nil { + excludeName, err = regexp.Compile(*filter.ExcludeNamePattern) + if err != nil { + return nil, fmt.Errorf("%w: invalid excludeNamePattern: %w", ErrClientError, err) + } + } + var declaringTypes map[*ast.Symbol]struct{} + if filter.IncludeInheritedFrom != nil && !filter.IncludeInheritedFrom.All { + declaringTypes = make(map[*ast.Symbol]struct{}, len(filter.IncludeInheritedFrom.DeclaringTypes)+1) + if own := t.Symbol(); own != nil { + declaringTypes[own] = struct{}{} + } + for _, ref := range filter.IncludeInheritedFrom.DeclaringTypes { + declaring, err := setup.resolveSymbolHandle(ref) + if err != nil { + return nil, err + } + if declaring != nil { + declaringTypes[declaring] = struct{}{} + } + } + } + + var results []*MemberResponse + for _, prop := range setup.checker.GetPropertiesOfType(t) { + // Names are matched in their wire form, as the client sees them (e.g. an + // ECMAScript private field is `__#1@#x`). + name := ast.EscapeSymbolName(prop.Name()) + if excludeName != nil && excludeName.MatchString(name) { + continue + } + // A member without a declaration (a tuple element, a tuple's `length`, ...) + // is still a member: it is classified from its symbol and has no owner. + var decl *ast.Node + if decls := prop.Declarations(); len(decls) > 0 { + decl = decls[0] + } + if len(visibilities) > 0 { + if _, ok := visibilities[memberVisibility(prop)]; !ok { + continue + } + } + kind := memberKind(decl, prop) + if len(kinds) > 0 { + if _, ok := kinds[kind]; !ok { + continue + } + } + var docs SymbolDocumentation + docsRead := false + if len(excludedTags) > 0 { + docs = symbolDocumentation(setup.checker, prop) + docsRead = true + excluded := false + for _, tag := range docs.Tags { + if _, ok := excludedTags[tag.Name]; ok { + excluded = true + break + } + } + if excluded { + continue + } + } + var ownerSymbol *ast.Symbol + if decl != nil { + ownerNode := decl.Parent + if kind == "parameterProperty" && ownerNode != nil { + ownerNode = ownerNode.Parent + } + if ownerNode != nil { + if ownerType := setup.checker.GetTypeAtLocation(ownerNode); ownerType != nil { + ownerSymbol = ownerType.Symbol() + } + } + } + // A member whose declaring type is unknown is kept: the filter can only + // drop what it can attribute to another type. + if declaringTypes != nil && ownerSymbol != nil { + if _, ok := declaringTypes[ownerSymbol]; !ok { + continue + } + } + if !docsRead { + docs = symbolDocumentation(setup.checker, prop) + } + member := &MemberResponse{ + Symbol: setup.newSymbolResponse(prop), + IsMethod: kind == "method", + IsParameterProperty: kind == "parameterProperty", + Tags: docs.Tags, + Comment: docs.Comment, + } + if ownerSymbol != nil { + member.Owner = setup.newSymbolResponse(ownerSymbol) + } + if filter.IncludeDeclaredType && !member.IsMethod { + member.DeclaredType = setup.sd.newTypeResponse(setup.projectID, setup.checker.GetTypeOfSymbol(prop), setup.checker) + } + results = append(results, member) + } + return results, nil +} + +// memberVisibility classifies a member as "public", "protected" or "private" +// from the modifiers the checker attributes to the symbol for reads: for a +// getter/setter pair that is the getter's visibility whatever the declaration +// order, and for a synthetic union or intersection member the combined one. +// ECMAScript private names count as private. +func memberVisibility(symbol *ast.Symbol) string { + flags := checker.GetDeclarationModifierFlagsFromSymbol(symbol) + switch { + case flags&ast.ModifierFlagsPrivate != 0, core.Some(symbol.Declarations(), ast.IsPrivateIdentifierClassElementDeclaration): + return "private" + case flags&ast.ModifierFlagsProtected != 0: + return "protected" + } + return "public" +} + +// memberKind classifies a member for MemberFilter.Kinds and MemberResponse.IsMethod +// from its first declaration (nil for a member without one) and its symbol flags. +// Auto-accessors (`accessor x = 0`) are accessors; a synthetic member of a union +// or intersection is classified from its constituents' declarations. +func memberKind(decl *ast.Node, symbol *ast.Symbol) string { + switch { + case decl != nil && decl.Kind == ast.KindParameter: + return "parameterProperty" + case decl != nil && (ast.IsAccessor(decl) || ast.IsAutoAccessorPropertyDeclaration(decl)), symbol.Flags()&ast.SymbolFlagsAccessor != 0: + return "accessor" + case decl != nil && (ast.IsMethodDeclaration(decl) || ast.IsMethodSignatureDeclaration(decl)), symbol.Flags()&ast.SymbolFlagsMethod != 0: + return "method" + } + return "property" +} + // handleGetDocumentationComment returns the rendered documentation comment of a symbol as plain text. func (s *Session) handleGetDocumentationComment(ctx context.Context, params *CheckerSymbolParams) (string, error) { setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) diff --git a/tsc/internal/api/session_members_differential_test.go b/tsc/internal/api/session_members_differential_test.go new file mode 100644 index 0000000000000..8800a924bf745 --- /dev/null +++ b/tsc/internal/api/session_members_differential_test.go @@ -0,0 +1,320 @@ +package api + +import ( + "slices" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +// The differential fixture: a class hierarchy with every member shape a traversal +// tool distinguishes. getMembersOfType must report, for each member, exactly what the +// fine-grained path (getPropertiesOfType + getTypeOfSymbolAtLocation + getJsDocTags + +// getDocumentationComment + the declaration's modifiers) reports, in the same order. +const differentialTestSource = `export interface Shape { + /** area doc */ + area(): number; +} +export class Base { + /** base doc */ + readonly baseProp: string = ""; + /** overridden below */ + describe(): string { return ""; } + protected helper(): void {} + /** base accessor */ + get size(): number { return 0; } + set size(value: number) {} +} +export class Derived extends Base implements Shape { + /** @internal */ + readonly hidden = 2; + readonly _under = 4; + /** @deprecated gone */ + readonly old = 5; + /** own description */ + describe(): string { return "derived"; } + /** overloaded */ + area(): number; + area(scale: number): number; + area(scale?: number): number { return scale ?? 1; } + /** accessor doc */ + get label(): string { return ""; } + set label(value: string) {} + private secret = 3; + protected guarded = 7; + #ecmaPrivate = 6; + constructor(public readonly fromCtor: string, private readonly fromCtorPrivate: number) { super(); } + static create(): Derived { return new Derived("", 0); } +} +` + +// expectedMember is the hand-written description of one instance member of Derived. +type expectedMember struct { + visibility string + kind string + owner string + tags []string + comment string +} + +// expectedMembers is keyed by the member's wire-form name (the ECMAScript private +// field is matched by its "__#@#ecmaPrivate" form below). +var expectedMembers = map[string]expectedMember{ + "hidden": {"public", "property", "Derived", []string{"internal"}, ""}, + "_under": {"public", "property", "Derived", nil, ""}, + "old": {"public", "property", "Derived", []string{"deprecated"}, ""}, + "describe": {"public", "method", "Derived", nil, "own description"}, + "area": {"public", "method", "Derived", nil, "overloaded"}, + "label": {"public", "accessor", "Derived", nil, "accessor doc"}, + "secret": {"private", "property", "Derived", nil, ""}, + "guarded": {"protected", "property", "Derived", nil, ""}, + "#ecmaPrivate": {"private", "property", "Derived", nil, ""}, + "fromCtor": {"public", "parameterProperty", "Derived", nil, ""}, + "fromCtorPrivate": {"private", "parameterProperty", "Derived", nil, ""}, + "baseProp": {"public", "property", "Base", nil, "base doc"}, + "helper": {"protected", "method", "Base", nil, ""}, + "size": {"public", "accessor", "Base", nil, "base accessor"}, +} + +// displayName maps a wire-form name back to the fixture's key. +func displayName(wireName string) string { + if i := strings.Index(wireName, "@#"); strings.HasPrefix(wireName, "__#") && i >= 0 { + return wireName[i+1:] + } + return wireName +} + +func displayNames(members []*MemberResponse) []string { + names := make([]string, len(members)) + for i, m := range members { + names[i] = displayName(m.Symbol.Name) + } + return names +} + +// fineGrainedMember is what a traversal assembles per property from the existing +// methods, several round trips per member. +type fineGrainedMember struct { + symbol *SymbolResponse + declaredType *TypeResponse + tags []*JSDocTagInfo + comment string +} + +func (c *membersTestContext) fineGrainedMembers(t *testing.T, instanceType *TypeResponse) []fineGrainedMember { + t.Helper() + props, err := c.session.handleGetPropertiesOfType(t.Context(), &CheckerTypeParams{Snapshot: c.snapshot, Project: c.project, Type: instanceType.Id}) + assert.NilError(t, err) + members := make([]fineGrainedMember, len(props)) + for i, prop := range props { + assert.Assert(t, len(prop.Declarations) > 0, "%s has a declaration", prop.Name) + declaredType, err := c.session.handleGetTypeOfSymbolAtLocation(t.Context(), &GetTypeOfSymbolAtLocationParams{ + Snapshot: c.snapshot, + Project: c.project, + Symbol: prop.Reference, + Location: requestNodeHandle(t, prop.Declarations[0]), + }) + assert.NilError(t, err) + tags, err := c.session.handleGetJSDocTags(t.Context(), &CheckerSymbolParams{Snapshot: c.snapshot, Project: c.project, Symbol: prop.Reference}) + assert.NilError(t, err) + comment, err := c.session.handleGetDocumentationComment(t.Context(), &CheckerSymbolParams{Snapshot: c.snapshot, Project: c.project, Symbol: prop.Reference}) + assert.NilError(t, err) + members[i] = fineGrainedMember{symbol: prop, declaredType: declaredType, tags: tags, comment: comment} + } + return members +} + +func (c *membersTestContext) membersOf(t *testing.T, instanceType *TypeResponse, filter MemberFilter) []*MemberResponse { + t.Helper() + members, err := c.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: c.snapshot, + Project: c.project, + Type: instanceType.Id, + Filter: filter, + }) + assert.NilError(t, err) + return members +} + +// requestNodeHandle turns the "index.kind.#fileID.path" handle of a response into the +// "index.#fileID" form a client sends back. +func requestNodeHandle(t *testing.T, handle NodeHandle) NodeHandle { + t.Helper() + parts := strings.SplitN(string(handle), ".", 4) + assert.Assert(t, len(parts) == 4 && strings.HasPrefix(parts[2], "#"), "unexpected node handle %q", handle) + return NodeHandle(parts[0] + "." + parts[2]) +} + +func tagNames(tags []*JSDocTagInfo) []string { + var names []string + for _, tag := range tags { + names = append(names, tag.Name) + } + return names +} + +// expectedNames returns, in the given reference order, the fixture members that +// satisfy pred. +func expectedNames(order []string, pred func(name string, m expectedMember) bool) []string { + var names []string + for _, name := range order { + if pred(name, expectedMembers[name]) { + names = append(names, name) + } + } + return names +} + +func TestGetMembersOfTypeMatchesTheFineGrainedPath(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, differentialTestSource) + defer cleanup() + + derived := ctx.classSymbol(t, "Derived") + base := ctx.classSymbol(t, "Base") + instanceType := ctx.declaredType(t, derived) + owners := map[string]*SymbolResponse{"Derived": derived, "Base": base} + + fine := ctx.fineGrainedMembers(t, instanceType) + order := make([]string, len(fine)) + for i, m := range fine { + order[i] = displayName(m.symbol.Name) + } + // The fixture describes exactly the instance members the checker reports; the + // static `create` is not among them. + assert.Equal(t, len(fine), len(expectedMembers), "fixture covers every property: %v", order) + for _, name := range order { + _, ok := expectedMembers[name] + assert.Assert(t, ok, "unexpected property %s", name) + } + + // 1. With no filter, getMembersOfType is getPropertiesOfType plus, per member, + // the same documentation and declared type the fine-grained path obtains, in + // the same order and with the same handles. + coarse := ctx.membersOf(t, instanceType, MemberFilter{IncludeDeclaredType: true}) + assert.DeepEqual(t, displayNames(coarse), order) + for i, m := range coarse { + f := fine[i] + name := order[i] + want := expectedMembers[name] + assert.DeepEqual(t, m.Symbol.Reference, f.symbol.Reference) + assert.Equal(t, m.Comment, f.comment, "comment of %s", name) + assert.Equal(t, m.Comment, want.comment, "comment of %s", name) + assert.DeepEqual(t, tagNames(m.Tags), tagNames(f.tags)) + assert.DeepEqual(t, tagNames(m.Tags), want.tags) + assert.Equal(t, m.IsMethod, want.kind == "method", "isMethod of %s", name) + assert.Equal(t, m.IsParameterProperty, want.kind == "parameterProperty", "isParameterProperty of %s", name) + if m.IsMethod { + assert.Assert(t, m.DeclaredType == nil, "%s: methods are rendered from their signatures", name) + } else { + assert.Assert(t, m.DeclaredType != nil, "%s: declared type requested", name) + assert.Equal(t, m.DeclaredType.Id, f.declaredType.Id, "declared type handle of %s", name) + } + assert.Assert(t, m.Owner != nil, "owner of %s", name) + assert.DeepEqual(t, m.Owner.Reference, owners[want.owner].Reference) + // The owner is also the member symbol's parent, which is what a fine-grained + // client would compare against. + assert.Assert(t, f.symbol.Parent != nil && f.symbol.Parent.Id == m.Owner.Reference.Id, "owner of %s is its symbol's parent", name) + } + + // 2. Each predicate on its own partitions the members as the fixture says, in + // property order. + for _, visibility := range []string{"public", "protected", "private"} { + got := ctx.membersOf(t, instanceType, MemberFilter{Visibility: []string{visibility}}) + assert.DeepEqual(t, displayNames(got), expectedNames(order, func(_ string, m expectedMember) bool { return m.visibility == visibility })) + } + for _, kind := range []string{"property", "accessor", "method", "parameterProperty"} { + got := ctx.membersOf(t, instanceType, MemberFilter{Kinds: []string{kind}}) + assert.DeepEqual(t, displayNames(got), expectedNames(order, func(_ string, m expectedMember) bool { return m.kind == kind })) + for _, m := range got { + assert.Equal(t, m.IsMethod, kind == "method", "isMethod of %s admitted as %s", m.Symbol.Name, kind) + } + } + for _, owner := range []string{"Derived", "Base"} { + var filter MemberFilter + if owner == "Derived" { + filter.IncludeInheritedFrom = &InheritedMemberFilter{} + } else { + filter.IncludeInheritedFrom = &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}} + } + got := ctx.membersOf(t, instanceType, filter) + want := expectedNames(order, func(_ string, m expectedMember) bool { return m.owner == "Derived" || m.owner == owner }) + assert.Check(t, slices.Equal(displayNames(got), want), "declaring types up to %s: got %v, want %v", owner, displayNames(got), want) + } + // The ECMAScript private field is matched on its wire form. + got := ctx.membersOf(t, instanceType, MemberFilter{ExcludeNamePattern: new("^__#")}) + assert.DeepEqual(t, displayNames(got), slices.DeleteFunc(slices.Clone(order), func(name string) bool { return name == "#ecmaPrivate" })) + + // 3. The combination a binding generator uses: public and protected members, + // nothing @internal or @deprecated, no underscore names, declared on the type + // itself or on Base. What survives is exactly the fixture's prediction, in + // property order, and carries the same information as in 1. + policy := MemberFilter{ + Visibility: []string{"public", "protected"}, + ExcludeJsDocTags: []string{"internal", "deprecated"}, + ExcludeNamePattern: new("^_"), + IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, + IncludeDeclaredType: true, + } + survivors := ctx.membersOf(t, instanceType, policy) + assert.DeepEqual(t, displayNames(survivors), expectedNames(order, func(name string, m expectedMember) bool { + return m.visibility != "private" && !slices.Contains(m.tags, "internal") && !slices.Contains(m.tags, "deprecated") && !strings.HasPrefix(name, "_") + })) + assert.DeepEqual(t, displayNames(survivors), []string{"describe", "area", "label", "guarded", "fromCtor", "baseProp", "helper", "size"}) + byName := map[string]*MemberResponse{} + for _, m := range coarse { + byName[displayName(m.Symbol.Name)] = m + } + for _, m := range survivors { + assert.DeepEqual(t, m, byName[displayName(m.Symbol.Name)]) + } +} + +func TestGetMembersOfTypeOverloadsAndOverrides(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, differentialTestSource) + defer cleanup() + + derived := ctx.classSymbol(t, "Derived") + base := ctx.classSymbol(t, "Base") + instanceType := ctx.declaredType(t, derived) + byName := map[string]*MemberResponse{} + for _, m := range ctx.membersOf(t, instanceType, MemberFilter{}) { + byName[displayName(m.Symbol.Name)] = m + } + + // An override is reported once, as the derived class's own member: the symbol is + // the derived declaration's and so is the owner, even though Base declares a + // member of the same name. + describe := byName["describe"] + assert.DeepEqual(t, describe.Owner.Reference, derived.Reference) + assert.Equal(t, describe.Comment, "own description") + assert.Equal(t, len(describe.Symbol.Declarations), 1) + // The base class's own view still attributes its member to Base. + baseType := ctx.declaredType(t, base) + for _, m := range ctx.membersOf(t, baseType, MemberFilter{}) { + if displayName(m.Symbol.Name) == "describe" { + assert.DeepEqual(t, m.Owner.Reference, base.Reference) + assert.Equal(t, m.Comment, "overridden below") + } + } + + // Overloads are one member with one declaration per signature; its + // documentation comes from the first declaration like getDocumentationComment. + area := byName["area"] + assert.Assert(t, area.IsMethod) + assert.Equal(t, len(area.Symbol.Declarations), 3) + assert.Equal(t, area.Comment, "overloaded") + + // A getter/setter pair is one accessor member whose declared type is the + // accessor type. + label := byName["label"] + assert.Assert(t, !label.IsMethod) + assert.Equal(t, len(label.Symbol.Declarations), 2) + withTypes := ctx.membersOf(t, instanceType, MemberFilter{Kinds: []string{"accessor"}, IncludeDeclaredType: true}) + assert.DeepEqual(t, displayNames(withTypes), []string{"label", "size"}) + for _, m := range withTypes { + assert.Assert(t, m.DeclaredType != nil, "%s", m.Symbol.Name) + } +} diff --git a/tsc/internal/api/session_members_test.go b/tsc/internal/api/session_members_test.go new file mode 100644 index 0000000000000..a89628f751247 --- /dev/null +++ b/tsc/internal/api/session_members_test.go @@ -0,0 +1,410 @@ +package api + +import ( + "slices" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +const membersTestFile = "/home/projects/p/src/index.ts" + +const membersTestSource = `export class Base { + /** base doc */ + readonly baseProp: string = ""; +} +export class C extends Base { + /** doc for own */ + readonly own: number = 1; + /** @internal */ + readonly hidden = 2; + private secret = 3; + readonly _under = 4; + /** @deprecated gone */ + readonly old = 5; + #ecmaPrivate = 6; + constructor(public readonly fromCtor: string) { super(); } + method(): void {} + /** accessor doc */ + get size(): number { return 0; } + set size(value: number) {} +} +` + +type membersTestContext struct { + session *Session + snapshot SnapshotID + project project.Handle + source string +} + +func setupMembersTest(t *testing.T) (*membersTestContext, func()) { + return setupMembersTestWithSource(t, membersTestSource) +} + +func setupMembersTestWithSource(t *testing.T, source string) (*membersTestContext, func()) { + t.Helper() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + files := map[string]any{ + "/home/projects/p/tsconfig.json": `{ "compilerOptions": { "strict": true } }`, + membersTestFile: source, + } + projectSession, _ := projecttestutil.Setup(files) + session := NewLSPSession(projectSession, nil) + snapshotResp, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenFiles: []DocumentIdentifier{{FileName: membersTestFile}}, + }) + assert.NilError(t, err) + proj, err := session.handleGetDefaultProjectForFile(t.Context(), &GetDefaultProjectForFileParams{ + Snapshot: snapshotResp.Snapshot, + File: DocumentIdentifier{FileName: membersTestFile}, + }) + assert.NilError(t, err) + assert.Assert(t, proj != nil) + ctx := &membersTestContext{session: session, snapshot: snapshotResp.Snapshot, project: proj.Handle, source: source} + return ctx, func() { + session.Close() + projectSession.Close() + } +} + +// classSymbol returns the symbol of the class declared as `export class `. +func (c *membersTestContext) classSymbol(t *testing.T, name string) *SymbolResponse { + t.Helper() + return c.declarationSymbol(t, "export class "+name) +} + +// declarationSymbol returns the symbol of the name that follows `marker` in the source. +func (c *membersTestContext) declarationSymbol(t *testing.T, marker string) *SymbolResponse { + t.Helper() + pos := strings.Index(c.source, marker) + assert.Assert(t, pos >= 0, "marker %q not found", marker) + symbol, err := c.session.handleGetSymbolAtPosition(t.Context(), &GetSymbolAtPositionParams{ + Snapshot: c.snapshot, + Project: c.project, + File: DocumentIdentifier{FileName: membersTestFile}, + Position: uint32(pos + len(marker)), + }) + assert.NilError(t, err) + assert.Assert(t, symbol != nil, "%q should be followed by a declaration with a symbol", marker) + return symbol +} + +func (c *membersTestContext) declaredType(t *testing.T, symbol *SymbolResponse) *TypeResponse { + t.Helper() + typeResp, err := c.session.handleGetDeclaredTypeOfSymbol(t.Context(), &GetTypeOfSymbolParams{ + Snapshot: c.snapshot, + Project: c.project, + Symbol: symbol.Reference, + }) + assert.NilError(t, err) + assert.Assert(t, typeResp != nil) + return typeResp +} + +func memberNames(members []*MemberResponse) []string { + names := make([]string, len(members)) + for i, m := range members { + names[i] = m.Symbol.Name + } + return names +} + +func TestGetMembersOfTypeCombinesPredicatesLikeATraversal(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTest(t) + defer cleanup() + + class := ctx.classSymbol(t, "C") + instanceType := ctx.declaredType(t, class) + + // jsii's member policy expressed with the neutral predicates: public/protected + // only, no @internal / @deprecated, no underscore names, own members only. + members, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{ + Visibility: []string{"public", "protected"}, + ExcludeJsDocTags: []string{"internal", "deprecated"}, + ExcludeNamePattern: new("^_"), + IncludeInheritedFrom: &InheritedMemberFilter{}, + IncludeDeclaredType: true, + }, + }) + assert.NilError(t, err) + // baseProp is declared on Base (not the queried type); hidden (@internal), secret + // (private), _under (underscore), #ecmaPrivate (private) and old (@deprecated) are + // filtered out. + assert.DeepEqual(t, memberNames(members), []string{"own", "fromCtor", "method", "size"}) + + byName := map[string]*MemberResponse{} + for _, m := range members { + byName[m.Symbol.Name] = m + } + own := byName["own"] + assert.Equal(t, own.Comment, "doc for own") + assert.Assert(t, own.DeclaredType != nil, "non-method members carry their declared type") + assert.Assert(t, !own.IsMethod) + assert.Assert(t, own.Owner != nil && own.Owner.Reference.Id == class.Reference.Id, "owner is the declaring class") + + fromCtor := byName["fromCtor"] + assert.Assert(t, fromCtor.IsParameterProperty) + assert.Assert(t, fromCtor.DeclaredType != nil) + + method := byName["method"] + assert.Assert(t, method.IsMethod) + assert.Assert(t, method.DeclaredType == nil, "methods are rendered from their signature, no declared type") + + size := byName["size"] + assert.Assert(t, !size.IsMethod, "an accessor is not a method") + assert.Equal(t, size.Comment, "accessor doc") + assert.Assert(t, size.DeclaredType != nil, "an accessor carries its declared type like a property") + + // The declared type handle is the same type the checker reports for the symbol: + // one id per type on the single API checker, so handles stay comparable. + direct, err := ctx.session.handleGetTypeOfSymbol(t.Context(), &GetTypeOfSymbolParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Symbol: own.Symbol.Reference, + }) + assert.NilError(t, err) + assert.Equal(t, direct.Id, own.DeclaredType.Id) +} + +func TestGetMembersOfTypeInheritedFromAndKinds(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTest(t) + defer cleanup() + + class := ctx.classSymbol(t, "C") + base := ctx.classSymbol(t, "Base") + instanceType := ctx.declaredType(t, class) + + members, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{ + Visibility: []string{"public", "protected"}, + ExcludeNamePattern: new("^_"), + IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, + }, + }) + assert.NilError(t, err) + names := strings.Join(memberNames(members), ",") + assert.Assert(t, strings.Contains(names, "baseProp"), "base members are admitted when the base is a declaring type: %v", names) + assert.Assert(t, strings.Contains(names, "old"), "deprecated members stay without an excluded tag: %v", names) + assert.Assert(t, strings.Contains(names, "hidden"), "@internal members stay without an excluded tag: %v", names) + assert.Assert(t, !strings.Contains(names, "secret") && !strings.Contains(names, "#ecmaPrivate"), "private members are dropped by visibility: %v", names) + for _, m := range members { + assert.Assert(t, m.DeclaredType == nil, "no declared types unless requested") + } + + // nil IncludeInheritedFrom means every declaring type, like getPropertiesOfType. + all, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + }) + assert.NilError(t, err) + props, err := ctx.session.handleGetPropertiesOfType(t.Context(), &CheckerTypeParams{Snapshot: ctx.snapshot, Project: ctx.project, Type: instanceType.Id}) + assert.NilError(t, err) + assert.Equal(t, len(all), len(props), "an empty filter returns every property") + + // Kinds select by declaration shape. + methods, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{Kinds: []string{"method"}}, + }) + assert.NilError(t, err) + assert.DeepEqual(t, memberNames(methods), []string{"method"}) + paramProps, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{Kinds: []string{"parameterProperty"}}, + }) + assert.NilError(t, err) + assert.DeepEqual(t, memberNames(paramProps), []string{"fromCtor"}) + accessors, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{Kinds: []string{"accessor"}}, + }) + assert.NilError(t, err) + assert.DeepEqual(t, memberNames(accessors), []string{"size"}) + properties, err := ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{Kinds: []string{"property"}}, + }) + assert.NilError(t, err) + assert.Assert(t, !slices.Contains(memberNames(properties), "size"), "an accessor is not a property: %v", memberNames(properties)) + assert.Assert(t, !slices.Contains(memberNames(properties), "method"), "a method is not a property: %v", memberNames(properties)) + // isMethod agrees with the "method" kind for every member, whichever filter admitted it. + for _, m := range all { + assert.Equal(t, m.IsMethod, slices.Contains(memberNames(methods), m.Symbol.Name), "isMethod of %s", m.Symbol.Name) + } + + _, err = ctx.session.handleGetMembersOfType(t.Context(), &GetMembersOfTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + Filter: MemberFilter{ExcludeNamePattern: new("(")}, + }) + assert.ErrorContains(t, err, "invalid excludeNamePattern") +} + +func TestGetSymbolDocumentationsMatchesIndividualCalls(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTest(t) + defer cleanup() + + class := ctx.classSymbol(t, "C") + instanceType := ctx.declaredType(t, class) + props, err := ctx.session.handleGetPropertiesOfType(t.Context(), &CheckerTypeParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Type: instanceType.Id, + }) + assert.NilError(t, err) + assert.Assert(t, len(props) > 3) + + refs := make([]SymbolReference, len(props)) + for i, p := range props { + refs[i] = p.Reference + } + docs, err := ctx.session.handleGetSymbolDocumentations(t.Context(), &GetSymbolDocumentationsParams{ + Snapshot: ctx.snapshot, + Project: ctx.project, + Symbols: refs, + }) + assert.NilError(t, err) + assert.Equal(t, len(docs), len(props)) + for i, p := range props { + tags, err := ctx.session.handleGetJSDocTags(t.Context(), &CheckerSymbolParams{Snapshot: ctx.snapshot, Project: ctx.project, Symbol: p.Reference}) + assert.NilError(t, err) + comment, err := ctx.session.handleGetDocumentationComment(t.Context(), &CheckerSymbolParams{Snapshot: ctx.snapshot, Project: ctx.project, Symbol: p.Reference}) + assert.NilError(t, err) + assert.Equal(t, docs[i].Comment, comment, "comment of %s", p.Name) + assert.Equal(t, len(docs[i].Tags), len(tags), "tags of %s", p.Name) + for j := range tags { + assert.Equal(t, *docs[i].Tags[j], *tags[j]) + } + } +} + +// The shapes the review of the first version found missing: members without a +// declaration, accessor pairs declared setter-first, auto-accessors, and the +// synthetic member of a union. +const membersEdgeCaseSource = `export type Pair = [string, number]; +export class Order { + private set x(value: number) {} + get x(): number { return 0; } + accessor auto = 0; + plain = ""; +} +export type Either = { m(): number } | { m(): string }; +` + +func TestGetMembersOfTypeKeepsMembersWithoutDeclarations(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, membersEdgeCaseSource) + defer cleanup() + + pair := ctx.declaredType(t, ctx.declarationSymbol(t, "export type ")) + props, err := ctx.session.handleGetPropertiesOfType(t.Context(), &CheckerTypeParams{Snapshot: ctx.snapshot, Project: ctx.project, Type: pair.Id}) + assert.NilError(t, err) + propNames := memberNames2(props) + assert.Assert(t, slices.Contains(propNames, "0") && slices.Contains(propNames, "length"), "tuple members: %v", propNames) + + members := ctx.membersOf(t, pair, MemberFilter{IncludeDeclaredType: true}) + assert.DeepEqual(t, memberNames(members), propNames) + for _, m := range members { + switch m.Symbol.Name { + case "0", "1", "length": + assert.Assert(t, len(m.Symbol.Declarations) == 0, "%s has no declaration", m.Symbol.Name) + assert.Assert(t, m.Owner == nil, "%s has no owner", m.Symbol.Name) + assert.Assert(t, !m.IsMethod && !m.IsParameterProperty) + assert.Assert(t, m.DeclaredType != nil, "%s has a declared type", m.Symbol.Name) + direct, err := ctx.session.handleGetTypeOfSymbol(t.Context(), &GetTypeOfSymbolParams{Snapshot: ctx.snapshot, Project: ctx.project, Symbol: m.Symbol.Reference}) + assert.NilError(t, err) + assert.Equal(t, m.DeclaredType.Id, direct.Id) + } + } + // Predicates still apply to them: they are public properties, and a filter on + // the declaring type keeps what it cannot attribute to another type. + public := ctx.membersOf(t, pair, MemberFilter{Visibility: []string{"public"}, Kinds: []string{"property"}, IncludeInheritedFrom: &InheritedMemberFilter{}}) + assert.Assert(t, slices.Contains(memberNames(public), "0") && slices.Contains(memberNames(public), "length"), "%v", memberNames(public)) + assert.Assert(t, !slices.Contains(memberNames(public), "push"), "array methods are not properties: %v", memberNames(public)) +} + +func TestGetMembersOfTypeEmptyNamePatternIsNotAbsent(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, membersEdgeCaseSource) + defer cleanup() + + order := ctx.declaredType(t, ctx.classSymbol(t, "Order")) + all := ctx.membersOf(t, order, MemberFilter{}) + assert.DeepEqual(t, memberNames(all), []string{"x", "auto", "plain"}) + assert.DeepEqual(t, memberNames(ctx.membersOf(t, order, MemberFilter{ExcludeNamePattern: nil})), memberNames(all)) + assert.Equal(t, len(ctx.membersOf(t, order, MemberFilter{ExcludeNamePattern: new("")})), 0, "the empty pattern matches every name") +} + +func TestGetMembersOfTypeReadVisibilityOfAccessorPairs(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, membersEdgeCaseSource) + defer cleanup() + + order := ctx.declaredType(t, ctx.classSymbol(t, "Order")) + public := ctx.membersOf(t, order, MemberFilter{Visibility: []string{"public"}}) + assert.Assert(t, slices.Contains(memberNames(public), "x"), "a private setter declared before a public getter does not hide the member: %v", memberNames(public)) + private := ctx.membersOf(t, order, MemberFilter{Visibility: []string{"private"}}) + assert.Equal(t, len(private), 0, "%v", memberNames(private)) +} + +func TestGetMembersOfTypeAutoAccessorIsAnAccessor(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, membersEdgeCaseSource) + defer cleanup() + + order := ctx.declaredType(t, ctx.classSymbol(t, "Order")) + assert.DeepEqual(t, memberNames(ctx.membersOf(t, order, MemberFilter{Kinds: []string{"accessor"}})), []string{"x", "auto"}) + assert.DeepEqual(t, memberNames(ctx.membersOf(t, order, MemberFilter{Kinds: []string{"property"}})), []string{"plain"}) +} + +func TestGetMembersOfTypeUnionMethodIsAMethod(t *testing.T) { + t.Parallel() + ctx, cleanup := setupMembersTestWithSource(t, membersEdgeCaseSource) + defer cleanup() + + either := ctx.declaredType(t, ctx.declarationSymbol(t, "export type Either")) + members := ctx.membersOf(t, either, MemberFilter{IncludeDeclaredType: true}) + assert.DeepEqual(t, memberNames(members), []string{"m"}) + m := members[0] + assert.Assert(t, m.Symbol.Flags&uint32(ast.SymbolFlagsMethod) == 0, "the synthetic union member carries no method flag; the test would be vacuous otherwise") + assert.Assert(t, m.IsMethod, "a member whose constituents are methods is a method") + assert.Assert(t, m.DeclaredType == nil) + assert.DeepEqual(t, memberNames(ctx.membersOf(t, either, MemberFilter{Kinds: []string{"method"}})), []string{"m"}) + assert.Equal(t, len(ctx.membersOf(t, either, MemberFilter{Kinds: []string{"property"}})), 0) +} + +func memberNames2(symbols []*SymbolResponse) []string { + names := make([]string, len(symbols)) + for i, s := range symbols { + names[i] = s.Name + } + return names +}