From 3fcd4c629495a4e87d8d9afaf7a2c31e36662d1e Mon Sep 17 00:00:00 2001 From: WinterYukky <49480575+WinterYukky@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:28:21 +0900 Subject: [PATCH 1/3] api: getSymbolDocumentations and getMembersOfType, coarse-grained checker queries for traversal tools Tools that walk a whole program (binding generators, documentation generators, API extractors) ask the checker, for every member of every type, for its documentation and its type, and skip most members after looking at them. Over the synchronous API that is several round trips per member, and the server materializes a type for members the caller then discards. getSymbolDocumentations returns the JSDoc tags and documentation comment of many symbols in one request, element for element equal to getJsDocTags and getDocumentationComment. getMembersOfType returns the properties of a type that pass a filter, each with its documentation, the symbol of the type declaring it and, on request, its declared type. The filter is a set of neutral predicates (visibility, member kind, excluded JSDoc tags, an excluded name pattern, which declaring types to take members from) that the caller combines to describe the members it will render; a tool's own policy stays in the tool. Members come back in getPropertiesOfType order with the same symbol and type handles. The Go tests include a differential fixture: a class hierarchy with overrides, overloads, accessors, every visibility including an ECMAScript private name, a parameter property and excluded members, checked member by member against what the fine-grained path (getPropertiesOfType + getTypeOfSymbolAtLocation + getJsDocTags + getDocumentationComment) reports. The client tests cover the two methods through the API and in the generator batching parity suite. Refs: https://github.com/microsoft/TypeScript/issues/65051 --- packages/typescript/src/api/async/api.ts | 101 ++++++ .../typescript/src/api/proto.generated.ts | 100 ++++++ packages/typescript/src/api/sync/api.ts | 168 +++++++++ packages/typescript/test/async/api.test.ts | 113 +++++++ .../test/sync/api-generators.test.ts | 16 + packages/typescript/test/sync/api.test.ts | 113 +++++++ tsc/internal/api/proto.go | 76 +++++ tsc/internal/api/session.go | 196 +++++++++++ .../api/session_members_differential_test.go | 320 ++++++++++++++++++ tsc/internal/api/session_members_test.go | 306 +++++++++++++++++ 10 files changed, 1509 insertions(+) create mode 100644 tsc/internal/api/session_members_differential_test.go create mode 100644 tsc/internal/api/session_members_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index d0b0bf7fe8aab..e9737f54338d1 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -2311,6 +2311,46 @@ 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")[]; + /** Member kinds to include; empty means all. */ + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[]; + /** Drop members whose JSDoc carries any of these tags. */ + excludeJsDocTags?: readonly string[]; + /** Drop members whose (wire-form) name matches this RE2 pattern. */ + excludeNamePattern?: string; + /** + * Which declaring types to take members from: `undefined` means all, `{}` means the + * queried type only, `{ declaringTypes }` adds members declared on those types. + */ + includeInheritedFrom?: { all?: boolean; declaringTypes?: readonly Symbol[]; }; + includeDeclaredType?: boolean; +} + +/** 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. */ + owner: Symbol | undefined; + isMethod: boolean; + isParameterProperty: boolean; + tags: readonly JSDocTagInfo[]; + comment: string; + /** `getTypeOfSymbolAtLocation(symbol, firstDeclaration)`; only with `includeDeclaredType`. */ + declaredType: Type | undefined; +} + export class Checker { private snapshotId: number; private project: Project; @@ -3273,6 +3313,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..3ba4b0c15979d 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,32 @@ 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. + */ + 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 +1036,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 +1323,7 @@ export interface BatchRequest { | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" + | "getMembersOfType" | "getMergedSymbol" | "getModeForResolutionAtIndex" | "getModeForUsageLocation" @@ -1323,6 +1371,7 @@ export interface BatchRequest { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolDocumentations" | "getSymbolOfDeclaration" | "getSymbolOfDeclarationForChecker" | "getSymbolOfNode" @@ -1470,6 +1519,7 @@ export interface BatchResponse { | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" + | "getMembersOfType" | "getMergedSymbol" | "getModeForResolutionAtIndex" | "getModeForUsageLocation" @@ -1517,6 +1567,7 @@ export interface BatchResponse { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolDocumentations" | "getSymbolOfDeclaration" | "getSymbolOfDeclarationForChecker" | "getSymbolOfNode" @@ -1752,6 +1803,43 @@ 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$)". + */ + 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 (getTypeOfSymbolAtLocation at + * its first declaration). + */ + 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..e7c99ace9c0b7 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -4177,6 +4177,46 @@ 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")[]; + /** Member kinds to include; empty means all. */ + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[]; + /** Drop members whose JSDoc carries any of these tags. */ + excludeJsDocTags?: readonly string[]; + /** Drop members whose (wire-form) name matches this RE2 pattern. */ + excludeNamePattern?: string; + /** + * Which declaring types to take members from: `undefined` means all, `{}` means the + * queried type only, `{ declaringTypes }` adds members declared on those types. + */ + includeInheritedFrom?: { all?: boolean; declaringTypes?: readonly Symbol[]; }; + includeDeclaredType?: boolean; +} + +/** 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. */ + owner: Symbol | undefined; + isMethod: boolean; + isParameterProperty: boolean; + tags: readonly JSDocTagInfo[]; + comment: string; + /** `getTypeOfSymbolAtLocation(symbol, firstDeclaration)`; only with `includeDeclaredType`. */ + declaredType: Type | undefined; +} + export class Checker { private snapshotId: number; private project: Project; @@ -6799,6 +6839,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..2406b14f76452 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -6617,6 +6617,119 @@ 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"); + }); + + 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..99303c21a1696 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -6417,6 +6417,119 @@ 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"); + }); + + 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..643b9d6265f3a 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,78 @@ 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$)". + 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 (getTypeOfSymbolAtLocation at + // its first declaration). + 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. + 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..32fe79cddfdc1 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,197 @@ 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 != "" { + 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 + } + decls := prop.Declarations() + if len(decls) == 0 { + continue + } + decl := decls[0] + if len(visibilities) > 0 { + if _, ok := visibilities[memberVisibility(decl)]; !ok { + continue + } + } + isParameterProperty := decl.Kind == ast.KindParameter + if len(kinds) > 0 { + if _, ok := kinds[memberKind(decl, prop)]; !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 + } + } + ownerNode := decl.Parent + if isParameterProperty && ownerNode != nil { + ownerNode = ownerNode.Parent + } + var ownerSymbol *ast.Symbol + if ownerNode != nil { + if ownerType := setup.checker.GetTypeAtLocation(ownerNode); ownerType != nil { + ownerSymbol = ownerType.Symbol() + } + } + if declaringTypes != nil { + if _, ok := declaringTypes[ownerSymbol]; !ok { + continue + } + } + if !docsRead { + docs = symbolDocumentation(setup.checker, prop) + } + member := &MemberResponse{ + Symbol: setup.newSymbolResponse(prop), + IsMethod: prop.Flags()&ast.SymbolFlagsMethod != 0, + IsParameterProperty: isParameterProperty, + 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.GetTypeOfSymbolAtLocation(prop, decl), setup.checker) + } + results = append(results, member) + } + return results, nil +} + +// memberVisibility classifies a member declaration as "public", "protected" or +// "private" (ECMAScript private names count as private). +func memberVisibility(decl *ast.Node) string { + switch { + case ast.HasSyntacticModifier(decl, ast.ModifierFlagsPrivate), ast.IsPrivateIdentifierClassElementDeclaration(decl): + return "private" + case ast.HasSyntacticModifier(decl, ast.ModifierFlagsProtected): + return "protected" + } + return "public" +} + +// memberKind classifies a member declaration for MemberFilter.Kinds. +func memberKind(decl *ast.Node, symbol *ast.Symbol) string { + switch { + case decl.Kind == ast.KindParameter: + return "parameterProperty" + case ast.IsAccessor(decl): + return "accessor" + case 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..6ffff174c3755 --- /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: "^__#"}) + 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: "^_", + 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..020b63803ff29 --- /dev/null +++ b/tsc/internal/api/session_members_test.go @@ -0,0 +1,306 @@ +package api + +import ( + "slices" + "strings" + "testing" + + "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: "^_", + 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: "^_", + 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: "("}, + }) + 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]) + } + } +} From 523e7c0a972be9cb20aaaa9009dcc71269996130 Mon Sep 17 00:00:00 2001 From: WinterYukky <49480575+WinterYukky@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:59:16 +0900 Subject: [PATCH 2/3] api: getMembersOfType classifies members from the checker's view rather than their first declaration Review of the first version found six gaps, all in how a member's metadata was derived from its first declaration: - Members without a declaration (tuple elements, a tuple's `length`) were dropped even with no filter, so the result diverged from getPropertiesOfType. They are kept, classified from their symbol flags, have no owner, and their declared type comes from getTypeOfSymbol, which needs no location; the declared type of every member is now obtained that way. - Visibility is read with GetDeclarationModifierFlagsFromSymbol, so a getter/setter pair is classified by the getter whatever the declaration order, and a synthetic union or intersection member by its constituents. ECMAScript private names still count as private. - isMethod is derived from the same classification as the `method` kind, so the synthetic member of `{ m(): number } | { m(): string }`, which carries no method flag, is a method and gets no declared type. - Auto-accessors (`accessor x = 0`) are accessors, like explicit getters and setters. - excludeNamePattern distinguishes absent from empty: a pattern that is passed is always compiled, and the empty pattern matches every member. - MemberFilterInput accepts an explicit `undefined` for every optional field, nested ones included, under exactOptionalPropertyTypes. Each gap has a test that failed before the change. Refs: https://github.com/microsoft/TypeScript/pull/65351 --- packages/typescript/src/api/async/api.ts | 25 ++-- .../typescript/src/api/proto.generated.ts | 12 +- packages/typescript/src/api/sync/api.ts | 25 ++-- packages/typescript/test/async/api.test.ts | 12 ++ packages/typescript/test/sync/api.test.ts | 12 ++ tsc/internal/api/proto.go | 12 +- tsc/internal/api/session.go | 70 ++++++----- .../api/session_members_differential_test.go | 4 +- tsc/internal/api/session_members_test.go | 112 +++++++++++++++++- 9 files changed, 221 insertions(+), 63 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index e9737f54338d1..0bf07ef188826 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -2317,19 +2317,23 @@ function encodeNumberLiteralValue(value: number): number | string { */ export interface MemberFilterInput { /** Visibilities to include; empty means all. ECMAScript private names count as private. */ - visibility?: readonly ("public" | "protected" | "private")[]; + visibility?: readonly ("public" | "protected" | "private")[] | undefined; /** Member kinds to include; empty means all. */ - kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[]; + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[] | undefined; /** Drop members whose JSDoc carries any of these tags. */ - excludeJsDocTags?: readonly string[]; - /** Drop members whose (wire-form) name matches this RE2 pattern. */ - excludeNamePattern?: string; + 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; declaringTypes?: readonly Symbol[]; }; - includeDeclaredType?: boolean; + includeInheritedFrom?: { all?: boolean | undefined; declaringTypes?: readonly Symbol[] | undefined; } | undefined; + includeDeclaredType?: boolean | undefined; } /** The documentation of one symbol, as returned by {@link Checker.getSymbolDocumentations}. */ @@ -2341,13 +2345,16 @@ export interface SymbolDocumentation { /** 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. */ + /** + * 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; - /** `getTypeOfSymbolAtLocation(symbol, firstDeclaration)`; only with `includeDeclaredType`. */ + /** `getTypeOfSymbol(symbol)` for non-method members; only with `includeDeclaredType`. */ declaredType: Type | undefined; } diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 3ba4b0c15979d..56d1d00bb03e8 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -961,7 +961,8 @@ export interface MemberResponse { symbol: SymbolResponse | null; /** * Owner is the symbol of the type declaring the member (see InheritedMemberFilter), - * when it could be determined. + * when it could be determined; members without a declaration (tuple elements, + * `length`, ...) have none. */ owner?: SymbolResponse | undefined; isMethod?: boolean | undefined; @@ -1825,7 +1826,9 @@ export interface MemberFilter { excludeJsDocTags?: string[] | undefined; /** * Drop members whose name (in its wire form, as the client sees it) matches this - * RE2 pattern, e.g. "^_" or "^(_|prototype$)". + * 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; /** @@ -1833,10 +1836,7 @@ export interface MemberFilter { * getPropertiesOfType semantics). */ includeInheritedFrom?: InheritedMemberFilter | undefined; - /** - * Include the declared type of each non-method member (getTypeOfSymbolAtLocation at - * its first declaration). - */ + /** Include the declared type of each non-method member (getTypeOfSymbol). */ includeDeclaredType?: boolean | undefined; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index e7c99ace9c0b7..b67aff413ddaa 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -4183,19 +4183,23 @@ function encodeNumberLiteralValue(value: number): number | string { */ export interface MemberFilterInput { /** Visibilities to include; empty means all. ECMAScript private names count as private. */ - visibility?: readonly ("public" | "protected" | "private")[]; + visibility?: readonly ("public" | "protected" | "private")[] | undefined; /** Member kinds to include; empty means all. */ - kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[]; + kinds?: readonly ("property" | "accessor" | "method" | "parameterProperty")[] | undefined; /** Drop members whose JSDoc carries any of these tags. */ - excludeJsDocTags?: readonly string[]; - /** Drop members whose (wire-form) name matches this RE2 pattern. */ - excludeNamePattern?: string; + 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; declaringTypes?: readonly Symbol[]; }; - includeDeclaredType?: boolean; + includeInheritedFrom?: { all?: boolean | undefined; declaringTypes?: readonly Symbol[] | undefined; } | undefined; + includeDeclaredType?: boolean | undefined; } /** The documentation of one symbol, as returned by {@link Checker.getSymbolDocumentations}. */ @@ -4207,13 +4211,16 @@ export interface SymbolDocumentation { /** 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. */ + /** + * 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; - /** `getTypeOfSymbolAtLocation(symbol, firstDeclaration)`; only with `includeDeclaredType`. */ + /** `getTypeOfSymbol(symbol)` for non-method members; only with `includeDeclaredType`. */ declaredType: Type | undefined; } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 2406b14f76452..0dd72ea41fc23 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -6703,6 +6703,18 @@ export class Derived extends Base { 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 () => { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 99303c21a1696..dd821f34eaf49 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -6503,6 +6503,18 @@ export class Derived extends Base { 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", () => { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 643b9d6265f3a..268459b8cd61a 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -1990,13 +1990,14 @@ type MemberFilter struct { // 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$)". - ExcludeNamePattern string `json:"excludeNamePattern,omitempty"` + // 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 (getTypeOfSymbolAtLocation at - // its first declaration). + // Include the declared type of each non-method member (getTypeOfSymbol). IncludeDeclaredType bool `json:"includeDeclaredType,omitempty"` } @@ -2023,7 +2024,8 @@ type GetMembersOfTypeParams struct { type MemberResponse struct { Symbol *SymbolResponse `json:"symbol"` // Owner is the symbol of the type declaring the member (see InheritedMemberFilter), - // when it could be determined. + // 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"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 32fe79cddfdc1..b3857b6021469 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -5253,8 +5253,8 @@ func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembers excludedTags[tag] = struct{}{} } var excludeName *regexp.Regexp - if filter.ExcludeNamePattern != "" { - excludeName, err = regexp.Compile(filter.ExcludeNamePattern) + if filter.ExcludeNamePattern != nil { + excludeName, err = regexp.Compile(*filter.ExcludeNamePattern) if err != nil { return nil, fmt.Errorf("%w: invalid excludeNamePattern: %w", ErrClientError, err) } @@ -5284,19 +5284,20 @@ func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembers if excludeName != nil && excludeName.MatchString(name) { continue } - decls := prop.Declarations() - if len(decls) == 0 { - 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] } - decl := decls[0] if len(visibilities) > 0 { - if _, ok := visibilities[memberVisibility(decl)]; !ok { + if _, ok := visibilities[memberVisibility(prop)]; !ok { continue } } - isParameterProperty := decl.Kind == ast.KindParameter + kind := memberKind(decl, prop) if len(kinds) > 0 { - if _, ok := kinds[memberKind(decl, prop)]; !ok { + if _, ok := kinds[kind]; !ok { continue } } @@ -5316,17 +5317,21 @@ func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembers continue } } - ownerNode := decl.Parent - if isParameterProperty && ownerNode != nil { - ownerNode = ownerNode.Parent - } var ownerSymbol *ast.Symbol - if ownerNode != nil { - if ownerType := setup.checker.GetTypeAtLocation(ownerNode); ownerType != nil { - ownerSymbol = ownerType.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() + } } } - if declaringTypes != nil { + // 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 } @@ -5336,8 +5341,8 @@ func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembers } member := &MemberResponse{ Symbol: setup.newSymbolResponse(prop), - IsMethod: prop.Flags()&ast.SymbolFlagsMethod != 0, - IsParameterProperty: isParameterProperty, + IsMethod: kind == "method", + IsParameterProperty: kind == "parameterProperty", Tags: docs.Tags, Comment: docs.Comment, } @@ -5345,33 +5350,40 @@ func (s *Session) handleGetMembersOfType(ctx context.Context, params *GetMembers member.Owner = setup.newSymbolResponse(ownerSymbol) } if filter.IncludeDeclaredType && !member.IsMethod { - member.DeclaredType = setup.sd.newTypeResponse(setup.projectID, setup.checker.GetTypeOfSymbolAtLocation(prop, decl), setup.checker) + member.DeclaredType = setup.sd.newTypeResponse(setup.projectID, setup.checker.GetTypeOfSymbol(prop), setup.checker) } results = append(results, member) } return results, nil } -// memberVisibility classifies a member declaration as "public", "protected" or -// "private" (ECMAScript private names count as private). -func memberVisibility(decl *ast.Node) string { +// 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 ast.HasSyntacticModifier(decl, ast.ModifierFlagsPrivate), ast.IsPrivateIdentifierClassElementDeclaration(decl): + case flags&ast.ModifierFlagsPrivate != 0, core.Some(symbol.Declarations(), ast.IsPrivateIdentifierClassElementDeclaration): return "private" - case ast.HasSyntacticModifier(decl, ast.ModifierFlagsProtected): + case flags&ast.ModifierFlagsProtected != 0: return "protected" } return "public" } -// memberKind classifies a member declaration for MemberFilter.Kinds. +// 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.Kind == ast.KindParameter: + case decl != nil && decl.Kind == ast.KindParameter: return "parameterProperty" - case ast.IsAccessor(decl): + case decl != nil && (ast.IsAccessor(decl) || ast.IsAutoAccessorPropertyDeclaration(decl)), symbol.Flags()&ast.SymbolFlagsAccessor != 0: return "accessor" - case ast.IsMethodDeclaration(decl), ast.IsMethodSignatureDeclaration(decl), symbol.Flags()&ast.SymbolFlagsMethod != 0: + case decl != nil && (ast.IsMethodDeclaration(decl) || ast.IsMethodSignatureDeclaration(decl)), symbol.Flags()&ast.SymbolFlagsMethod != 0: return "method" } return "property" diff --git a/tsc/internal/api/session_members_differential_test.go b/tsc/internal/api/session_members_differential_test.go index 6ffff174c3755..842357d2a8756 100644 --- a/tsc/internal/api/session_members_differential_test.go +++ b/tsc/internal/api/session_members_differential_test.go @@ -243,7 +243,7 @@ func TestGetMembersOfTypeMatchesTheFineGrainedPath(t *testing.T) { 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: "^__#"}) + got := ctx.membersOf(t, instanceType, MemberFilter{ExcludeNamePattern: ptr("^__#")}) 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, @@ -253,7 +253,7 @@ func TestGetMembersOfTypeMatchesTheFineGrainedPath(t *testing.T) { policy := MemberFilter{ Visibility: []string{"public", "protected"}, ExcludeJsDocTags: []string{"internal", "deprecated"}, - ExcludeNamePattern: "^_", + ExcludeNamePattern: ptr("^_"), IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, IncludeDeclaredType: true, } diff --git a/tsc/internal/api/session_members_test.go b/tsc/internal/api/session_members_test.go index 020b63803ff29..f6f0ef3f7ed3d 100644 --- a/tsc/internal/api/session_members_test.go +++ b/tsc/internal/api/session_members_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -133,7 +134,7 @@ func TestGetMembersOfTypeCombinesPredicatesLikeATraversal(t *testing.T) { Filter: MemberFilter{ Visibility: []string{"public", "protected"}, ExcludeJsDocTags: []string{"internal", "deprecated"}, - ExcludeNamePattern: "^_", + ExcludeNamePattern: ptr("^_"), IncludeInheritedFrom: &InheritedMemberFilter{}, IncludeDeclaredType: true, }, @@ -193,7 +194,7 @@ func TestGetMembersOfTypeInheritedFromAndKinds(t *testing.T) { Type: instanceType.Id, Filter: MemberFilter{ Visibility: []string{"public", "protected"}, - ExcludeNamePattern: "^_", + ExcludeNamePattern: ptr("^_"), IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, }, }) @@ -261,7 +262,7 @@ func TestGetMembersOfTypeInheritedFromAndKinds(t *testing.T) { Snapshot: ctx.snapshot, Project: ctx.project, Type: instanceType.Id, - Filter: MemberFilter{ExcludeNamePattern: "("}, + Filter: MemberFilter{ExcludeNamePattern: ptr("(")}, }) assert.ErrorContains(t, err, "invalid excludeNamePattern") } @@ -304,3 +305,108 @@ func TestGetSymbolDocumentationsMatchesIndividualCalls(t *testing.T) { } } } + +func ptr[T any](v T) *T { return &v } + +// 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: ptr("")})), 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 +} From 9a305a630bae700c5ece97b454e5df0e01b4cf31 Mon Sep 17 00:00:00 2001 From: WinterYukky <49480575+WinterYukky@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:30:14 +0900 Subject: [PATCH 3/3] api: use new(expr) for the optional name pattern in the member tests (modernize linter) --- tsc/internal/api/session_members_differential_test.go | 4 ++-- tsc/internal/api/session_members_test.go | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tsc/internal/api/session_members_differential_test.go b/tsc/internal/api/session_members_differential_test.go index 842357d2a8756..8800a924bf745 100644 --- a/tsc/internal/api/session_members_differential_test.go +++ b/tsc/internal/api/session_members_differential_test.go @@ -243,7 +243,7 @@ func TestGetMembersOfTypeMatchesTheFineGrainedPath(t *testing.T) { 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: ptr("^__#")}) + 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, @@ -253,7 +253,7 @@ func TestGetMembersOfTypeMatchesTheFineGrainedPath(t *testing.T) { policy := MemberFilter{ Visibility: []string{"public", "protected"}, ExcludeJsDocTags: []string{"internal", "deprecated"}, - ExcludeNamePattern: ptr("^_"), + ExcludeNamePattern: new("^_"), IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, IncludeDeclaredType: true, } diff --git a/tsc/internal/api/session_members_test.go b/tsc/internal/api/session_members_test.go index f6f0ef3f7ed3d..a89628f751247 100644 --- a/tsc/internal/api/session_members_test.go +++ b/tsc/internal/api/session_members_test.go @@ -134,7 +134,7 @@ func TestGetMembersOfTypeCombinesPredicatesLikeATraversal(t *testing.T) { Filter: MemberFilter{ Visibility: []string{"public", "protected"}, ExcludeJsDocTags: []string{"internal", "deprecated"}, - ExcludeNamePattern: ptr("^_"), + ExcludeNamePattern: new("^_"), IncludeInheritedFrom: &InheritedMemberFilter{}, IncludeDeclaredType: true, }, @@ -194,7 +194,7 @@ func TestGetMembersOfTypeInheritedFromAndKinds(t *testing.T) { Type: instanceType.Id, Filter: MemberFilter{ Visibility: []string{"public", "protected"}, - ExcludeNamePattern: ptr("^_"), + ExcludeNamePattern: new("^_"), IncludeInheritedFrom: &InheritedMemberFilter{DeclaringTypes: []SymbolReference{base.Reference}}, }, }) @@ -262,7 +262,7 @@ func TestGetMembersOfTypeInheritedFromAndKinds(t *testing.T) { Snapshot: ctx.snapshot, Project: ctx.project, Type: instanceType.Id, - Filter: MemberFilter{ExcludeNamePattern: ptr("(")}, + Filter: MemberFilter{ExcludeNamePattern: new("(")}, }) assert.ErrorContains(t, err, "invalid excludeNamePattern") } @@ -306,8 +306,6 @@ func TestGetSymbolDocumentationsMatchesIndividualCalls(t *testing.T) { } } -func ptr[T any](v T) *T { return &v } - // 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. @@ -362,7 +360,7 @@ func TestGetMembersOfTypeEmptyNamePatternIsNotAbsent(t *testing.T) { 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: ptr("")})), 0, "the empty pattern matches every name") + assert.Equal(t, len(ctx.membersOf(t, order, MemberFilter{ExcludeNamePattern: new("")})), 0, "the empty pattern matches every name") } func TestGetMembersOfTypeReadVisibilityOfAccessorPairs(t *testing.T) {