diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 796f65bb6a1a2..db1faa7364bef 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1805,6 +1805,23 @@ export class Checker { }); } + /** + * The following symbols are considered read-only: + * - Properties with a `readonly` modifier + * - Variables declared with `const` + * - Get accessors without matching set accessors + * - Enum members + * - `Object.defineProperty` assignments with `writable: false` or no setter + * - Unions and intersections of the above + */ + async isReadonlySymbol(symbol: Symbol): Promise { + return this.client.apiRequest("isReadonlySymbol", { + snapshot: this.snapshotId, + project: this.project.id, + symbol: symbol.id, + }); + } + /** Get the return type of a signature. Always returns a type. */ async getReturnTypeOfSignature(signature: Signature): Promise { return signature.getReturnType(); diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 4380907d7e8ec..50c9b4114ceac 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -115,6 +115,7 @@ export interface APIMethodInfo { getDocumentationComment: APIMethod; isArrayType: APIMethod; isTupleType: APIMethod; + isReadonlySymbol: APIMethod; getReferencesToSymbolInFile: APIMethod; getReferencedSymbolsForNode: APIMethod; getSignatureUsages: APIMethod; @@ -998,6 +999,7 @@ export interface BatchRequest { | "isArrayLikeType" | "isArrayType" | "isContextSensitive" + | "isReadonlySymbol" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" @@ -1143,6 +1145,7 @@ export interface BatchResponse { | "isArrayLikeType" | "isArrayType" | "isContextSensitive" + | "isReadonlySymbol" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index daaac356f8b5e..8440a36bbce43 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -1807,6 +1807,23 @@ export class Checker { }); } + /** + * The following symbols are considered read-only: + * - Properties with a `readonly` modifier + * - Variables declared with `const` + * - Get accessors without matching set accessors + * - Enum members + * - `Object.defineProperty` assignments with `writable: false` or no setter + * - Unions and intersections of the above + */ + isReadonlySymbol(symbol: Symbol): boolean { + return this.client.apiRequest("isReadonlySymbol", { + snapshot: this.snapshotId, + project: this.project.id, + symbol: symbol.id, + }); + } + /** Get the return type of a signature. Always returns a type. */ getReturnTypeOfSignature(signature: Signature): Type { return signature.getReturnType(); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index ee69511ca8d5c..68284afac1e20 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -11,6 +11,7 @@ import { isFunctionDeclaration, isIdentifier, isImportDeclaration, + isInterfaceDeclaration, isJSDocParameterTag, isNamedImports, isReturnStatement, @@ -3400,6 +3401,109 @@ describe("Checker - isArrayType / isTupleType", () => { }); }); +describe("Checker - isReadonlySymbol", () => { + test("properties with a 'readonly' modifier", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +export interface User { + readonly name: string; + age: number; +} + +export type ReadonlyUser = Readonly; +`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const user = sourceFile.statements.find(isInterfaceDeclaration); + assert.ok(user); + const userProperties = await project.checker.getPropertiesOfType( + await project.checker.getTypeAtLocation(user), + ); + assert.equal(await project.checker.isReadonlySymbol(userProperties[0]), true); + assert.equal(await project.checker.isReadonlySymbol(userProperties[1]), false); + const readonlyUser = sourceFile.statements.find(isTypeAliasDeclaration); + assert.ok(readonlyUser); + const readonlyUserProperties = await project.checker.getPropertiesOfType( + await project.checker.getTypeAtLocation(readonlyUser), + ); + assert.equal(await project.checker.isReadonlySymbol(readonlyUserProperties[0]), true); + assert.equal(await project.checker.isReadonlySymbol(readonlyUserProperties[1]), true); + } + finally { + await api.close(); + } + }); + + test("variables declared with 'const'", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": `export const a = 1; export let b = 2;`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const { checker } = snapshot.getProject("/tsconfig.json")!; + const a = await checker.getSymbolAtPosition("/src/main.ts", "export const ".length); + const b = await checker.getSymbolAtPosition("/src/main.ts", "export const a = 1; export let ".length); + assert.ok(a); + assert.ok(b); + assert.equal(await checker.isReadonlySymbol(a), true); + assert.equal(await checker.isReadonlySymbol(b), false); + } + finally { + await api.close(); + } + }); + + test("get accessors without matching set accessors", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +class Alpha { + private _value!: number; + get value(): number { + return this._value; + } +} +class Bravo { + private _value!: number; + get value(): number { + return this._value; + } + set value(newValue: number) { + this._value = newValue; + } +} +export type A = InstanceType; +export type B = InstanceType; +`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const typeAliases = sourceFile.statements.filter(isTypeAliasDeclaration); + assert.equal(typeAliases.length, 2); + const aProperties = await project.checker.getPropertiesOfType( + await project.checker.getTypeAtLocation(typeAliases[0]), + ); + assert.equal(await project.checker.isReadonlySymbol(aProperties[1]), true); + const bProperties = await project.checker.getPropertiesOfType( + await project.checker.getTypeAtLocation(typeAliases[1]), + ); + assert.equal(await project.checker.isReadonlySymbol(bProperties[1]), false); + } + finally { + await api.close(); + } + }); +}); + describe("Checker - getReturnTypeOfSignature", () => { test("returns the return type of a function signature", async () => { const api = spawnAPI({ diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index db26edb092604..2c5490b0a5320 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -19,6 +19,7 @@ import { isFunctionDeclaration, isIdentifier, isImportDeclaration, + isInterfaceDeclaration, isJSDocParameterTag, isNamedImports, isReturnStatement, @@ -3316,6 +3317,109 @@ describe("Checker - isArrayType / isTupleType", () => { }); }); +describe("Checker - isReadonlySymbol", () => { + test("properties with a 'readonly' modifier", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +export interface User { + readonly name: string; + age: number; +} + +export type ReadonlyUser = Readonly; +`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const user = sourceFile.statements.find(isInterfaceDeclaration); + assert.ok(user); + const userProperties = project.checker.getPropertiesOfType( + project.checker.getTypeAtLocation(user), + ); + assert.equal(project.checker.isReadonlySymbol(userProperties[0]), true); + assert.equal(project.checker.isReadonlySymbol(userProperties[1]), false); + const readonlyUser = sourceFile.statements.find(isTypeAliasDeclaration); + assert.ok(readonlyUser); + const readonlyUserProperties = project.checker.getPropertiesOfType( + project.checker.getTypeAtLocation(readonlyUser), + ); + assert.equal(project.checker.isReadonlySymbol(readonlyUserProperties[0]), true); + assert.equal(project.checker.isReadonlySymbol(readonlyUserProperties[1]), true); + } + finally { + api.close(); + } + }); + + test("variables declared with 'const'", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": `export const a = 1; export let b = 2;`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const { checker } = snapshot.getProject("/tsconfig.json")!; + const a = checker.getSymbolAtPosition("/src/main.ts", "export const ".length); + const b = checker.getSymbolAtPosition("/src/main.ts", "export const a = 1; export let ".length); + assert.ok(a); + assert.ok(b); + assert.equal(checker.isReadonlySymbol(a), true); + assert.equal(checker.isReadonlySymbol(b), false); + } + finally { + api.close(); + } + }); + + test("get accessors without matching set accessors", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +class Alpha { + private _value!: number; + get value(): number { + return this._value; + } +} +class Bravo { + private _value!: number; + get value(): number { + return this._value; + } + set value(newValue: number) { + this._value = newValue; + } +} +export type A = InstanceType; +export type B = InstanceType; +`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const typeAliases = sourceFile.statements.filter(isTypeAliasDeclaration); + assert.equal(typeAliases.length, 2); + const aProperties = project.checker.getPropertiesOfType( + project.checker.getTypeAtLocation(typeAliases[0]), + ); + assert.equal(project.checker.isReadonlySymbol(aProperties[1]), true); + const bProperties = project.checker.getPropertiesOfType( + project.checker.getTypeAtLocation(typeAliases[1]), + ); + assert.equal(project.checker.isReadonlySymbol(bProperties[1]), false); + } + finally { + api.close(); + } + }); +}); + describe("Checker - getReturnTypeOfSignature", () => { test("returns the return type of a function signature", () => { const api = spawnAPI({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index e9705c85c1a9d..407c53ba83ad3 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -176,6 +176,7 @@ const ( MethodGetDocumentationComment Method = "getDocumentationComment" MethodIsArrayType Method = "isArrayType" MethodIsTupleType Method = "isTupleType" + MethodIsReadonlySymbol Method = "isReadonlySymbol" // Reference methods MethodGetReferencesToSymbolInFile Method = "getReferencesToSymbolInFile" @@ -514,6 +515,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetDocumentationComment: unmarshallerFor[CheckerSymbolParams], MethodIsArrayType: unmarshallerFor[CheckerTypeParams], MethodIsTupleType: unmarshallerFor[CheckerTypeParams], + MethodIsReadonlySymbol: unmarshallerFor[CheckerSymbolParams], MethodGetReferencesToSymbolInFile: unmarshallerFor[GetReferencesToSymbolInFileParams], MethodGetReferencedSymbolsForNode: unmarshallerFor[GetReferencedSymbolsForNodeParams], MethodGetSignatureUsages: unmarshallerFor[GetSignatureUsagesParams], diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index e09bc8e5ad867..cd63157a13580 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -822,6 +822,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleIsArrayType(ctx, parsed.(*CheckerTypeParams)) case string(MethodIsTupleType): return s.handleIsTupleType(ctx, parsed.(*CheckerTypeParams)) + case string(MethodIsReadonlySymbol): + return s.handleIsReadonlySymbol(ctx, parsed.(*CheckerSymbolParams)) case string(MethodGetAnyType): return s.handleGetIntrinsicType(ctx, parsed.(*GetIntrinsicTypeParams), (*checker.Checker).GetAnyType) case string(MethodGetStringType): @@ -3001,6 +3003,22 @@ func (s *Session) handleIsTupleType(ctx context.Context, params *CheckerTypePara return checker.IsTupleType(t), nil } +// handleIsReadonlySymbol returns whether a symbol is a readonly symbol. +func (s *Session) handleIsReadonlySymbol(ctx context.Context, params *CheckerSymbolParams) (bool, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return false, err + } + defer setup.done() + + symbol, err := setup.resolveSymbolHandle(params.Symbol) + if err != nil { + return false, err + } + + return setup.checker.IsReadonlySymbol(symbol), nil +} + // handleGetBaseTypes returns the base types of an interface/class type. // @gen-proto-nullable func (s *Session) handleGetBaseTypes(ctx context.Context, params *CheckerTypeParams) ([]*TypeResponse, error) { diff --git a/tsc/internal/checker/exports.go b/tsc/internal/checker/exports.go index 13d0ccc5ad4c8..f5c0747731b2a 100644 --- a/tsc/internal/checker/exports.go +++ b/tsc/internal/checker/exports.go @@ -225,6 +225,10 @@ func (c *Checker) IsArrayType(t *Type) bool { return c.isArrayType(t) } +func (c *Checker) IsReadonlySymbol(symbol *ast.Symbol) bool { + return c.isReadonlySymbol(symbol) +} + func (c *Checker) GetReturnTypeOfSignature(sig *Signature) *Type { return c.getReturnTypeOfSignature(sig) }