diff --git a/packages/typescript/scripts/generateSync.ts b/packages/typescript/scripts/generateSync.ts index a20dbdc531b4f..4273e8429a2fc 100755 --- a/packages/typescript/scripts/generateSync.ts +++ b/packages/typescript/scripts/generateSync.ts @@ -11,6 +11,7 @@ * // @sync-skip-block-start/end — omit all lines between (inclusive) * // @sync-only-start/end — uncomment lines between (strip "// " prefix) * // @sync: — replace this line with (preserving indent) + * // @generators: - replace this line only in generator output * * AST-based transforms (applied after directives using the TypeScript compiler API): * - Generate synchronous implementations from async methods @@ -60,6 +61,7 @@ function generateSyncFile( transform: SourceTransform, force: boolean, variant: SyncVariant = "sync", + preserveGeneratorOverrides = false, ): GeneratedFile | undefined { const generated = new GeneratedFile(destPath, [import.meta.filename, srcPath]); if (generated.isCurrent(force)) return; @@ -69,7 +71,7 @@ function generateSyncFile( const normalized = source.replace(/\r/g, ""); // Phase 1: Process sync directives (text-based, operates on comments/lines) - const afterDirectives = processDirectives(normalized.split("\n"), variant).join("\n"); + const afterDirectives = processDirectives(normalized.split("\n"), variant, preserveGeneratorOverrides).join("\n"); // Phase 2: AST-based async→sync transforms const fileName = destPath.split("/").pop()!; @@ -88,7 +90,7 @@ function generateSyncFile( // ── Directive processing ───────────────────────────────────────── -function processDirectives(lines: string[], variant: SyncVariant): string[] { +function processDirectives(lines: string[], variant: SyncVariant, preserveGeneratorOverrides = false): string[] { const output: string[] = []; let skipBlock = false; let syncOnlyBlock = false; @@ -163,7 +165,7 @@ function processDirectives(lines: string[], variant: SyncVariant): string[] { continue; } - const lineWithoutGeneratorsDirective = generatorsReplaceMatch + const lineWithoutGeneratorsDirective = generatorsReplaceMatch && !preserveGeneratorOverrides ? line.slice(0, generatorsReplaceMatch.index).trimEnd() : line; const syncReplaceMatch = lineWithoutGeneratorsDirective.match(/\/\/ @sync: (.+)$/); @@ -211,14 +213,35 @@ function transformAsyncSource(source: string, fileName: string, attachGenerators const edits: Edit[] = []; const asyncMethodNames = new Set(); const synchronousMethodNames = new Set(); + const batchedMethodNames = new Set(); + const asyncMethods: ts.MethodDeclaration[] = []; for (const classDeclaration of getNamedClasses(sourceFile).values()) { for (const group of getMethodGroups(classDeclaration).values()) { const method = group.implementation; - if (isGeneratorMethod(method)) asyncMethodNames.add((method.name as ts.Identifier).text); + if (isGeneratorMethod(method)) { + asyncMethodNames.add((method.name as ts.Identifier).text); + asyncMethods.push(method); + } else synchronousMethodNames.add((method.name as ts.Identifier).text); } } + let changed = true; + while (changed) { + changed = false; + for (const method of asyncMethods) { + const name = (method.name as ts.Identifier).text; + if (!batchedMethodNames.has(name) && containsBatchedCall(method.body!)) { + batchedMethodNames.add(name); + changed = true; + } + } + } + + function containsBatchedCall(node: ts.Node): boolean { + if (isPromiseAllCall(node) || ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && batchedMethodNames.has(node.expression.name.text)) return true; + return ts.forEachChild(node, containsBatchedCall) ?? false; + } visit(sourceFile); return applyEdits(source, edits); @@ -321,6 +344,9 @@ function transformAsyncSource(source: string, fileName: string, attachGenerators if (!node.type) return "void"; if (mode === "generator" && isPromiseType(node.type)) { const returnType = getUnwrappedPromiseText(node.type, source, sourceFile); + if (node.name && ts.isIdentifier(node.name) && batchedMethodNames.has(node.name.text)) { + return `APIRequestGenerator<${returnType}>`; + } return `Generator`; } return transformNodeText(node.type, mode, false); @@ -347,7 +373,10 @@ function transformAsyncSource(source: string, fileName: string, attachGenerators } visitNode(node); - return applyEdits(source.slice(start, node.end), localEdits); + const result = applyEdits(source.slice(start, node.end), localEdits); + return ts.isBlock(node) + ? processDirectives(result.split("\n"), mode === "generator" ? "generators" : "sync").join("\n") + : result; } function addGeneratorEdit(node: ts.Node, offset: number, targetEdits: Edit[]): boolean { @@ -387,6 +416,24 @@ function transformAsyncSource(source: string, fileName: string, attachGenerators } function getGeneratorAwaitText(node: ts.AwaitExpression): string { + if (isPromiseAllCall(node.expression)) { + const argument = node.expression.arguments[0]; + if ( + !ts.isCallExpression(argument) || + !ts.isPropertyAccessExpression(argument.expression) || + argument.expression.name.text !== "map" || + argument.arguments.length !== 1 || + !ts.isArrowFunction(argument.arguments[0]) || + !ts.isCallExpression(argument.arguments[0].body) || + !isGeneratorCall(argument.arguments[0].body) + ) { + throw new Error("Promise.all requires a map callback returning an API method call"); + } + const callback = argument.arguments[0]; + const receiver = getTextWithOwner(argument.expression.expression); + const parameters = callback.parameters.map(parameter => getTextWithOwner(parameter)).join(", "); + return `(yield* all(...${receiver}.map((${parameters}) => ${getGeneratorCallText(argument.arguments[0].body)})))`; + } if (isAPIRequestCall(node.expression)) { const receiver = node.expression.expression.expression; if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "client") return getRequestYieldText(node.expression); @@ -487,6 +534,11 @@ function transformAsyncSource(source: string, fileName: string, attachGenerators } function addSyncEdit(node: ts.Node, source: string, sourceFile: ts.SourceFile, edits: Edit[], offset: number = 0): boolean { + if (isPromiseAllCall(node)) { + const argument = node.arguments[0]; + edits.push({ start: node.getStart(sourceFile) - offset, end: argument.getStart(sourceFile) - offset, newText: "" }); + edits.push({ start: argument.end - offset, end: node.end - offset, newText: "" }); + } if ( (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.modifiers @@ -540,6 +592,15 @@ function addSyncEdit(node: ts.Node, source: string, sourceFile: ts.SourceFile, e return false; } +function isPromiseAllCall(node: ts.Node): node is ts.CallExpression { + return ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "Promise" && + node.expression.name.text === "all" && + node.arguments.length === 1; +} + function isPromiseTypeReference(node: ts.Node | undefined): node is ts.TypeReferenceNode { return !!node && ts.isTypeReferenceNode(node) && @@ -641,7 +702,7 @@ export function generateSync(force = false): void { ? (source, fileName) => [ 'import type { APIRequest as ProtocolRequest, APIResponse as ProtocolResponse } from "../proto.ts";', - 'import { apiRequest, cacheGeneratorMethod } from "./generatorSupport.ts";', + 'import { all, apiRequest, cacheGeneratorMethod, type APIRequestGenerator } from "./generatorSupport.ts";', "", transformAsyncSource(source, fileName, true), ].join("\n") @@ -652,6 +713,8 @@ export function generateSync(force = false): void { transformAsyncSource(source, fileName, true), ].join("\n"), force, + "sync", + relPath === "api.ts", )); } diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 48f4103e6d852..879ef2e5dd0ab 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -42,6 +42,7 @@ import { import { assertNever } from "../../internal/utils.ts"; import { encodeNode, + sourceFileResponseToUint8Array, uint8ArrayToBase64, } from "../node/encoder.ts"; import { @@ -95,6 +96,7 @@ import type { ModuleResolutionSpec, PackageId, ParsedCommandLine, + ProgramStatisticsResponse, ProjectId, ProjectReference, ProjectResponse, @@ -102,6 +104,7 @@ import type { RawCompilerOptions, ReadConfigFileResponse, ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + RelationCacheSizes, ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, @@ -242,6 +245,7 @@ export type { ProjectReference, RawCompilerOptions, ReadConfigFileResponse, + RelationCacheSizes, RequestTiming, ResolutionMode, ResolvedModule, @@ -344,7 +348,6 @@ export interface TranspileOutput { // @sync-only-start // export { all, defer, type APIRequestGenerator, type AnyAPIRequestGenerator, type AllAPIRequestGenerator, type DeferredAPIRequestGenerator, type ExecutedGeneratorsResults } from "./generatorSupport.ts"; // import {executeRequestGenerators, type ExecutedGeneratorsResults, type AnyAPIRequestGenerator} from "./generatorSupport.ts"; -// import { sourceFileResponseToUint8Array } from "../node/encoder.ts"; // @sync-only-end export class API implements FormatDiagnosticsHost { @@ -1585,11 +1588,13 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; + private readonly toPath: (fileName: string, basePath?: string) => PathKey; constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string, basePath?: string) => PathKey, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; this.handle = data.handle; this.api = api; + this.toPath = toPath; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -1603,6 +1608,64 @@ export class Project { this.languageService = new LanguageService(snapshotId, this, objectRegistry); } + async containsFile(file: DocumentIdentifier, requireOpen?: boolean): Promise { + return this.api.client.apiRequest("containsProjectFile", { + snapshot: this.program.snapshotId, + project: this.handle, + file, + requireOpen, + }); + } + + async getFileNames(excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): Promise { + const files = await this.program.getSourceFileNames(); + const result: RootedFilePath[] = []; + const metadata = excludeFilesFromExternalLibraries + ? await Promise.all(files.map(file => this.program.getSourceFileMetadata(file))) + : undefined; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (metadata) { + const fileMetadata = metadata[i]; + if (!fileMetadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (fileMetadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...await this.program.getConfigFileNames()); + } + return result; + } + + /** Returns existing root files, excluding missing root declarations. */ + async getRootFiles(): Promise { + return this.api.client.apiRequest("getProjectRootFiles", { + snapshot: this.program.snapshotId, + project: this.handle, + }); + } + + async hasRoots(): Promise { + return this.parsedCommandLine.fileNames.length > 0; + } + + async isRoot(file: DocumentIdentifier): Promise { + const path = this.toPath(resolveFileName(file), this.currentDirectory); + return (await this.getRootFiles()).some(root => this.toPath(root, this.currentDirectory) === path); + } + + async hasConfigFile(file: DocumentIdentifier): Promise { + const path = this.toPath(resolveFileName(file), this.currentDirectory); + return (await this.program.getConfigFileNames()).some(config => this.toPath(config, this.currentDirectory) === path); + } + + async getSourceFile(file: DocumentIdentifier): Promise { + return this.program.getSourceFile(file); + } + dispose(): void { this.checker.dispose(); } @@ -1735,6 +1798,12 @@ export class LanguageService { } } +export interface ResolvedProjectReference { + commandLine: ParsedCommandLine; + sourceFile: SourceFile; + references?: readonly (ResolvedProjectReference | undefined)[] | undefined; +} + export class Program implements FormatDiagnosticsHost { /** @internal */ readonly snapshotId: number; @@ -1745,6 +1814,7 @@ export class Program implements FormatDiagnost private readonly sourceFileMetadataCache = new Map>(); private ownedSnapshot: Snapshot | undefined; private disposePromise: Promise | undefined; + private resolvedProjectReferences: Promise | undefined; private get client(): Client { return this.project.api.client; @@ -1796,6 +1866,89 @@ export class Program implements FormatDiagnost return this.project.parsedCommandLine.options; } + async getRootFileNames(): Promise { + return this.project.parsedCommandLine.fileNames; + } + + async getProjectReferences(): Promise { + return this.project.parsedCommandLine.projectReferences; + } + + async getTypeChecker(): Promise { + await this.client.apiRequest("ensureTypeChecker", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + return this.project.checker; + } + + async getSourceFiles(): Promise { + const fileNames = await this.getSourceFileNames(); + const sourceFiles = await Promise.all(fileNames.map(fileName => this.getSourceFile(fileName))); + const files: SourceFile[] = []; + for (let i = 0; i < fileNames.length; i++) { + const file = sourceFiles[i]; + if (!file) { + throw new Error(`Program source file '${fileNames[i]}' could not be loaded`); + } + files.push(file); + } + return files; + } + + private async getStatistics(): Promise { + return this.client.apiRequest("getProgramStatistics", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + } + + async getIdentifierCount(): Promise { + return (await this.getStatistics()).identifierCount; + } + + async getInstantiationCount(): Promise { + return (await this.getStatistics()).instantiationCount; + } + + async getNodeCount(): Promise { + return (await this.getStatistics()).nodeCount; + } + + async getSymbolCount(): Promise { + return (await this.getStatistics()).symbolCount; + } + + async getTypeCount(): Promise { + return (await this.getStatistics()).typeCount; + } + + async getRelationCacheSizes(): Promise { + return (await this.getStatistics()).relationCacheSizes; + } + + getResolvedProjectReferences(): Promise { + // @generators: if (owner.resolvedProjectReferences !== undefined) return owner.resolvedProjectReferences; + // @generators: const references = yield* owner.fetchResolvedProjectReferences.gen(); + return this.resolvedProjectReferences ??= this.fetchResolvedProjectReferences(); // @generators: return owner.resolvedProjectReferences ??= references; + } + + private async fetchResolvedProjectReferences(): Promise { + const data = await this.client.apiRequest("getResolvedProjectReferences", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, this.decoder, undefined, this.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + } + async getSourceFile(file: DocumentIdentifier): Promise { const fileName = resolveFileName(file); const path = this.pathKeyForFileName(fileName); @@ -2095,6 +2248,14 @@ export class Program implements FormatDiagnost return data ?? []; } + async getOptionsDiagnostics(): Promise { + const data = await this.client.apiRequest("getOptionsDiagnostics", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + return data ?? []; + } + /** * Get global (non-file-specific) semantic diagnostics for the project. */ diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 51b1123b15634..1b47d7ecf1e93 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -80,6 +80,12 @@ export interface APIMethodInfo { getNonMissingTypeOfSymbol: APIMethod; getSourceFile: APIMethod; getSourceFileNames: APIMethod; + containsProjectFile: APIMethod; + getProjectRootFiles: APIMethod; + ensureTypeChecker: APIMethod; + getProgramStatistics: APIMethod; + getResolvedProjectReferences: APIMethod; + getOptionsDiagnostics: APIMethod; getSourceFileMetadata: APIMethod; getModeForUsageLocation: APIMethod; getModeForResolutionAtIndex: APIMethod; @@ -591,6 +597,66 @@ export interface GetSourceFileNamesParams { project: number; } +export interface ContainsProjectFileParams { + snapshot: number; + project: number; + file: DocumentIdentifier; + requireOpen?: boolean | undefined; +} + +/** GetProjectDiagnosticsParams are parameters for project-wide diagnostic methods. */ +export interface GetProjectDiagnosticsParams { + snapshot: number; + project: number; +} + +export interface ProgramStatisticsResponse { + nodeCount: number; + identifierCount: number; + symbolCount: number; + typeCount: number; + instantiationCount: number; + relationCacheSizes: RelationCacheSizes; +} + +/** References use one-based indices into Projects; zero denotes an unresolved reference. */ +export interface ResolvedProjectReferencesResponse { + references?: number[] | undefined; + projects: ResolvedProjectReferenceResponse[]; +} + +/** DiagnosticResponse is the API response for a single diagnostic. */ +export interface DiagnosticResponse { + /** The file name of the file this diagnostic belongs to, if any. */ + fileName?: RootedFilePath | undefined; + /** Pos is the start position of the diagnostic in the source file. */ + pos: number; + /** End is the end position of the diagnostic in the source file. */ + end: number; + /** StartPosition is the zero-based line and UTF-16 character position of Pos. */ + startPosition?: DiagnosticPositionResponse | undefined; + /** EndPosition is the zero-based line and UTF-16 character position of End. */ + endPosition?: DiagnosticPositionResponse | undefined; + /** SourceLines contains the source lines needed to render this diagnostic with context. */ + sourceLines?: DiagnosticSourceLineResponse[] | undefined; + /** Code is the diagnostic error code. */ + code: number; + /** Category is the diagnostic category (error, warning, suggestion, message). */ + category: number; + /** Source is a custom diagnostic-code prefix. An empty value uses the default "TS". */ + source?: string | undefined; + /** Text is the localized diagnostic message text. */ + text: string; + /** ReportsUnnecessary indicates this diagnostic highlights unnecessary code. */ + reportsUnnecessary?: boolean | undefined; + /** ReportsDeprecated indicates this diagnostic highlights deprecated code. */ + reportsDeprecated?: boolean | undefined; + /** MessageChain contains chained diagnostic messages, if any. */ + messageChain?: DiagnosticResponse[] | undefined; + /** RelatedInformation contains related diagnostic information, if any. */ + relatedInformation?: DiagnosticResponse[] | undefined; +} + /** SourceFileMetadata carries program-stored metadata about a single source file. */ export interface SourceFileMetadata { isDefaultLibrary: boolean; @@ -664,12 +730,6 @@ export interface GetResolvedTypeReferenceDirectiveFromReferenceParams { resolutionMode: ModuleKind; } -/** GetProjectDiagnosticsParams are parameters for project-wide diagnostic methods. */ -export interface GetProjectDiagnosticsParams { - snapshot: number; - project: number; -} - export interface ResolveNameParams { snapshot: number; project: number; @@ -1021,38 +1081,6 @@ export interface GetDiagnosticsParams { files?: readonly DocumentIdentifier[] | undefined; } -/** DiagnosticResponse is the API response for a single diagnostic. */ -export interface DiagnosticResponse { - /** The file name of the file this diagnostic belongs to, if any. */ - fileName?: RootedFilePath | undefined; - /** Pos is the start position of the diagnostic in the source file. */ - pos: number; - /** End is the end position of the diagnostic in the source file. */ - end: number; - /** StartPosition is the zero-based line and UTF-16 character position of Pos. */ - startPosition?: DiagnosticPositionResponse | undefined; - /** EndPosition is the zero-based line and UTF-16 character position of End. */ - endPosition?: DiagnosticPositionResponse | undefined; - /** SourceLines contains the source lines needed to render this diagnostic with context. */ - sourceLines?: DiagnosticSourceLineResponse[] | undefined; - /** Code is the diagnostic error code. */ - code: number; - /** Category is the diagnostic category (error, warning, suggestion, message). */ - category: number; - /** Source is a custom diagnostic-code prefix. An empty value uses the default "TS". */ - source?: string | undefined; - /** Text is the localized diagnostic message text. */ - text: string; - /** ReportsUnnecessary indicates this diagnostic highlights unnecessary code. */ - reportsUnnecessary?: boolean | undefined; - /** ReportsDeprecated indicates this diagnostic highlights deprecated code. */ - reportsDeprecated?: boolean | undefined; - /** MessageChain contains chained diagnostic messages, if any. */ - messageChain?: DiagnosticResponse[] | undefined; - /** RelatedInformation contains related diagnostic information, if any. */ - relatedInformation?: DiagnosticResponse[] | undefined; -} - /** PrintNodeParams are the parameters for the printNode method. */ export interface PrintNodeParams { /** base64-encoded binary AST data */ @@ -1157,6 +1185,7 @@ export interface BatchRequest { | "buildReferences" | "cleanBuild" | "cleanReferences" + | "containsProjectFile" | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" @@ -1165,6 +1194,7 @@ export interface BatchRequest { | "disposeBuildOrchestrator" | "emit" | "emitToString" + | "ensureTypeChecker" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" @@ -1231,12 +1261,15 @@ export interface BatchRequest { | "getNullType" | "getNumberType" | "getObjectTypeOfType" + | "getOptionsDiagnostics" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getParentOfSymbolForChecker" | "getProgramDiagnostics" + | "getProgramStatistics" + | "getProjectRootFiles" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" @@ -1245,6 +1278,7 @@ export interface BatchRequest { | "getRegularTypeOfType" | "getResolvedModule" | "getResolvedModuleFromModuleSpecifier" + | "getResolvedProjectReferences" | "getResolvedSignature" | "getResolvedTypeReferenceDirective" | "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective" @@ -1339,6 +1373,7 @@ export interface BatchResponse { | "buildReferences" | "cleanBuild" | "cleanReferences" + | "containsProjectFile" | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" @@ -1347,6 +1382,7 @@ export interface BatchResponse { | "disposeBuildOrchestrator" | "emit" | "emitToString" + | "ensureTypeChecker" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" @@ -1413,12 +1449,15 @@ export interface BatchResponse { | "getNullType" | "getNumberType" | "getObjectTypeOfType" + | "getOptionsDiagnostics" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getParentOfSymbolForChecker" | "getProgramDiagnostics" + | "getProgramStatistics" + | "getProjectRootFiles" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" @@ -1427,6 +1466,7 @@ export interface BatchResponse { | "getRegularTypeOfType" | "getResolvedModule" | "getResolvedModuleFromModuleSpecifier" + | "getResolvedProjectReferences" | "getResolvedSignature" | "getResolvedTypeReferenceDirective" | "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective" @@ -1669,6 +1709,29 @@ export interface TranspileOptions { reportDiagnostics?: boolean | undefined; } +export interface RelationCacheSizes { + assignable: number; + identity: number; + subtype: number; + strictSubtype: number; +} + +export interface ResolvedProjectReferenceResponse { + commandLine: ConfigFileResponse; + sourceFile: SourceFileResponse; + references?: number[] | undefined; +} + +export interface DiagnosticPositionResponse { + line: number; + character: number; +} + +export interface DiagnosticSourceLineResponse { + line: number; + text: string; +} + export interface PackageId { name: string; subModuleName: string; @@ -1694,16 +1757,6 @@ export interface CompletionEntryResponse { symbol?: SymbolResponse | undefined; } -export interface DiagnosticPositionResponse { - line: number; - character: number; -} - -export interface DiagnosticSourceLineResponse { - line: number; - text: string; -} - export interface EmitOutputFile { fileName: RootedFilePath; text: string; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 7045ac37cdd7a..ed23173a75653 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -11,7 +11,9 @@ import type { APIResponse as ProtocolResponse, } from "../proto.ts"; import { + all, apiRequest, + type APIRequestGenerator, cacheGeneratorMethod, } from "./generatorSupport.ts"; @@ -59,6 +61,7 @@ import { import { assertNever } from "../../internal/utils.ts"; import { encodeNode, + sourceFileResponseToUint8Array, uint8ArrayToBase64, } from "../node/encoder.ts"; import { @@ -112,6 +115,7 @@ import type { ModuleResolutionSpec, PackageId, ParsedCommandLine, + ProgramStatisticsResponse, ProjectId, ProjectReference, ProjectResponse, @@ -119,6 +123,7 @@ import type { RawCompilerOptions, ReadConfigFileResponse, ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + RelationCacheSizes, ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, @@ -258,6 +263,7 @@ export type { ProjectReference, RawCompilerOptions, ReadConfigFileResponse, + RelationCacheSizes, RequestTiming, ResolutionMode, ResolvedModule, @@ -358,7 +364,6 @@ export interface TranspileOutput { } export { all, type AllAPIRequestGenerator, type AnyAPIRequestGenerator, type APIRequestGenerator, defer, type DeferredAPIRequestGenerator, type ExecutedGeneratorsResults } from "./generatorSupport.ts"; -import { sourceFileResponseToUint8Array } from "../node/encoder.ts"; import { type AnyAPIRequestGenerator, type ExecutedGeneratorsResults, @@ -2606,11 +2611,13 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; + private readonly toPath: (fileName: string, basePath?: string) => PathKey; constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string, basePath?: string) => PathKey, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; this.handle = data.handle; this.api = api; + this.toPath = toPath; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -2624,6 +2631,184 @@ export class Project { this.languageService = new LanguageService(snapshotId, this, objectRegistry); } + get containsFile(): { + (file: DocumentIdentifier, requireOpen?: boolean): boolean; + gen(file: DocumentIdentifier, requireOpen?: boolean): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "containsFile", + function (file: DocumentIdentifier, requireOpen?: boolean): boolean { + return owner.api.client.apiRequest("containsProjectFile", { + snapshot: owner.program.snapshotId, + project: owner.handle, + file, + requireOpen, + }); + }, + function* (file: DocumentIdentifier, requireOpen?: boolean): Generator { + return yield* apiRequest("containsProjectFile", { + snapshot: owner.program.snapshotId, + project: owner.handle, + file, + requireOpen, + }); + }, + ); + } + + get getFileNames(): { + (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): readonly RootedFilePath[]; + gen(excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): APIRequestGenerator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getFileNames", + function (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): readonly RootedFilePath[] { + const files = owner.program.getSourceFileNames(); + const result: RootedFilePath[] = []; + const metadata = excludeFilesFromExternalLibraries + ? files.map(file => owner.program.getSourceFileMetadata(file)) + : undefined; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (metadata) { + const fileMetadata = metadata[i]; + if (!fileMetadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (fileMetadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...owner.program.getConfigFileNames()); + } + return result; + }, + function* (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): APIRequestGenerator { + const files = yield* owner.program.getSourceFileNames.gen(); + const result: RootedFilePath[] = []; + const metadata = excludeFilesFromExternalLibraries + ? (yield* all(...files.map(file => owner.program.getSourceFileMetadata.gen(file)))) + : undefined; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (metadata) { + const fileMetadata = metadata[i]; + if (!fileMetadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (fileMetadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...(yield* owner.program.getConfigFileNames.gen())); + } + return result; + }, + ); + } + + /** Returns existing root files, excluding missing root declarations. */ + get getRootFiles(): { + (): readonly RootedFilePath[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRootFiles", + function (): readonly RootedFilePath[] { + return owner.api.client.apiRequest("getProjectRootFiles", { + snapshot: owner.program.snapshotId, + project: owner.handle, + }); + }, + function* (): Generator { + return yield* apiRequest("getProjectRootFiles", { + snapshot: owner.program.snapshotId, + project: owner.handle, + }); + }, + ); + } + + get hasRoots(): { + (): boolean; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "hasRoots", + function (): boolean { + return owner.parsedCommandLine.fileNames.length > 0; + }, + function* (): Generator { + return owner.parsedCommandLine.fileNames.length > 0; + }, + ); + } + + get isRoot(): { + (file: DocumentIdentifier): boolean; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "isRoot", + function (file: DocumentIdentifier): boolean { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (owner.getRootFiles()).some(root => owner.toPath(root, owner.currentDirectory) === path); + }, + function* (file: DocumentIdentifier): Generator { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (yield* owner.getRootFiles.gen()).some(root => owner.toPath(root, owner.currentDirectory) === path); + }, + ); + } + + get hasConfigFile(): { + (file: DocumentIdentifier): boolean; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "hasConfigFile", + function (file: DocumentIdentifier): boolean { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (owner.program.getConfigFileNames()).some(config => owner.toPath(config, owner.currentDirectory) === path); + }, + function* (file: DocumentIdentifier): Generator { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (yield* owner.program.getConfigFileNames.gen()).some(config => owner.toPath(config, owner.currentDirectory) === path); + }, + ); + } + + get getSourceFile(): { + (file: DocumentIdentifier): SourceFile | undefined; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSourceFile", + function (file: DocumentIdentifier): SourceFile | undefined { + return owner.program.getSourceFile(file); + }, + function* (file: DocumentIdentifier): Generator { + return yield* owner.program.getSourceFile.gen(file); + }, + ); + } + dispose(): void { this.checker.dispose(); } @@ -2912,6 +3097,12 @@ export class LanguageService { } } +export interface ResolvedProjectReference { + commandLine: ParsedCommandLine; + sourceFile: SourceFile; + references?: readonly (ResolvedProjectReference | undefined)[] | undefined; +} + export class Program implements FormatDiagnosticsHost { /** @internal */ readonly snapshotId: number; @@ -2922,6 +3113,7 @@ export class Program implements FormatDiagnost private readonly sourceFileMetadataCache = new Map(); private ownedSnapshot: Snapshot | undefined; private disposePromise: void | undefined; + private resolvedProjectReferences: readonly (ResolvedProjectReference | undefined)[] | undefined | undefined; private get client(): Client { return this.project.api.client; @@ -3001,6 +3193,287 @@ export class Program implements FormatDiagnost return this.project.parsedCommandLine.options; } + get getRootFileNames(): { + (): readonly RootedFilePath[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRootFileNames", + function (): readonly RootedFilePath[] { + return owner.project.parsedCommandLine.fileNames; + }, + function* (): Generator { + return owner.project.parsedCommandLine.fileNames; + }, + ); + } + + get getProjectReferences(): { + (): readonly ProjectReference[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getProjectReferences", + function (): readonly ProjectReference[] | undefined { + return owner.project.parsedCommandLine.projectReferences; + }, + function* (): Generator { + return owner.project.parsedCommandLine.projectReferences; + }, + ); + } + + get getTypeChecker(): { + (): Checker; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getTypeChecker", + function (): Checker { + owner.client.apiRequest("ensureTypeChecker", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return owner.project.checker; + }, + function* (): Generator { + yield* apiRequest("ensureTypeChecker", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return owner.project.checker; + }, + ); + } + + get getSourceFiles(): { + (): readonly SourceFile[]; + gen(): APIRequestGenerator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSourceFiles", + function (): readonly SourceFile[] { + const fileNames = owner.getSourceFileNames(); + const sourceFiles = fileNames.map(fileName => owner.getSourceFile(fileName)); + const files: SourceFile[] = []; + for (let i = 0; i < fileNames.length; i++) { + const file = sourceFiles[i]; + if (!file) { + throw new Error(`Program source file '${fileNames[i]}' could not be loaded`); + } + files.push(file); + } + return files; + }, + function* (): APIRequestGenerator { + const fileNames = yield* owner.getSourceFileNames.gen(); + const sourceFiles = yield* all(...fileNames.map(fileName => owner.getSourceFile.gen(fileName))); + const files: SourceFile[] = []; + for (let i = 0; i < fileNames.length; i++) { + const file = sourceFiles[i]; + if (!file) { + throw new Error(`Program source file '${fileNames[i]}' could not be loaded`); + } + files.push(file); + } + return files; + }, + ); + } + + private get getStatistics(): { + (): ProgramStatisticsResponse; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getStatistics", + function (): ProgramStatisticsResponse { + return owner.client.apiRequest("getProgramStatistics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + }, + function* (): Generator { + return yield* apiRequest("getProgramStatistics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + }, + ); + } + + get getIdentifierCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getIdentifierCount", + function (): number { + return (owner.getStatistics()).identifierCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).identifierCount; + }, + ); + } + + get getInstantiationCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getInstantiationCount", + function (): number { + return (owner.getStatistics()).instantiationCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).instantiationCount; + }, + ); + } + + get getNodeCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getNodeCount", + function (): number { + return (owner.getStatistics()).nodeCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).nodeCount; + }, + ); + } + + get getSymbolCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSymbolCount", + function (): number { + return (owner.getStatistics()).symbolCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).symbolCount; + }, + ); + } + + get getTypeCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getTypeCount", + function (): number { + return (owner.getStatistics()).typeCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).typeCount; + }, + ); + } + + get getRelationCacheSizes(): { + (): RelationCacheSizes; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRelationCacheSizes", + function (): RelationCacheSizes { + return (owner.getStatistics()).relationCacheSizes; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).relationCacheSizes; + }, + ); + } + + get getResolvedProjectReferences(): { + (): readonly (ResolvedProjectReference | undefined)[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getResolvedProjectReferences", + function (): readonly (ResolvedProjectReference | undefined)[] | undefined { + return owner.resolvedProjectReferences ??= owner.fetchResolvedProjectReferences(); + }, + function* (): Generator { + if (owner.resolvedProjectReferences !== undefined) return owner.resolvedProjectReferences; + const references = yield* owner.fetchResolvedProjectReferences.gen(); + return owner.resolvedProjectReferences ??= references; + }, + ); + } + + private get fetchResolvedProjectReferences(): { + (): readonly (ResolvedProjectReference | undefined)[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "fetchResolvedProjectReferences", + function (): readonly (ResolvedProjectReference | undefined)[] | undefined { + const data = owner.client.apiRequest("getResolvedProjectReferences", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, owner.decoder, undefined, owner.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + }, + function* (): Generator { + const data = yield* apiRequest("getResolvedProjectReferences", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, owner.decoder, undefined, owner.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + }, + ); + } + get getSourceFile(): { (file: DocumentIdentifier): SourceFile | undefined; gen(file: DocumentIdentifier): Generator; @@ -3723,6 +4196,31 @@ export class Program implements FormatDiagnost ); } + get getOptionsDiagnostics(): { + (): readonly Diagnostic[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getOptionsDiagnostics", + function (): readonly Diagnostic[] { + const data = owner.client.apiRequest("getOptionsDiagnostics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return data ?? []; + }, + function* (): Generator { + const data = yield* apiRequest("getOptionsDiagnostics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return data ?? []; + }, + ); + } + /** * Get global (non-file-specific) semantic diagnostics for the project. */ diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 759295ad40d9d..0522f8ba7aa62 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -127,8 +127,19 @@ import { toRootedFilePath, } from "@typescript/typescript/path"; import assert from "node:assert"; -import { globSync } from "node:fs"; -import { resolve } from "node:path"; +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + join, + resolve, +} from "node:path"; import { describe, test, @@ -1556,6 +1567,44 @@ describe("API - automatic batching", { concurrency }, () => { assert.equal(parseCommandLine.options.strict, true); assert.deepStrictEqual(readConfigFile.config, {}); }); + + test("batches file-list lookups while preserving order and cached results", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" }, files: ["src/main.ts"] }), + "/src/main.ts": `import { value } from "my-lib"; import { local } from "./local"; export const result = value + local;`, + "/src/local.ts": "export const local = 1;", + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }, { collectTiming: true }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const program = project.program; + const expected = ["/src/local.ts", "/src/main.ts", "/tsconfig.json"]; + + await api.resetTimingInfo(); + assert.deepEqual(await project.getFileNames(true), expected); + assert.equal((await api.getTimingInfo()).totals.requestCount, 3); + + await api.resetTimingInfo(); + assert.deepEqual(await project.getFileNames(true), expected); + assert.equal((await api.getTimingInfo()).totals.requestCount, 2); + + const local = await program.getSourceFile("/src/local.ts"); + assert.ok(local); + await api.resetTimingInfo(); + const files = await program.getSourceFiles(); + assert.deepEqual(files.map(file => file.fileName), ["/node_modules/my-lib/index.d.ts", "/src/local.ts", "/src/main.ts"]); + assert.strictEqual(files[1], local); + // The WASM transport sends the two uncached binary source-file requests individually. + assert.equal((await api.getTimingInfo()).totals.requestCount, process.env.TS_API_TEST_WASM ? 3 : 2); + + await api.resetTimingInfo(); + const cached = await program.getSourceFiles(); + for (let i = 0; i < files.length; i++) { + assert.strictEqual(cached[i], files[i]); + } + assert.equal((await api.getTimingInfo()).totals.requestCount, 1); + }); }); describe("API - batchContext", { concurrency }, () => { @@ -2697,6 +2746,271 @@ describe("SourceFile", { concurrency }, () => { ); }); + test("Program accessors distinguish root files from source files and reuse the checker", async () => { + await using api = spawnAPI({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + }); + await using program = await api.createProgram(["/src/main.ts"], { noLib: true }); + assert.deepEqual(await program.getRootFileNames(), ["/src/main.ts"]); + const files = await program.getSourceFiles(); + assert.deepEqual(files.map(file => file.fileName), ["/src/dependency.ts", "/src/main.ts"]); + assert.strictEqual(files[1], await program.getSourceFile("/src/main.ts")); + const checker = await program.getTypeChecker(); + assert.strictEqual(await program.getTypeChecker(), checker); + assert.ok(await checker.getSymbolAtLocation(cast(files[1].statements[1], isVariableStatement).declarationList.declarations[0].name)); + assert.equal(await program.getProjectReferences(), undefined); + assert.equal(await program.getResolvedProjectReferences(), undefined); + }); + + test("Program counters reflect work on its type checker without forcing diagnostics", async () => { + await using api = spawnAPI({ + "/src/main.ts": `type Box = { value: T }; +export const box: Box = { value: "hello" }; +export const other: { value: string } = box;`, + }); + await using program = await api.createProgram(["/src/main.ts"], { noLib: true }); + const identifiers = await program.getIdentifierCount(); + const nodes = await program.getNodeCount(); + const symbols = await program.getSymbolCount(); + const types = await program.getTypeCount(); + const instantiations = await program.getInstantiationCount(); + const relations = await program.getRelationCacheSizes(); + assert.ok(identifiers > 0); + assert.ok(nodes > identifiers); + assert.ok(symbols > 0); + assert.ok(types > 0); + assert.equal(instantiations, 0); + assert.deepEqual(relations, { assignable: 0, identity: 0, subtype: 0, strictSubtype: 0 }); + + const file = (await program.getSourceFile("/src/main.ts"))!; + const checker = await program.getTypeChecker(); + const declaration = cast(file.statements[1], isVariableStatement).declarationList.declarations[0]; + const type = await checker.getTypeAtLocation(declaration.name); + await type.getProperties(); + assert.ok(await program.getInstantiationCount() > instantiations); + assert.ok(await program.getTypeCount() > types); + assert.ok(await program.getSymbolCount() >= symbols); + const other = cast(file.statements[2], isVariableStatement).declarationList.declarations[0]; + await checker.isTypeAssignableTo(type, await checker.getTypeAtLocation(other.name)); + assert.ok((await program.getRelationCacheSizes()).assignable > relations.assignable); + assert.equal(await program.getIdentifierCount(), identifiers); + assert.equal(await program.getNodeCount(), nodes); + }); + + test("Program accessors handle empty programs and missing root files", async () => { + await using api = spawnAPI({}); + await using empty = await api.createProgram([], { noLib: true }); + assert.deepEqual(await empty.getRootFileNames(), []); + assert.deepEqual(await empty.getSourceFiles(), []); + assert.equal(await empty.getNodeCount(), 0); + assert.equal(await empty.getIdentifierCount(), 0); + assert.equal(await empty.getProjectReferences(), undefined); + assert.equal(await empty.getResolvedProjectReferences(), undefined); + assert.deepEqual(await empty.getOptionsDiagnostics(), []); + + await using noReferences = await api.createProgram([], { noLib: true }, { projectReferences: [] }); + assert.deepEqual(await noReferences.getProjectReferences(), []); + assert.deepEqual(await noReferences.getResolvedProjectReferences(), []); + + await using missing = await api.createProgram(["/missing.ts"], { noLib: true }); + assert.deepEqual(await missing.getRootFileNames(), ["/missing.ts"]); + assert.deepEqual(await missing.getSourceFiles(), []); + assert.ok((await missing.getOptionsDiagnostics()).some(diagnostic => diagnostic.code === 6053)); + }); + + test("Program options diagnostics exclude source-file and config parsing diagnostics", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true, strictNullChecks: false, exactOptionalPropertyTypes: true, unknownOption: true }, + files: ["src/main.ts"], + }), + "/src/main.ts": `/// +const value: string = 1;`, + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const diagnostics = await program.getOptionsDiagnostics(); + assert.ok(diagnostics.some(diagnostic => diagnostic.code === 5052)); + assert.ok(diagnostics.every(diagnostic => !diagnostic.fileName || diagnostic.fileName === "/tsconfig.json")); + assert.ok(!(diagnostics.some(diagnostic => diagnostic.code === 5023 || diagnostic.code === 6053 || diagnostic.code === 2322))); + assert.ok((await program.getConfigFileParsingDiagnostics()).some(diagnostic => diagnostic.code === 5023)); + }); + + test("Program resolved project references preserve missing, nested, shared, and circular references", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./a" }, { path: "./missing" }, { path: "./b" }, { path: "./a" }] }), + "/a/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../b" }] }), + "/a/index.ts": "export const a = 1;", + "/b/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../a", circular: true }, { path: "../missing" }] }), + "/b/index.ts": "export const b = 1;", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const references = await program.getProjectReferences(); + assert.equal(references?.length, 4); + const resolved = await program.getResolvedProjectReferences(); + assert.equal(resolved?.length, 4); + assert.equal(resolved![1], undefined); + const a = resolved![0]!; + const b = resolved![2]!; + assert.strictEqual(resolved![3], a); + assert.strictEqual(a.references![0], b); + assert.strictEqual(b.references![0], a); + assert.equal(b.references![1], undefined); + assert.equal(a.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(a.commandLine.options.composite, true); + assert.deepEqual(a.commandLine.fileNames, ["/a/index.ts"]); + assert.strictEqual((await program.getResolvedProjectReferences())![0], a); + + await using synthetic = await api.createProgram([], { noLib: true }, { + projectReferences: [{ path: "/a/tsconfig.json" }, { path: "/missing/tsconfig.json" }], + }); + assert.equal((await synthetic.getProjectReferences())!.length, 2); + const syntheticResolved = await synthetic.getResolvedProjectReferences(); + assert.equal(syntheticResolved!.length, 2); + assert.equal(syntheticResolved![0]!.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(syntheticResolved![1], undefined); + }); + + test("Project membership distinguishes roots, dependencies, and open files", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts", "missing.ts"] }), + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + "/outside.ts": "export {};", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts", "/outside.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await project.hasRoots(), true); + assert.deepEqual(await project.getRootFiles(), ["/src/main.ts"]); + assert.equal(await project.isRoot("src/main.ts"), true); + assert.equal(await project.isRoot({ uri: "file:///src/main.ts" }), true); + assert.equal(await project.isRoot("/missing.ts"), false); + assert.equal(await project.isRoot("/src/dependency.ts"), false); + assert.equal(await project.containsFile("src/main.ts"), true); + assert.equal(await project.containsFile({ uri: "file:///src/main.ts" }, true), true); + assert.equal(await project.containsFile("/src/dependency.ts"), true); + assert.equal(await project.containsFile("/src/dependency.ts", true), false); + assert.equal(await project.containsFile("/outside.ts"), false); + assert.equal(await project.containsFile("/outside.ts", true), false); + assert.equal(await project.containsFile("/missing.ts"), false); + assert.strictEqual(await project.getSourceFile("src/main.ts"), await project.program.getSourceFile("/src/main.ts")); + assert.equal(await project.getSourceFile("/outside.ts"), undefined); + assert.equal(await project.hasConfigFile("tsconfig.json"), true); + assert.equal(await project.hasConfigFile("/src/main.ts"), false); + + const updated = await snapshot.update({ closeFiles: ["/src/main.ts"] }); + assert.equal(await updated.getConfiguredProject("/tsconfig.json")!.containsFile("/src/main.ts", true), false); + assert.equal(await project.containsFile("/src/main.ts", true), true); + }); + + test("Project membership includes supplemental files without physical files", { + skip: process.env.TS_API_TEST_WASM ? "WASM cannot spawn external content mapper processes" : false, + }, async t => { + const directory = mkdtempSync(join(tmpdir(), "ts-project-membership-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const mapperDirectory = join(directory, "node_modules", "mapper"); + mkdirSync(mapperDirectory, { recursive: true }); + const mapperScript = ` + import { createMessageConnection } from ${JSON.stringify(new URL("../../vendor/vscode-jsonrpc/lib/node/main.js", import.meta.url).href)}; + const connection = createMessageConnection(process.stdin, process.stdout); + connection.onRequest("initialize", () => ({ positionEncoding: "utf-8", diagnosticSource: "mapper" })); + connection.onRequest("openProject", () => ({})); + connection.onRequest("closeProject", () => null); + connection.onRequest("transform", ({ content }) => ({ + text: "export {};", + extension: ".ts", + supplemental: [{ text: content, extension: ".ts" }], + })); + connection.listen(); + `; + writeFileSync( + join(mapperDirectory, "package.json"), + JSON.stringify({ + name: "mapper", + version: "1.0.0", + typescript: { contentMapper: { exec: [process.execPath, "--input-type=module", "--eval", mapperScript] } }, + }), + ); + const configFileName = toRootedFilePath(join(directory, "tsconfig.json"), undefined); + writeFileSync( + configFileName, + JSON.stringify({ + compilerOptions: { noLib: true }, + files: ["app.box", "main.ts", "missing.ts"], + contentMappers: [{ package: "mapper", extensions: [".box"] }], + }), + ); + writeFileSync(join(directory, "app.box"), "declare const supplementalValue: number;"); + writeFileSync(join(directory, "main.ts"), "const value: number = supplementalValue;"); + await using api = new API({ cwd: directory, runExternalCode: true }); + const snapshot = await api.createSnapshot({ openProjects: [configFileName] }); + const project = snapshot.getConfiguredProject(configFileName)!; + const supplementalFileName = toRootedFilePath(join(directory, "app.box.0.ts"), undefined); + assert.equal(existsSync(supplementalFileName), false); + assert.ok(await project.getSourceFile(supplementalFileName)); + assert.equal(await project.containsFile(supplementalFileName), true); + assert.equal(await project.containsFile(supplementalFileName, true), false); + assert.equal(await project.containsFile(join(directory, "missing.ts")), false); + }); + + test("Project file lists include extended configs and optionally exclude external libraries", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), + "/base.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" } }), + "/src/main.ts": `import { value } from "my-lib"; export const result = value;`, + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.deepEqual(await project.getFileNames(), ["/node_modules/my-lib/index.d.ts", "/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(await project.getFileNames(true), ["/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(await project.getFileNames(false, true), ["/node_modules/my-lib/index.d.ts", "/src/main.ts"]); + assert.deepEqual(await project.getFileNames(true, true), ["/src/main.ts"]); + assert.equal(await project.hasConfigFile("./base.json"), true); + assert.equal(await project.hasConfigFile({ uri: "file:///base.json" }), true); + assert.equal(await project.hasConfigFile("/other.json"), false); + assert.equal(await project.containsFile("/tsconfig.json"), false); + }); + + test("Project roots preserve missing declarations and empty projects", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: [] }), + }); + const snapshot = await api.createSnapshot({ + openProjects: ["/tsconfig.json"], + createPrograms: [{ rootFiles: ["/missing.ts"], compilerOptions: { noLib: true } }], + }); + const empty = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await empty.hasRoots(), false); + assert.deepEqual(await empty.getRootFiles(), []); + assert.deepEqual(await empty.getFileNames(), ["/tsconfig.json"]); + const missing = snapshot.getProject(snapshot.operation.createdPrograms![0].id)!; + assert.equal(await missing.hasRoots(), true); + assert.deepEqual(await missing.getRootFiles(), []); + assert.equal(await missing.isRoot("/missing.ts"), false); + assert.equal(await missing.containsFile("/missing.ts"), false); + assert.deepEqual(await missing.getFileNames(), []); + assert.equal(await missing.hasConfigFile("/tsconfig.json"), false); + }); + + test("Project membership uses configured path case sensitivity", async () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": "export {};", + }; + for (const useCaseSensitiveFileNames of [true, false]) { + await using api = new API({ cwd: "/", fs: createVirtualFileSystem(files), useCaseSensitiveFileNames }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await project.isRoot("/SRC/MAIN.TS"), !useCaseSensitiveFileNames); + assert.equal(await project.hasConfigFile("/TSCONFIG.JSON"), !useCaseSensitiveFileNames); + assert.equal(await project.containsFile("/SRC/MAIN.TS", true), !useCaseSensitiveFileNames); + } + }); + test("getSourceFileNames returns all program files, not just root files", async () => { await using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index bae80333e1ba3..8a2474c2aeb04 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -142,7 +142,7 @@ export const missingValue = absent; }; type GeneratorMethod = ((...args: Args) => Result) & { - gen(...args: Args): Generator; + gen(...args: Args): APIRequestGenerator; }; type EquivalenceAssertion = (actual: Result, expected: Result, message?: string) => void; @@ -150,7 +150,7 @@ type EquivalenceAssertion = (actual: Result, expected: Result, message?: interface ParityCase { readonly ownerName: string; readonly methodName: string; - readonly runGenerator: () => Generator; + readonly runGenerator: () => APIRequestGenerator; readonly assertEquivalent: (message?: string) => void; } @@ -173,6 +173,8 @@ const privateGeneratorGetters = new Set([ "NodeHandle.fetchOwnerFile", "NodeHandle.resolveInFile", "Program.disposeWorker", + "Program.getStatistics", + "Program.fetchResolvedProjectReferences", "Program.fetchSourceFileMetadata", "Program.getSourceFileWorker", "Snapshot.disposeWorker", @@ -485,6 +487,68 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => ]); }); + test("concurrent resolved project reference generators reuse the first cached graph", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./a" }, { path: "./b" }, { path: "./a" }] }), + "/a/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../b" }] }), + "/a/index.ts": "export const a = 1;", + "/b/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../a", circular: true }] }), + "/b/index.ts": "export const b = 1;", + }); + using snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const [first, second] = api.batch(program.getResolvedProjectReferences.gen(), program.getResolvedProjectReferences.gen()); + assert.ok(first); + assert.equal(first.length, 3); + assert.ok(second === first, "Concurrent calls must return the same reference graph"); + assert.strictEqual(first[2], first[0]); + assert.strictEqual(first[0]!.references![0], first[1]); + assert.strictEqual(first[1]!.references![0], first[0]); + assert.strictEqual(api.batch(program.getResolvedProjectReferences.gen())[0], first); + assert.strictEqual(program.getResolvedProjectReferences(), first); + }); + + test("batches file-list lookups while preserving order and cached results", context => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" }, files: ["src/main.ts"] }), + "/src/main.ts": `import { value } from "my-lib"; import { local } from "./local"; export const result = value + local;`, + "/src/local.ts": "export const local = 1;", + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }); + using snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const program = project.program; + const requestBatches: string[][] = []; + observeRequestBatches(api, requestBatches, context); + + const [names] = api.batch(project.getFileNames.gen(true)); + assert.deepEqual(names, ["/src/local.ts", "/src/main.ts", "/tsconfig.json"]); + assert.deepEqual(requestBatches, [ + ["getSourceFileNames"], + ["getSourceFileMetadata", "getSourceFileMetadata", "getSourceFileMetadata"], + ["getConfigFileNames"], + ]); + requestBatches.length = 0; + assert.deepEqual(api.batch(project.getFileNames.gen(true))[0], names); + assert.deepEqual(requestBatches, [["getSourceFileNames"], ["getConfigFileNames"]]); + + const local = program.getSourceFile("/src/local.ts"); + assert.ok(local); + requestBatches.length = 0; + const [files] = api.batch(program.getSourceFiles.gen()); + assert.deepEqual(files.map(file => file.fileName), ["/node_modules/my-lib/index.d.ts", "/src/local.ts", "/src/main.ts"]); + assert.strictEqual(files[1], local); + assert.deepEqual(requestBatches, [["getSourceFileNames"], ["getSourceFile", "getSourceFile"]]); + + requestBatches.length = 0; + const [cached] = api.batch(program.getSourceFiles.gen()); + for (let i = 0; i < files.length; i++) { + assert.strictEqual(cached[i], files[i]); + } + assert.deepEqual(requestBatches, [["getSourceFileNames"]]); + }); + test("all and defer yield discriminated host messages without starting children", () => { let started = false; function* child() { @@ -1599,6 +1663,15 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Snapshot", "getDefaultProjectForFile", snapshot.getDefaultProjectForFile, assertOptionalProjectsEquivalent, "/src/index.ts"), parityCase("ModuleResolver", "resolveModuleName", moduleResolver.resolveModuleName, assertDeepEquivalent, "models", "/src"), parityCase("Snapshot", "update", snapshot.update, assertSnapshotsEquivalent, {}), + parityCase("Project", "containsFile", project.containsFile, assertDeepEquivalent, "/src/index.ts"), + parityCase("Project", "containsFile", project.containsFile, assertDeepEquivalent, "/src/index.ts", true), + parityCase("Project", "getFileNames", project.getFileNames, assertDeepEquivalent), + parityCase("Project", "getFileNames", project.getFileNames, assertDeepEquivalent, true, true), + parityCase("Project", "getRootFiles", project.getRootFiles, assertDeepEquivalent), + parityCase("Project", "hasRoots", project.hasRoots, assertDeepEquivalent), + parityCase("Project", "isRoot", project.isRoot, assertDeepEquivalent, "/src/index.ts"), + parityCase("Project", "hasConfigFile", project.hasConfigFile, assertDeepEquivalent, "/tsconfig.json"), + parityCase("Project", "getSourceFile", project.getSourceFile, assertOptionalSourceFilesEquivalent, "/src/index.ts"), parityCase("LanguageService", "getImportAdderEdits", languageService.getImportAdderEdits, assertDeepEquivalent, "/src/index.ts", [{ kind: "importSymbol", symbol: unimportedSymbol }]), parityCase("LanguageService", "getImportEditsForSymbols", languageService.getImportEditsForSymbols, assertDeepEquivalent, "/src/index.ts", [unimportedSymbol], { isValidTypeOnlyUseSite: true }), @@ -1616,6 +1689,13 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Program", "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective", program.getResolvedTypeReferenceDirectiveFromTypeReferenceDirective, assertDeepEquivalent, typeReferenceDirective, "/src/index.ts"), parityCase("Program", "getSourceFileByPath", program.getSourceFileByPath, assertOptionalSourceFilesEquivalent, indexFile.path), parityCase("Program", "getSourceFileNames", program.getSourceFileNames, assertDeepEquivalent), + parityCase("Program", "getRootFileNames", program.getRootFileNames, assertDeepEquivalent), + parityCase("Program", "getSourceFiles", program.getSourceFiles, (actual, expected, message) => { + assertArrayElementsEquivalent(actual, expected, assertSourceFilesEquivalent, message); + }), + parityCase("Program", "getProjectReferences", program.getProjectReferences, assertDeepEquivalent), + parityCase("Program", "getResolvedProjectReferences", program.getResolvedProjectReferences, assertDeepEquivalent), + parityCase("Program", "getTypeChecker", program.getTypeChecker, assert.strictEqual), parityCase("Program", "getSourceFileMetadata", program.getSourceFileMetadata, assertDeepEquivalent, "/src/index.ts"), parityCase("Program", "getSourceFileMetadataByPath", program.getSourceFileMetadataByPath, assertDeepEquivalent, indexFile.path), parityCase("Program", "isSourceFileFromExternalLibrary", program.isSourceFileFromExternalLibrary, assertDeepEquivalent, indexFile), @@ -1632,6 +1712,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Program", "getSuggestionDiagnostics", program.getSuggestionDiagnostics, assertDeepEquivalent, "/src/suggestions.ts"), parityCase("Program", "getDeclarationDiagnostics", program.getDeclarationDiagnostics, assertDeepEquivalent, "/src/index.ts"), parityCase("Program", "getProgramDiagnostics", program.getProgramDiagnostics, assertDeepEquivalent), + parityCase("Program", "getOptionsDiagnostics", program.getOptionsDiagnostics, assertDeepEquivalent), parityCase("Program", "getGlobalDiagnostics", program.getGlobalDiagnostics, assertDeepEquivalent), parityCase("Program", "getConfigFileParsingDiagnostics", program.getConfigFileParsingDiagnostics, assertDeepEquivalent), parityCase("Program", "emit", program.emit, assertDeepEquivalent), @@ -1789,6 +1870,14 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => ]; runParityBatch(api, cases); + runParityBatch(api, [ + parityCase("Program", "getIdentifierCount", program.getIdentifierCount, assertDeepEquivalent), + parityCase("Program", "getNodeCount", program.getNodeCount, assertDeepEquivalent), + parityCase("Program", "getSymbolCount", program.getSymbolCount, assertDeepEquivalent), + parityCase("Program", "getTypeCount", program.getTypeCount, assertDeepEquivalent), + parityCase("Program", "getInstantiationCount", program.getInstantiationCount, assertDeepEquivalent), + parityCase("Program", "getRelationCacheSizes", program.getRelationCacheSizes, assertDeepEquivalent), + ]); assert.deepEqual(temporaryProjects, ["/tsconfig.json", "/tsconfig.json"]); const snapshotFiles = { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 1f06169429fd6..64250ff47d068 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -135,8 +135,19 @@ import { type UserPreferences, } from "@typescript/typescript/sync"; import assert from "node:assert"; -import { globSync } from "node:fs"; -import { resolve } from "node:path"; +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + join, + resolve, +} from "node:path"; import { describe, test, @@ -2555,6 +2566,271 @@ describe("SourceFile", { concurrency }, () => { ); }); + test("Program accessors distinguish root files from source files and reuse the checker", () => { + using api = spawnAPI({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + }); + using program = api.createProgram(["/src/main.ts"], { noLib: true }); + assert.deepEqual(program.getRootFileNames(), ["/src/main.ts"]); + const files = program.getSourceFiles(); + assert.deepEqual(files.map(file => file.fileName), ["/src/dependency.ts", "/src/main.ts"]); + assert.strictEqual(files[1], program.getSourceFile("/src/main.ts")); + const checker = program.getTypeChecker(); + assert.strictEqual(program.getTypeChecker(), checker); + assert.ok(checker.getSymbolAtLocation(cast(files[1].statements[1], isVariableStatement).declarationList.declarations[0].name)); + assert.equal(program.getProjectReferences(), undefined); + assert.equal(program.getResolvedProjectReferences(), undefined); + }); + + test("Program counters reflect work on its type checker without forcing diagnostics", () => { + using api = spawnAPI({ + "/src/main.ts": `type Box = { value: T }; +export const box: Box = { value: "hello" }; +export const other: { value: string } = box;`, + }); + using program = api.createProgram(["/src/main.ts"], { noLib: true }); + const identifiers = program.getIdentifierCount(); + const nodes = program.getNodeCount(); + const symbols = program.getSymbolCount(); + const types = program.getTypeCount(); + const instantiations = program.getInstantiationCount(); + const relations = program.getRelationCacheSizes(); + assert.ok(identifiers > 0); + assert.ok(nodes > identifiers); + assert.ok(symbols > 0); + assert.ok(types > 0); + assert.equal(instantiations, 0); + assert.deepEqual(relations, { assignable: 0, identity: 0, subtype: 0, strictSubtype: 0 }); + + const file = (program.getSourceFile("/src/main.ts"))!; + const checker = program.getTypeChecker(); + const declaration = cast(file.statements[1], isVariableStatement).declarationList.declarations[0]; + const type = checker.getTypeAtLocation(declaration.name); + type.getProperties(); + assert.ok(program.getInstantiationCount() > instantiations); + assert.ok(program.getTypeCount() > types); + assert.ok(program.getSymbolCount() >= symbols); + const other = cast(file.statements[2], isVariableStatement).declarationList.declarations[0]; + checker.isTypeAssignableTo(type, checker.getTypeAtLocation(other.name)); + assert.ok((program.getRelationCacheSizes()).assignable > relations.assignable); + assert.equal(program.getIdentifierCount(), identifiers); + assert.equal(program.getNodeCount(), nodes); + }); + + test("Program accessors handle empty programs and missing root files", () => { + using api = spawnAPI({}); + using empty = api.createProgram([], { noLib: true }); + assert.deepEqual(empty.getRootFileNames(), []); + assert.deepEqual(empty.getSourceFiles(), []); + assert.equal(empty.getNodeCount(), 0); + assert.equal(empty.getIdentifierCount(), 0); + assert.equal(empty.getProjectReferences(), undefined); + assert.equal(empty.getResolvedProjectReferences(), undefined); + assert.deepEqual(empty.getOptionsDiagnostics(), []); + + using noReferences = api.createProgram([], { noLib: true }, { projectReferences: [] }); + assert.deepEqual(noReferences.getProjectReferences(), []); + assert.deepEqual(noReferences.getResolvedProjectReferences(), []); + + using missing = api.createProgram(["/missing.ts"], { noLib: true }); + assert.deepEqual(missing.getRootFileNames(), ["/missing.ts"]); + assert.deepEqual(missing.getSourceFiles(), []); + assert.ok((missing.getOptionsDiagnostics()).some(diagnostic => diagnostic.code === 6053)); + }); + + test("Program options diagnostics exclude source-file and config parsing diagnostics", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true, strictNullChecks: false, exactOptionalPropertyTypes: true, unknownOption: true }, + files: ["src/main.ts"], + }), + "/src/main.ts": `/// +const value: string = 1;`, + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const diagnostics = program.getOptionsDiagnostics(); + assert.ok(diagnostics.some(diagnostic => diagnostic.code === 5052)); + assert.ok(diagnostics.every(diagnostic => !diagnostic.fileName || diagnostic.fileName === "/tsconfig.json")); + assert.ok(!(diagnostics.some(diagnostic => diagnostic.code === 5023 || diagnostic.code === 6053 || diagnostic.code === 2322))); + assert.ok((program.getConfigFileParsingDiagnostics()).some(diagnostic => diagnostic.code === 5023)); + }); + + test("Program resolved project references preserve missing, nested, shared, and circular references", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./a" }, { path: "./missing" }, { path: "./b" }, { path: "./a" }] }), + "/a/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../b" }] }), + "/a/index.ts": "export const a = 1;", + "/b/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../a", circular: true }, { path: "../missing" }] }), + "/b/index.ts": "export const b = 1;", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const references = program.getProjectReferences(); + assert.equal(references?.length, 4); + const resolved = program.getResolvedProjectReferences(); + assert.equal(resolved?.length, 4); + assert.equal(resolved![1], undefined); + const a = resolved![0]!; + const b = resolved![2]!; + assert.strictEqual(resolved![3], a); + assert.strictEqual(a.references![0], b); + assert.strictEqual(b.references![0], a); + assert.equal(b.references![1], undefined); + assert.equal(a.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(a.commandLine.options.composite, true); + assert.deepEqual(a.commandLine.fileNames, ["/a/index.ts"]); + assert.strictEqual((program.getResolvedProjectReferences())![0], a); + + using synthetic = api.createProgram([], { noLib: true }, { + projectReferences: [{ path: "/a/tsconfig.json" }, { path: "/missing/tsconfig.json" }], + }); + assert.equal((synthetic.getProjectReferences())!.length, 2); + const syntheticResolved = synthetic.getResolvedProjectReferences(); + assert.equal(syntheticResolved!.length, 2); + assert.equal(syntheticResolved![0]!.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(syntheticResolved![1], undefined); + }); + + test("Project membership distinguishes roots, dependencies, and open files", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts", "missing.ts"] }), + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + "/outside.ts": "export {};", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts", "/outside.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(project.hasRoots(), true); + assert.deepEqual(project.getRootFiles(), ["/src/main.ts"]); + assert.equal(project.isRoot("src/main.ts"), true); + assert.equal(project.isRoot({ uri: "file:///src/main.ts" }), true); + assert.equal(project.isRoot("/missing.ts"), false); + assert.equal(project.isRoot("/src/dependency.ts"), false); + assert.equal(project.containsFile("src/main.ts"), true); + assert.equal(project.containsFile({ uri: "file:///src/main.ts" }, true), true); + assert.equal(project.containsFile("/src/dependency.ts"), true); + assert.equal(project.containsFile("/src/dependency.ts", true), false); + assert.equal(project.containsFile("/outside.ts"), false); + assert.equal(project.containsFile("/outside.ts", true), false); + assert.equal(project.containsFile("/missing.ts"), false); + assert.strictEqual(project.getSourceFile("src/main.ts"), project.program.getSourceFile("/src/main.ts")); + assert.equal(project.getSourceFile("/outside.ts"), undefined); + assert.equal(project.hasConfigFile("tsconfig.json"), true); + assert.equal(project.hasConfigFile("/src/main.ts"), false); + + const updated = snapshot.update({ closeFiles: ["/src/main.ts"] }); + assert.equal(updated.getConfiguredProject("/tsconfig.json")!.containsFile("/src/main.ts", true), false); + assert.equal(project.containsFile("/src/main.ts", true), true); + }); + + test("Project membership includes supplemental files without physical files", { + skip: process.env.TS_API_TEST_WASM ? "WASM cannot spawn external content mapper processes" : false, + }, t => { + const directory = mkdtempSync(join(tmpdir(), "ts-project-membership-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const mapperDirectory = join(directory, "node_modules", "mapper"); + mkdirSync(mapperDirectory, { recursive: true }); + const mapperScript = ` + import { createMessageConnection } from ${JSON.stringify(new URL("../../vendor/vscode-jsonrpc/lib/node/main.js", import.meta.url).href)}; + const connection = createMessageConnection(process.stdin, process.stdout); + connection.onRequest("initialize", () => ({ positionEncoding: "utf-8", diagnosticSource: "mapper" })); + connection.onRequest("openProject", () => ({})); + connection.onRequest("closeProject", () => null); + connection.onRequest("transform", ({ content }) => ({ + text: "export {};", + extension: ".ts", + supplemental: [{ text: content, extension: ".ts" }], + })); + connection.listen(); + `; + writeFileSync( + join(mapperDirectory, "package.json"), + JSON.stringify({ + name: "mapper", + version: "1.0.0", + typescript: { contentMapper: { exec: [process.execPath, "--input-type=module", "--eval", mapperScript] } }, + }), + ); + const configFileName = toRootedFilePath(join(directory, "tsconfig.json"), undefined); + writeFileSync( + configFileName, + JSON.stringify({ + compilerOptions: { noLib: true }, + files: ["app.box", "main.ts", "missing.ts"], + contentMappers: [{ package: "mapper", extensions: [".box"] }], + }), + ); + writeFileSync(join(directory, "app.box"), "declare const supplementalValue: number;"); + writeFileSync(join(directory, "main.ts"), "const value: number = supplementalValue;"); + using api = new API({ cwd: directory, runExternalCode: true }); + const snapshot = api.createSnapshot({ openProjects: [configFileName] }); + const project = snapshot.getConfiguredProject(configFileName)!; + const supplementalFileName = toRootedFilePath(join(directory, "app.box.0.ts"), undefined); + assert.equal(existsSync(supplementalFileName), false); + assert.ok(project.getSourceFile(supplementalFileName)); + assert.equal(project.containsFile(supplementalFileName), true); + assert.equal(project.containsFile(supplementalFileName, true), false); + assert.equal(project.containsFile(join(directory, "missing.ts")), false); + }); + + test("Project file lists include extended configs and optionally exclude external libraries", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), + "/base.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" } }), + "/src/main.ts": `import { value } from "my-lib"; export const result = value;`, + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.deepEqual(project.getFileNames(), ["/node_modules/my-lib/index.d.ts", "/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(project.getFileNames(true), ["/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(project.getFileNames(false, true), ["/node_modules/my-lib/index.d.ts", "/src/main.ts"]); + assert.deepEqual(project.getFileNames(true, true), ["/src/main.ts"]); + assert.equal(project.hasConfigFile("./base.json"), true); + assert.equal(project.hasConfigFile({ uri: "file:///base.json" }), true); + assert.equal(project.hasConfigFile("/other.json"), false); + assert.equal(project.containsFile("/tsconfig.json"), false); + }); + + test("Project roots preserve missing declarations and empty projects", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: [] }), + }); + const snapshot = api.createSnapshot({ + openProjects: ["/tsconfig.json"], + createPrograms: [{ rootFiles: ["/missing.ts"], compilerOptions: { noLib: true } }], + }); + const empty = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(empty.hasRoots(), false); + assert.deepEqual(empty.getRootFiles(), []); + assert.deepEqual(empty.getFileNames(), ["/tsconfig.json"]); + const missing = snapshot.getProject(snapshot.operation.createdPrograms![0].id)!; + assert.equal(missing.hasRoots(), true); + assert.deepEqual(missing.getRootFiles(), []); + assert.equal(missing.isRoot("/missing.ts"), false); + assert.equal(missing.containsFile("/missing.ts"), false); + assert.deepEqual(missing.getFileNames(), []); + assert.equal(missing.hasConfigFile("/tsconfig.json"), false); + }); + + test("Project membership uses configured path case sensitivity", () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": "export {};", + }; + for (const useCaseSensitiveFileNames of [true, false]) { + using api = new API({ cwd: "/", fs: createVirtualFileSystem(files), useCaseSensitiveFileNames }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(project.isRoot("/SRC/MAIN.TS"), !useCaseSensitiveFileNames); + assert.equal(project.hasConfigFile("/TSCONFIG.JSON"), !useCaseSensitiveFileNames); + assert.equal(project.containsFile("/SRC/MAIN.TS", true), !useCaseSensitiveFileNames); + } + }); + test("getSourceFileNames returns all program files, not just root files", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 47b04cb15faec..73ad40fa17816 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -111,6 +111,12 @@ const ( MethodGetNonMissingTypeOfSymbol Method = "getNonMissingTypeOfSymbol" MethodGetSourceFile Method = "getSourceFile" MethodGetSourceFileNames Method = "getSourceFileNames" + MethodContainsProjectFile Method = "containsProjectFile" + MethodGetProjectRootFiles Method = "getProjectRootFiles" + MethodEnsureTypeChecker Method = "ensureTypeChecker" + MethodGetProgramStatistics Method = "getProgramStatistics" + MethodGetResolvedProjectReferences Method = "getResolvedProjectReferences" + MethodGetOptionsDiagnostics Method = "getOptionsDiagnostics" MethodGetSourceFileMetadata Method = "getSourceFileMetadata" MethodGetModeForUsageLocation Method = "getModeForUsageLocation" MethodGetModeForResolutionAtIndex Method = "getModeForResolutionAtIndex" @@ -628,6 +634,8 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetDefaultProjectForFile: unmarshallerFor[GetDefaultProjectForFileParams], MethodGetSourceFile: unmarshallerFor[GetSourceFileParams], MethodGetSourceFileNames: unmarshallerFor[GetSourceFileNamesParams], + MethodContainsProjectFile: unmarshallerFor[ContainsProjectFileParams], + MethodGetProjectRootFiles: unmarshallerFor[GetSourceFileNamesParams], MethodGetSourceFileMetadata: unmarshallerFor[GetSourceFileParams], MethodGetModeForUsageLocation: unmarshallerFor[GetModeForUsageLocationParams], MethodGetModeForResolutionAtIndex: unmarshallerFor[GetModeForResolutionAtIndexParams], @@ -772,6 +780,10 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetSuggestionDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetDeclarationDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetProgramDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], + MethodEnsureTypeChecker: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetProgramStatistics: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetResolvedProjectReferences: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetOptionsDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetGlobalDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetConfigFileParsingDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodStartCPUProfile: unmarshallerFor[ProfileParams], @@ -1016,13 +1028,34 @@ type ConfigFileResponse struct { FileNames []tspath.RootedFilePath `json:"fileNames" nonnil:"true"` Options *core.CompilerOptions `json:"options" nonnil:"true"` BuildOptions *core.BuildOptions `json:"buildOptions,omitempty"` - ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` + ProjectReferences []*core.ProjectReference `json:"projectReferences,omitzero"` TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition,omitempty"` CompileOnSave *bool `json:"compileOnSave,omitempty"` Raw any `json:"raw,omitempty"` Errors []*DiagnosticResponse `json:"errors" nonnil:"true"` } +type ProgramStatisticsResponse struct { + NodeCount int `json:"nodeCount"` + IdentifierCount int `json:"identifierCount"` + SymbolCount int `json:"symbolCount"` + TypeCount uint32 `json:"typeCount"` + InstantiationCount uint32 `json:"instantiationCount"` + RelationCacheSizes checker.RelationCacheSizes `json:"relationCacheSizes"` +} + +// References use one-based indices into Projects; zero denotes an unresolved reference. +type ResolvedProjectReferencesResponse struct { + References []int `json:"references,omitzero"` + Projects []*ResolvedProjectReferenceResponse `json:"projects" nonnil:"true"` +} + +type ResolvedProjectReferenceResponse struct { + CommandLine *ConfigFileResponse `json:"commandLine" nonnil:"true"` + SourceFile *SourceFileResponse `json:"sourceFile" nonnil:"true"` + References []int `json:"references,omitzero"` +} + type ReadConfigFileResponse struct { Config any `json:"config"` Error *DiagnosticResponse `json:"error,omitempty"` @@ -1389,6 +1422,13 @@ type GetSourceFileNamesParams struct { Project project.Handle `json:"project"` } +type ContainsProjectFileParams struct { + Snapshot SnapshotID `json:"snapshot"` + Project project.Handle `json:"project"` + File DocumentIdentifier `json:"file"` + RequireOpen bool `json:"requireOpen,omitempty"` +} + type GetModeForUsageLocationParams struct { Snapshot SnapshotID `json:"snapshot"` Project project.Handle `json:"project"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 2fc7169226d00..75df3961e3ec6 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -951,6 +951,18 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleGetSourceFile(ctx, parsed.(*GetSourceFileParams)) case string(MethodGetSourceFileNames): return s.handleGetSourceFileNames(ctx, parsed.(*GetSourceFileNamesParams)) + case string(MethodContainsProjectFile): + return s.handleContainsProjectFile(ctx, parsed.(*ContainsProjectFileParams)) + case string(MethodGetProjectRootFiles): + return s.handleGetProjectRootFiles(ctx, parsed.(*GetSourceFileNamesParams)) + case string(MethodEnsureTypeChecker): + return s.handleEnsureTypeChecker(ctx, parsed.(*GetProjectDiagnosticsParams)) + case string(MethodGetProgramStatistics): + return s.handleGetProgramStatistics(ctx, parsed.(*GetProjectDiagnosticsParams)) + case string(MethodGetResolvedProjectReferences): + return s.handleGetResolvedProjectReferences(ctx, parsed.(*GetProjectDiagnosticsParams)) + case string(MethodGetOptionsDiagnostics): + return s.handleGetOptionsDiagnostics(ctx, parsed.(*GetProjectDiagnosticsParams)) case string(MethodGetSourceFileMetadata): return s.handleGetSourceFileMetadata(ctx, parsed.(*GetSourceFileParams)) case string(MethodGetModeForUsageLocation): @@ -2374,6 +2386,108 @@ func transpileOutput(ctx context.Context, input string, options TranspileOptions }, nil } +func (s *Session) handleEnsureTypeChecker(ctx context.Context, params *GetProjectDiagnosticsParams) (bool, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return false, err + } + defer setup.done() + return true, nil +} + +func (s *Session) handleGetProgramStatistics(ctx context.Context, params *GetProjectDiagnosticsParams) (*ProgramStatisticsResponse, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return nil, err + } + defer setup.done() + result := &ProgramStatisticsResponse{ + SymbolCount: int(setup.checker.SymbolCount), + TypeCount: setup.checker.TypeCount, + InstantiationCount: setup.checker.TotalInstantiationCount, + RelationCacheSizes: setup.checker.GetRelationCacheSizes(), + } + for _, file := range setup.program.GetSourceFiles() { + result.NodeCount += file.NodeCount + result.IdentifierCount += file.IdentifierCount + result.SymbolCount += file.SymbolCount + } + return result, nil +} + +func (s *Session) handleGetResolvedProjectReferences(ctx context.Context, params *GetProjectDiagnosticsParams) (*ResolvedProjectReferencesResponse, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + program, err := sd.getProgram(params.Project) + if err != nil { + return nil, err + } + result := &ResolvedProjectReferencesResponse{Projects: []*ResolvedProjectReferenceResponse{}} + indices := map[*tsoptions.ParsedCommandLine]int{} + var visit func(config *tsoptions.ParsedCommandLine) (int, error) + visit = func(config *tsoptions.ParsedCommandLine) (int, error) { + if config == nil { + return 0, nil + } + if index, ok := indices[config]; ok { + return index, nil + } + if config.ConfigFile == nil || config.ConfigFile.SourceFile == nil { + return 0, errors.New("resolved project reference has no config source file") + } + file := config.ConfigFile.SourceFile + data, _, encodeErr := encoder.EncodeSourceFile(file) + if encodeErr != nil { + return 0, fmt.Errorf("failed to encode project reference config: %w", encodeErr) + } + encoder.SetSourceFileID(data, sourceFileNodeID(file)) + response := &ResolvedProjectReferenceResponse{ + CommandLine: NewConfigFileResponse(config), + SourceFile: &SourceFileResponse{Data: base64.StdEncoding.EncodeToString(data)}, + } + result.Projects = append(result.Projects, response) + index := len(result.Projects) + indices[config] = index + if config.ProjectReferences() != nil { + response.References = make([]int, len(config.ProjectReferences())) + for i, path := range config.ResolvedProjectReferencePaths() { + child, _ := program.GetResolvedProjectReferenceFor(program.PathKeyForFileName(path)) + response.References[i], err = visit(child) + if err != nil { + return 0, err + } + } + } + return index, nil + } + if program.CommandLine().ProjectReferences() != nil { + references := program.GetResolvedProjectReferences() + result.References = make([]int, len(references)) + for i, config := range references { + result.References[i], err = visit(config) + if err != nil { + return nil, err + } + } + } + return result, nil +} + +// @gen-proto-nullable +func (s *Session) handleGetOptionsDiagnostics(ctx context.Context, params *GetProjectDiagnosticsParams) ([]*DiagnosticResponse, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + program, err := sd.getProgram(params.Project) + if err != nil { + return nil, err + } + return NewDiagnosticResponses(program.GetOptionsDiagnostics()), nil +} + // handleGetSourceFile returns a source file from a project within a snapshot. // @gen-proto-result: SourceFileResponse // @gen-proto-nullable @@ -2481,6 +2595,49 @@ func (s *Session) encodeSourceFileResponse(sourceFile *ast.SourceFile) (any, err }, nil } +func (s *Session) handleContainsProjectFile(ctx context.Context, params *ContainsProjectFileParams) (bool, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return false, err + } + proj, err := sd.getProject(params.Project) + if err != nil { + return false, err + } + fileName := params.File.ToFileName(proj.CurrentDirectory()) + path := sd.snapshot.CaseSensitivity().PathKey(fileName.AsPath()) + if params.RequireOpen && !sd.snapshot.ProjectCollection.IsFileOpen(path) { + return false, nil + } + if program := proj.GetProgram(); program != nil && program.GetSourceFileByPath(path) != nil { + return true, nil + } + for _, root := range proj.CommandLine.FileNames() { + if sd.snapshot.CaseSensitivity().PathKey(root.AsPath()) == path { + return sd.snapshot.GetFile(fileName) != nil, nil + } + } + return false, nil +} + +func (s *Session) handleGetProjectRootFiles(ctx context.Context, params *GetSourceFileNamesParams) ([]tspath.RootedFilePath, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + proj, err := sd.getProject(params.Project) + if err != nil { + return nil, err + } + result := make([]tspath.RootedFilePath, 0, len(proj.CommandLine.FileNames())) + for _, root := range proj.CommandLine.FileNames() { + if sd.snapshot.GetFile(root) != nil { + result = append(result, root) + } + } + return result, nil +} + // handleGetSourceFileNames returns file names of all source files in a project. func (s *Session) handleGetSourceFileNames(ctx context.Context, params *GetSourceFileNamesParams) ([]tspath.RootedFilePath, error) { sd, err := s.getSnapshotData(params.Snapshot) diff --git a/tsc/internal/api/session_program_test.go b/tsc/internal/api/session_program_test.go new file mode 100644 index 0000000000000..e31cd1dad8a44 --- /dev/null +++ b/tsc/internal/api/session_program_test.go @@ -0,0 +1,176 @@ +package api + +import ( + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestProjectContainsSupplementalFile(t *testing.T) { + t.Parallel() + + const supplementalFileName = "/home/project/app.box.0.ts" + files := map[string]any{ + "/home/project/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["app.box","main.ts","missing.ts"],"contentMappers":[{"package":"mapper","extensions":[".box"]}]}`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.SupplementalMapper), + "/home/project/app.box": "declare const supplementalValue: number;", + "/home/project/main.ts": "const value: number = supplementalValue;", + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + RunExternalCode: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + projectSession := project.NewSession(init) + defer projectSession.Close() + projectSession.DidOpenFile(context.Background(), "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + params := &ContainsProjectFileParams{ + Snapshot: response.Snapshot, + Project: response.Projects[0].Handle, + File: DocumentIdentifier{FileName: supplementalFileName}, + } + sd, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Assert(t, sd.snapshot.GetFile(supplementalFileName) == nil) + sourceFile, err := session.handleGetSourceFile(context.Background(), &GetSourceFileParams{ + Snapshot: params.Snapshot, Project: params.Project, File: params.File, + }) + assert.NilError(t, err) + assert.Assert(t, sourceFile != nil) + contains, err := session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, contains) + params.RequireOpen = true + contains, err = session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, !contains) + params.RequireOpen = false + params.File = DocumentIdentifier{FileName: "/home/project/missing.ts"} + contains, err = session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, !contains) +} + +func TestProjectContainsEditorOpenFile(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/home/projects/p/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + fileName: "export {};", + }) + defer projectSession.Close() + uri := DocumentIdentifier{FileName: fileName}.ToURI(projectSession.GetCurrentDirectory()) + projectSession.DidOpenFile(context.Background(), uri, 1, "export {};", lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + params := &ContainsProjectFileParams{ + Snapshot: response.Snapshot, + Project: response.Projects[0].Handle, + File: DocumentIdentifier{FileName: fileName}, + RequireOpen: true, + } + contains, err := session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, contains) + params.Project = project.Handle(999999) + _, err = session.handleContainsProjectFile(context.Background(), params) + assert.ErrorContains(t, err, "not found") + _, err = session.handleGetProjectRootFiles(context.Background(), &GetSourceFileNamesParams{ + Snapshot: response.Snapshot, Project: params.Project, + }) + assert.ErrorContains(t, err, "not found") +} + +func TestProgramStatisticsUseAPIChecker(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const value = 1;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + params := &GetProjectDiagnosticsParams{Snapshot: response.Snapshot, Project: response.Projects[0].Handle} + setup, err := session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + file := setup.program.GetSourceFile(fileName) + setup.checker.GetTypeAtLocation(file.Statements.Nodes[0]) + expected := ProgramStatisticsResponse{ + NodeCount: file.NodeCount, + IdentifierCount: file.IdentifierCount, + SymbolCount: file.SymbolCount + int(setup.checker.SymbolCount), + TypeCount: setup.checker.TypeCount, + InstantiationCount: setup.checker.TotalInstantiationCount, + RelationCacheSizes: setup.checker.GetRelationCacheSizes(), + } + setup.done() + + actual, err := session.handleGetProgramStatistics(context.Background(), params) + assert.NilError(t, err) + assert.DeepEqual(t, *actual, expected) + _, err = session.handleGetProgramStatistics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: response.Snapshot, Project: project.Handle(999999), + }) + assert.ErrorContains(t, err, "not found") +} + +func TestEnsureTypeChecker(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const value = 1;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + params := &GetProjectDiagnosticsParams{Snapshot: response.Snapshot, Project: response.Projects[0].Handle} + initialized, err := session.handleEnsureTypeChecker(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, initialized) + setup, err := session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + first := setup.checker + setup.done() + + initialized, err = session.handleEnsureTypeChecker(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, initialized) + setup, err = session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + assert.Assert(t, setup.checker == first) + setup.done() + + _, err = session.handleEnsureTypeChecker(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: response.Snapshot, Project: project.Handle(999999), + }) + assert.ErrorContains(t, err, "not found") +} diff --git a/tsc/internal/checker/exports.go b/tsc/internal/checker/exports.go index 5fedfddca61a5..78fdd0a472825 100644 --- a/tsc/internal/checker/exports.go +++ b/tsc/internal/checker/exports.go @@ -7,6 +7,15 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/diagnostics" ) +func (c *Checker) GetRelationCacheSizes() RelationCacheSizes { + return RelationCacheSizes{ + Assignable: c.assignableRelation.size(), + Identity: c.identityRelation.size(), + Subtype: c.subtypeRelation.size(), + StrictSubtype: c.strictSubtypeRelation.size(), + } +} + func (c *Checker) GetStringType() *Type { return c.stringType } diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 273dfa20290e7..b24102dfe098b 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -14,6 +14,13 @@ import ( //go:generate npx hereby generate:checker +type RelationCacheSizes struct { + Assignable int `json:"assignable"` + Identity int `json:"identity"` + Subtype int `json:"subtype"` + StrictSubtype int `json:"strictSubtype"` +} + // ParseFlags type ParseFlags uint32 diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 5b8b8d86d4e32..b7e34edfb1bc1 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -857,6 +857,27 @@ func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic { )) } +func (p *Program) GetOptionsDiagnostics() []*ast.Diagnostic { + configFiles := map[tspath.PathKey]*ast.SourceFile{} + addConfig := func(config *tsoptions.ParsedCommandLine) { + if config != nil && config.ConfigFile != nil { + configFiles[config.ConfigFile.SourceFile.PathKey()] = config.ConfigFile.SourceFile + } + } + addConfig(p.CommandLine()) + p.RangeResolvedProjectReference(func(_ tspath.PathKey, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool { + addConfig(config) + return true + }) + result := core.Filter(p.GetProgramDiagnostics(), func(d *ast.Diagnostic) bool { + return d.File() == nil || configFiles[d.File().PathKey()] != nil + }) + for _, file := range configFiles { + result = append(result, p.includeProcessor.getDiagnostics(p).GetDiagnosticsForFile(file)...) + } + return SortAndDeduplicateDiagnostics(result) +} + func (p *Program) collectContentMapperOptionDiagnostics() { project := p.ContentMapperProject() if project == nil { diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index 609985e2c8bd5..222701557ece7 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -224,6 +224,14 @@ func (c *ProjectCollection) GetOpenConfiguredProjects() *collections.Set[Configu return c.openConfiguredProjects } +func (c *ProjectCollection) IsFileOpen(path tspath.PathKey) bool { + if c.openFiles.Has(path) { + return true + } + _, ok := c.apiState.openFiles[path] + return ok +} + func openFilePaths(overlays map[tspath.PathKey]*Overlay) collections.Set[tspath.PathKey] { openFiles := collections.Set[tspath.PathKey]{M: make(map[tspath.PathKey]struct{}, len(overlays))} for path := range overlays {