diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index d91df45d68322..d767720351e95 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -60,14 +60,18 @@ import { toPath, } from "../path.ts"; import type { + BuildResponse, + CleanBuildResponse, CompilerOptions, ConfiguredProjectId, + CreateBuildOrchestratorResponse, CreateProgramOptions as ProtocolCreateProgramOptions, CreateSnapshotParams as ProtocolCreateSnapshotParams, CreateSnapshotProgramParams as ProtocolCreateSnapshotProgramParams, CreateSnapshotResponse, CreateSourceFileOptions, Diagnostic, + DiagnosticResponse, DocumentIdentifier, DocumentPosition, EmitOutputResponse as ProtocolEmitOutputResponse, @@ -328,6 +332,7 @@ export class API implements FormatDiagnosticsHo private initialized: boolean = false; private initializing: Promise | undefined; private activeSnapshots: Map = new Map(); + private activeBuildOrchestrators: Set = new Set(); private activeSourceFileLeases: Map = new Map(); readonly printer: Printer; readonly internal: InternalAPI; @@ -399,6 +404,21 @@ export class API implements FormatDiagnosticsHo return "\n"; } + async createBuildOrchestrator(rootNames: readonly string[], buildOrchestratorOptions: BuildOrchestratorOptions): Promise { + await this.ensureInitialized(); + const orchestratorResponse = await this.client.apiRequest("createBuildOrchestrator", { + ...buildOrchestratorOptions, + ...buildOrchestratorOptions.overrideCompilerOptions, + rootNames, + }); + + const orchestrator = new BuildOrchestrator(this.client, orchestratorResponse, () => { + this.activeBuildOrchestrators.delete(orchestrator); + }); + this.activeBuildOrchestrators.add(orchestrator); + return orchestrator; + } + async parseConfigFile(file: DocumentIdentifier): Promise { await this.ensureInitialized(); return this.client.apiRequest("parseConfigFile", { file }); @@ -652,6 +672,9 @@ export class API implements FormatDiagnosticsHo } finally { try { + for (const orchestrator of [...this.activeBuildOrchestrators]) { + await orchestrator.dispose(); + } for (const snapshot of [...this.activeSnapshots.values()]) { await snapshot.dispose(); } @@ -1961,6 +1984,107 @@ export class Program implements FormatDiagnost } } +export interface BuildOrchestratorOptions { + cwd?: string | undefined; + dry?: boolean; + force?: boolean; + verbose?: boolean; + stopBuildOnErrors?: boolean; + overrideCompilerOptions?: OverrideCompilerOptions; +} + +export interface OverrideCompilerOptions { + incremental?: boolean; + assumeChangesOnlyAffectDirectDependencies?: boolean; + declaration?: boolean; + declarationMap?: boolean; + emitDeclarationOnly?: boolean; + sourceMap?: boolean; + inlineSourceMap?: boolean; + traceResolution?: boolean; +} + +export class BuildOrchestrator { + private client: Client; + private id: number; + private disposed = false; + private disposePromise: Promise | undefined; + private onDispose: () => void; + + constructor( + client: Client, + orchestratorResponse: CreateBuildOrchestratorResponse, + onDispose: () => void, + ) { + this.client = client; + this.id = orchestratorResponse.buildOrchestratorID; + this.onDispose = onDispose; + } + + [globalThis.Symbol.dispose](): void { + void this.dispose(); + } + dispose(): Promise { + return this.disposePromise ??= this.disposeWorker(); + } + + private async disposeWorker(): Promise { + if (this.disposed) return; + this.disposed = true; + try { + await this.client.apiRequest("disposeBuildOrchestrator", { + buildOrchestratorID: this.id, + }); + } + finally { + this.onDispose(); + } + } + + async build(project?: string): Promise { + this.ensureNotDisposed(); + const response = await this.client.apiRequest("build", { + buildOrchestratorID: this.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + } + async buildReferences(project: string): Promise { + this.ensureNotDisposed(); + const response = await this.client.apiRequest("buildReferences", { + buildOrchestratorID: this.id, + project, + }); + return response; + } + async clean(project?: string): Promise { + this.ensureNotDisposed(); + const response = await this.client.apiRequest("cleanBuild", { + buildOrchestratorID: this.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + } + async cleanReferences(project?: string): Promise { + this.ensureNotDisposed(); + const response = await this.client.apiRequest("cleanReferences", { + buildOrchestratorID: this.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + } + + isDisposed(): boolean { + return this.disposed; + } + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new Error("Build orchestrator is disposed"); + } + } +} + function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { const outputFiles = new Map(); for (const { fileName, ...outputFile } of response.outputFiles) { diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 4904d310b2687..42c3713c12aa4 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -36,7 +36,7 @@ export interface FileSystem { } /** The callback names supported by the Go server for virtual FS delegation. */ -export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; +export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile", "removeFile"] as const; export interface CreateFileSystemOptions { /** Complete directory listings. Full filesystems derive these from `files` when omitted. */ diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 25b7badfa1a5b..4dccbe902202c 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -27,6 +27,12 @@ export interface APIMethodInfo { createSnapshot: APIMethod; updateSnapshot: APIMethod; getCurrentLanguageServerSnapshot: APIMethod; + createBuildOrchestrator: APIMethod; + disposeBuildOrchestrator: APIMethod; + build: APIMethod; + buildReferences: APIMethod; + cleanBuild: APIMethod; + cleanReferences: APIMethod; createModuleResolver: APIMethod; releaseModuleResolver: APIMethod; resolveModuleName: APIMethod; @@ -262,6 +268,42 @@ export interface GetCurrentLanguageServerSnapshotParams { changes?: LanguageServerSnapshotChanges | undefined; } +export interface CreateBuildOrchestratorParams extends BuildOptions, CompilerOptions { + rootNames: readonly string[] | null; + cwd?: string | undefined; +} + +export interface CreateBuildOrchestratorResponse { + buildOrchestratorID: number; +} + +export interface DisposeBuildOrchestratorParams { + buildOrchestratorID: number; +} + +export interface BuildParams { + buildOrchestratorID: number; + project?: string | undefined; +} + +export interface BuildResponse { + status: number; + diagnostics?: DiagnosticResponse[] | undefined; + statistics: Statistics; +} + +export interface CleanBuildParams { + buildOrchestratorID: number; + project?: string | undefined; +} + +export interface CleanBuildResponse { + status: number; + diagnostics?: DiagnosticResponse[] | undefined; + statistics: Statistics; + filesDeleted?: string[] | undefined; +} + export interface CreateModuleResolverParams { compilerOptions: CompilerOptions; moduleResolutions?: ModuleResolutionSpec | undefined; @@ -294,6 +336,7 @@ export interface ParseCommandLineParams { export interface ConfigFileResponse { fileNames: string[]; options: CompilerOptions; + buildOptions?: BuildOptions | undefined; projectReferences?: ProjectReference[] | undefined; typeAcquisition?: TypeAcquisition | undefined; compileOnSave?: boolean | undefined; @@ -1042,10 +1085,16 @@ export interface ProfileResult { export interface BatchRequest { method: | "batchRequests" + | "build" + | "buildReferences" + | "cleanBuild" + | "cleanReferences" + | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" | "createSourceFile" | "createSourceFileFromFile" + | "disposeBuildOrchestrator" | "emit" | "emitToString" | "formatNodeForInsertion" @@ -1211,10 +1260,16 @@ export interface BatchRequest { export interface BatchResponse { method: | "batchRequests" + | "build" + | "buildReferences" + | "cleanBuild" + | "cleanReferences" + | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" | "createSourceFile" | "createSourceFileFromFile" + | "disposeBuildOrchestrator" | "emit" | "emitToString" | "formatNodeForInsertion" @@ -1482,6 +1537,16 @@ export interface SnapshotOperationResponse { export interface LanguageServerSnapshotChanges extends SnapshotRequestChangesParams { } +export interface BuildOptions { + dry?: boolean | undefined; + force?: boolean | undefined; + verbose?: boolean | undefined; + builders?: number | undefined; + stopBuildOnErrors?: boolean | undefined; + /** Internal fields */ + clean?: boolean | undefined; +} + /** CompilerOptions contains the compiler options exposed by the API. */ export interface CompilerOptions { allowJs?: boolean | undefined; @@ -1590,6 +1655,12 @@ export interface CompilerOptions { configFilePath?: string | undefined; } +export interface Statistics { + Projects: number; + ProjectsBuilt: number; + TimestampUpdates: number; +} + export interface ModuleResolutionSpec { fallback: "resolve" | "unresolved"; entries: ModuleResolutionEntry[]; diff --git a/packages/typescript/src/api/proto.ts b/packages/typescript/src/api/proto.ts index 9dd2aea1e9fca..f45bc1f2caedc 100644 --- a/packages/typescript/src/api/proto.ts +++ b/packages/typescript/src/api/proto.ts @@ -92,6 +92,10 @@ export interface CreateSnapshotParams extends CoreCreateSnapshotParams { openProject?: string | undefined; } +export interface CreateBuildOrchestratorParams { + rootNames: readonly string[] | null; +} + /** * Builds the wire request for createSnapshot, applying the deprecated `openProject` * compatibility shim: a single `openProject` is folded into `openProjects` and is diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 04f7f104eb410..dcd00e40ee58d 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -77,14 +77,18 @@ import { toPath, } from "../path.ts"; import type { + BuildResponse, + CleanBuildResponse, CompilerOptions, ConfiguredProjectId, + CreateBuildOrchestratorResponse, CreateProgramOptions as ProtocolCreateProgramOptions, CreateSnapshotParams as ProtocolCreateSnapshotParams, CreateSnapshotProgramParams as ProtocolCreateSnapshotProgramParams, CreateSnapshotResponse, CreateSourceFileOptions, Diagnostic, + DiagnosticResponse, DocumentIdentifier, DocumentPosition, EmitOutputResponse as ProtocolEmitOutputResponse, @@ -347,6 +351,7 @@ export class API implements FormatDiagnosticsHo private initialized: boolean = false; private initializing: void | undefined; private activeSnapshots: Map = new Map(); + private activeBuildOrchestrators: Set = new Set(); private activeSourceFileLeases: Map = new Map(); readonly printer: Printer; readonly internal: InternalAPI; @@ -465,6 +470,45 @@ export class API implements FormatDiagnosticsHo return "\n"; } + get createBuildOrchestrator(): { + (rootNames: readonly string[], buildOrchestratorOptions: BuildOrchestratorOptions): BuildOrchestrator; + gen(rootNames: readonly string[], buildOrchestratorOptions: BuildOrchestratorOptions): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createBuildOrchestrator", + function (rootNames: readonly string[], buildOrchestratorOptions: BuildOrchestratorOptions): BuildOrchestrator { + owner.ensureInitialized(); + const orchestratorResponse = owner.client.apiRequest("createBuildOrchestrator", { + ...buildOrchestratorOptions, + ...buildOrchestratorOptions.overrideCompilerOptions, + rootNames, + }); + + const orchestrator = new BuildOrchestrator(owner.client, orchestratorResponse, () => { + owner.activeBuildOrchestrators.delete(orchestrator); + }); + owner.activeBuildOrchestrators.add(orchestrator); + return orchestrator; + }, + function* (rootNames: readonly string[], buildOrchestratorOptions: BuildOrchestratorOptions): Generator { + yield* owner.ensureInitialized.gen(); + const orchestratorResponse = yield* apiRequest("createBuildOrchestrator", { + ...buildOrchestratorOptions, + ...buildOrchestratorOptions.overrideCompilerOptions, + rootNames, + }); + + const orchestrator = new BuildOrchestrator(owner.client, orchestratorResponse, () => { + owner.activeBuildOrchestrators.delete(orchestrator); + }); + owner.activeBuildOrchestrators.add(orchestrator); + return orchestrator; + }, + ); + } + get parseConfigFile(): { (file: DocumentIdentifier): ParsedCommandLine; gen(file: DocumentIdentifier): Generator; @@ -999,6 +1043,9 @@ export class API implements FormatDiagnosticsHo } finally { try { + for (const orchestrator of [...owner.activeBuildOrchestrators]) { + orchestrator.dispose(); + } for (const snapshot of [...owner.activeSnapshots.values()]) { snapshot.dispose(); } @@ -1017,6 +1064,9 @@ export class API implements FormatDiagnosticsHo } finally { try { + for (const orchestrator of [...owner.activeBuildOrchestrators]) { + yield* orchestrator.dispose.gen(); + } for (const snapshot of [...owner.activeSnapshots.values()]) { yield* snapshot.dispose.gen(); } @@ -3630,6 +3680,214 @@ export class Program implements FormatDiagnost } } +export interface BuildOrchestratorOptions { + cwd?: string | undefined; + dry?: boolean; + force?: boolean; + verbose?: boolean; + stopBuildOnErrors?: boolean; + overrideCompilerOptions?: OverrideCompilerOptions; +} + +export interface OverrideCompilerOptions { + incremental?: boolean; + assumeChangesOnlyAffectDirectDependencies?: boolean; + declaration?: boolean; + declarationMap?: boolean; + emitDeclarationOnly?: boolean; + sourceMap?: boolean; + inlineSourceMap?: boolean; + traceResolution?: boolean; +} + +export class BuildOrchestrator { + private client: Client; + private id: number; + private disposed = false; + private disposePromise: void | undefined; + private onDispose: () => void; + + constructor( + client: Client, + orchestratorResponse: CreateBuildOrchestratorResponse, + onDispose: () => void, + ) { + this.client = client; + this.id = orchestratorResponse.buildOrchestratorID; + this.onDispose = onDispose; + } + + [globalThis.Symbol.dispose](): void { + void this.dispose(); + } + get dispose(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "dispose", + function (): void { + return owner.disposePromise ??= owner.disposeWorker(); + }, + function* (): Generator { + return owner.disposePromise ??= yield* owner.disposeWorker.gen(); + }, + ); + } + + private get disposeWorker(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "disposeWorker", + function (): void { + if (owner.disposed) return; + owner.disposed = true; + try { + owner.client.apiRequest("disposeBuildOrchestrator", { + buildOrchestratorID: owner.id, + }); + } + finally { + owner.onDispose(); + } + }, + function* (): Generator { + if (owner.disposed) return; + owner.disposed = true; + try { + yield* apiRequest("disposeBuildOrchestrator", { + buildOrchestratorID: owner.id, + }); + } + finally { + owner.onDispose(); + } + }, + ); + } + + get build(): { + (project?: string): BuildResponse; + gen(project?: string): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "build", + function (project?: string): BuildResponse { + owner.ensureNotDisposed(); + const response = owner.client.apiRequest("build", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + function* (project?: string): Generator { + owner.ensureNotDisposed(); + const response = yield* apiRequest("build", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + ); + } + get buildReferences(): { + (project: string): BuildResponse; + gen(project: string): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "buildReferences", + function (project: string): BuildResponse { + owner.ensureNotDisposed(); + const response = owner.client.apiRequest("buildReferences", { + buildOrchestratorID: owner.id, + project, + }); + return response; + }, + function* (project: string): Generator { + owner.ensureNotDisposed(); + const response = yield* apiRequest("buildReferences", { + buildOrchestratorID: owner.id, + project, + }); + return response; + }, + ); + } + get clean(): { + (project?: string): CleanBuildResponse; + gen(project?: string): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "clean", + function (project?: string): CleanBuildResponse { + owner.ensureNotDisposed(); + const response = owner.client.apiRequest("cleanBuild", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + function* (project?: string): Generator { + owner.ensureNotDisposed(); + const response = yield* apiRequest("cleanBuild", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + ); + } + get cleanReferences(): { + (project?: string): CleanBuildResponse; + gen(project?: string): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "cleanReferences", + function (project?: string): CleanBuildResponse { + owner.ensureNotDisposed(); + const response = owner.client.apiRequest("cleanReferences", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + function* (project?: string): Generator { + owner.ensureNotDisposed(); + const response = yield* apiRequest("cleanReferences", { + buildOrchestratorID: owner.id, + ...(project !== undefined ? { project } : {}), + }); + return response; + }, + ); + } + + isDisposed(): boolean { + return this.disposed; + } + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new Error("Build orchestrator is disposed"); + } + } +} + function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { const outputFiles = new Map(); for (const { fileName, ...outputFile } of response.outputFiles) { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index a468d5c4286e2..3226d409e17a8 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -1244,6 +1244,393 @@ describe("API - batchContext", { concurrency }, () => { }); // @sync-skip-block-end +describe("BuildOrchestrator", () => { + const files = { + "/a/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/a/src/index.ts": `export const a = 1;`, + "/b/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/b/src/index.ts": `export const b = 2;`, + "/c/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + references: [{ path: "../a" }, { path: "../b" }], + }), + "/c/src/index.ts": `export const c = 3;`, + }; + + test("dispose is idempotent", async () => { + const { api: disposableApi } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const options = await api.parseCommandLine([]); + const orchestrator = await api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/", ...options }, + ); + + const firstDispose = orchestrator.dispose(); + const secondDispose = orchestrator.dispose(); + assert.strictEqual(firstDispose, secondDispose); + await firstDispose; // @sync: orchestrator.dispose(); + // Second dispose should not throw + await orchestrator.dispose(); + await assert.rejects(orchestrator.build(), /Build orchestrator is disposed/); // @sync: assert.throws(() => orchestrator.build(), /Build orchestrator is disposed/); + }); + + test("api.close disposes all build orchestrators", async () => { + const { api } = spawnAPIWithFS({ ...files }); + const options = await api.parseCommandLine([]); + const orchestrator1 = await api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/", ...options }, + ); + const orchestrator2 = await api.createBuildOrchestrator( + ["/b/tsconfig.json"], + { cwd: "/", ...options }, + ); + assert.ok(!orchestrator1.isDisposed()); + assert.ok(!orchestrator2.isDisposed()); + await api.close(); + assert.ok(orchestrator1.isDisposed()); + assert.ok(orchestrator2.isDisposed()); + await orchestrator1.dispose(); + await orchestrator2.dispose(); + }); + + test("builds the configured root projects", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const defaultOptions = { + cwd: "/", + dry: false, + force: false, + verbose: true, + stopBuildOnErrors: false, + overrideCompilerOptions: { + incremental: true, + assumeChangesOnlyAffectDirectDependencies: true, + declaration: false, + declarationMap: true, + emitDeclarationOnly: false, + sourceMap: false, + inlineSourceMap: false, + traceResolution: false, + }, + }; + const orchestrator = await api.createBuildOrchestrator( + ["/a/tsconfig.json", "/b/tsconfig.json"], + defaultOptions, + ); + + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + }); + + test("returns diagnostics in build response information", async () => { + const source = `export const value: string = 1;`; + const { api: disposableApi } = spawnAPIWithFS({ + "/a/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, noEmitOnError: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/a/src/index.ts": source, + }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/" }, + ); + + const response = await orchestrator.build(); + assert.equal(response.status, 1); + assert.equal(response.statistics.Projects, 1); + assert.equal(response.statistics.ProjectsBuilt, 1); + assert.deepEqual(response.diagnostics, [{ + fileName: "/a/src/index.ts", + pos: source.indexOf("value"), + end: source.indexOf("value") + "value".length, + startPosition: { line: 0, character: source.indexOf("value") }, + endPosition: { line: 0, character: source.indexOf("value") + "value".length }, + sourceLines: [{ line: 0, text: source }], + code: 2322, + category: 1, + text: "Type 'number' is not assignable to type 'string'.", + }]); + }); + + test("returns build response information after clean", async () => { + const { api: disposableApi } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const options = await api.parseCommandLine([]); + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/", ...options }, + ); + + assert.equal((await orchestrator.build()).status, 0); + + const cleanResp1 = await orchestrator.clean("/a/tsconfig.json"); + assert.equal(cleanResp1.status, 0); + assert.deepEqual(cleanResp1.diagnostics, undefined); + assert.deepEqual(cleanResp1.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanResp1.statistics.Projects, 1); + assert.equal(cleanResp1.statistics.ProjectsBuilt, 0); + const buildResp1 = await orchestrator.build("/a/tsconfig.json"); + assert.equal(buildResp1.status, 0); + assert.deepEqual(buildResp1.diagnostics, undefined); + assert.equal(buildResp1.statistics.Projects, 1); + assert.equal(buildResp1.statistics.ProjectsBuilt, 1); + + const cleanRefResp = await orchestrator.cleanReferences("/c/tsconfig.json"); + assert.equal(cleanRefResp.status, 0); + assert.deepEqual(cleanRefResp.diagnostics, undefined); + assert.deepEqual(cleanRefResp.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + "/b/dist/index.d.ts", + "/b/dist/index.js", + "/b/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanRefResp.statistics.Projects, 2); + assert.equal(cleanRefResp.statistics.ProjectsBuilt, 0); + const buildRefsResp = await orchestrator.buildReferences("/c/tsconfig.json"); + assert.equal(buildRefsResp.status, 0); + assert.deepEqual(buildRefsResp.diagnostics, undefined); + + const cleanResp2 = await orchestrator.clean(); + assert.equal(cleanResp2.status, 0); + assert.deepEqual(cleanResp2.diagnostics, undefined); + assert.deepEqual(cleanResp2.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + "/b/dist/index.d.ts", + "/b/dist/index.js", + "/b/tsconfig.tsbuildinfo", + "/c/dist/index.d.ts", + "/c/dist/index.js", + "/c/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanResp2.statistics.Projects, 3); + assert.equal(cleanResp2.statistics.ProjectsBuilt, 0); + const buildResp2 = await orchestrator.build(); + assert.equal(buildResp2.status, 0); + assert.deepEqual(buildResp2.diagnostics, undefined); + assert.equal(buildResp2.statistics.Projects, 3); + assert.equal(buildResp2.statistics.ProjectsBuilt, 3); + }); + + test("returns deleted files from a clean response", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + "/a/dist/index.d.ts": `export declare const a = 1;`, + "/a/dist/index.js": `export const a = 1;`, + }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/" }, + ); + + const response = await orchestrator.clean(); + assert.equal(response.status, 0); + assert.deepEqual(response.diagnostics, undefined); + assert.deepEqual(response.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + ]); + assert.equal(response.statistics.Projects, 1); + assert.equal(response.statistics.ProjectsBuilt, 0); + assert.equal(fs.readFile!("/a/dist/index.d.ts"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + }); + + test("rebuilds projects after multiple file system changes", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/a/tsconfig.json", "/b/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + + fs.writeFile!("/a/src/index.ts", `export const a = 10;`); + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + + fs.writeFile!("/b/src/index.ts", `export const b = 20;`); + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 20/); + + fs.writeFile!("/a/src/index.ts", `export const a = 100;`); + fs.writeFile!("/b/src/index.ts", `export const b = 200;`); + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 100/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 200/); + }); + + test("clean removes build outputs", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/c/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.equal((await orchestrator.clean()).status, 0); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + }); + + test("builds and cleans selected projects after file system changes", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.build("/a/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + fs.writeFile!("/a/src/index.ts", `export const a = 10;`); + assert.equal((await orchestrator.build("/b/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + fs.writeFile!("/b/src/index.ts", `export const b = 20;`); + assert.equal((await orchestrator.clean("/a/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); + + assert.equal((await orchestrator.clean("/b/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); + + fs.writeFile!("/b/dist/index.js", `export const b = 2`); + assert.equal((await orchestrator.clean("/b/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + }); + + test("builds only references of a selected project", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + }); + await using api = disposableApi; + const options = await api.parseCommandLine([]); + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.buildReferences("/c/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + }); + + test("cleans only references of a selected project", async () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + }); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.ok(fs.readFile!("/c/dist/index.js")); + + assert.equal((await orchestrator.cleanReferences("/c/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.ok(fs.readFile!("/c/dist/index.js")); + + assert.equal((await orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.equal((await orchestrator.cleanReferences()).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.ok(fs.readFile!("/c/dist/index.js")); + }); + + test("handles invalidated projects and cleans the last built configuration", async () => { + const writes: string[] = []; + const { api: disposableApi, fs } = spawnAPIWithFS( + { + ...files, + "/c/src/index.ts": `import { a } from "../../a/src/index"; export const c = a;`, + "/d/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/d/src/index.ts": `export const d = 4;`, + }, + path => writes.push(path), + ); + await using api = disposableApi; + const orchestrator = await api.createBuildOrchestrator( + ["/c/tsconfig.json", "/d/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/d/dist/index.js")!, /export const d = 4/); + + fs.writeFile!( + "/d/tsconfig.json", + JSON.stringify({ + compilerOptions: { composite: true, outDir: "lib", rootDir: "src" }, + files: ["src/index.ts"], + }), + ); + fs.writeFile!("/d/lib/index.js", `export const d = 40;`); + + assert.equal((await orchestrator.clean("/d/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.ok(fs.readFile!("/d/lib/index.js")); + + assert.equal((await orchestrator.build()).status, 0); + assert.match(fs.readFile!("/d/lib/index.js")!, /export const d = 4/); + assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + }); +}); + describe("Checker - getImmediateAliasedSymbol", { concurrency }, () => { test("resolves one level of alias indirection", async () => { await using api = spawnAPI({ @@ -8135,8 +8522,15 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: FileSystem; } { +function spawnAPIWithFS(files: Record = { ...defaultFiles }, onWrite?: (path: string) => void): { api: API; fs: FileSystem; } { const fs = createVirtualFileSystem(files); + if (onWrite) { + const writeFile = fs.writeFile!; + fs.writeFile = (path, content) => { + onWrite(path); + writeFile(path, content); + }; + } const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index d25bc6435e683..b0e38d61e9ed8 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -30,6 +30,7 @@ import { type AllAPIRequestGenerator, type AnyAPIRequestGenerator, type API, + type BuildOrchestrator, type ConditionalType, defer, type DeferredAPIRequestGenerator, @@ -352,6 +353,10 @@ function assertSnapshotsEquivalent(actual: Snapshot, expected: Snapshot, message assert.deepEqual(actual.operation.openedFiles?.map(result => result.project.id), expected.operation.openedFiles?.map(result => result.project.id), message); } +function assertBuildOrchestratorsEquivalent(actual: BuildOrchestrator, expected: BuildOrchestrator, message?: string): void { + assert.equal(actual.constructor, expected.constructor, message); +} + function assertSymbolMapsEquivalent(actual: ReadonlyMap, expected: ReadonlyMap, message?: string): void { assert.deepEqual([...actual.keys()], [...expected.keys()], message); for (const key of actual.keys()) { @@ -1563,6 +1568,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("API", "transpileDeclarationFromFile", api.transpileDeclarationFromFile, assertDeepEquivalent, "/src/index.ts"), parityCase("API", "createSnapshot", api.createSnapshot as GeneratorMethod<[params: { openProject: string; }], Snapshot>, assertSnapshotsEquivalent, { openProject: "/tsconfig.json" }), parityCase("API", "createProgram", api.createProgram, assertProgramsEquivalent, ["/src/index.ts"], { noLib: true }), + parityCase("API", "createBuildOrchestrator", api.createBuildOrchestrator, assertBuildOrchestratorsEquivalent, ["/tsconfig.json"], { cwd: "/" }), parityCase("API", "runWithTemporaryFileUpdate", api.runWithTemporaryFileUpdate, assertDeepEquivalent, snapshot, "/src/index.ts", parityFiles["/src/index.ts"].replace("123", '"fixed"'), (temporarySnapshot: Snapshot) => { temporaryProjects.push(temporarySnapshot.getProjects()[0].configFileName); }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index e5f8ec43ece02..aaa550e6ba456 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -1111,6 +1111,393 @@ declare module "augmentation" {}`, }); }); +describe("BuildOrchestrator", () => { + const files = { + "/a/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/a/src/index.ts": `export const a = 1;`, + "/b/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/b/src/index.ts": `export const b = 2;`, + "/c/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + references: [{ path: "../a" }, { path: "../b" }], + }), + "/c/src/index.ts": `export const c = 3;`, + }; + + test("dispose is idempotent", () => { + const { api: disposableApi } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const options = api.parseCommandLine([]); + const orchestrator = api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/", ...options }, + ); + + const firstDispose = orchestrator.dispose(); + const secondDispose = orchestrator.dispose(); + assert.strictEqual(firstDispose, secondDispose); + orchestrator.dispose(); + // Second dispose should not throw + orchestrator.dispose(); + assert.throws(() => orchestrator.build(), /Build orchestrator is disposed/); + }); + + test("api.close disposes all build orchestrators", () => { + const { api } = spawnAPIWithFS({ ...files }); + const options = api.parseCommandLine([]); + const orchestrator1 = api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/", ...options }, + ); + const orchestrator2 = api.createBuildOrchestrator( + ["/b/tsconfig.json"], + { cwd: "/", ...options }, + ); + assert.ok(!orchestrator1.isDisposed()); + assert.ok(!orchestrator2.isDisposed()); + api.close(); + assert.ok(orchestrator1.isDisposed()); + assert.ok(orchestrator2.isDisposed()); + orchestrator1.dispose(); + orchestrator2.dispose(); + }); + + test("builds the configured root projects", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const defaultOptions = { + cwd: "/", + dry: false, + force: false, + verbose: true, + stopBuildOnErrors: false, + overrideCompilerOptions: { + incremental: true, + assumeChangesOnlyAffectDirectDependencies: true, + declaration: false, + declarationMap: true, + emitDeclarationOnly: false, + sourceMap: false, + inlineSourceMap: false, + traceResolution: false, + }, + }; + const orchestrator = api.createBuildOrchestrator( + ["/a/tsconfig.json", "/b/tsconfig.json"], + defaultOptions, + ); + + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + }); + + test("returns diagnostics in build response information", () => { + const source = `export const value: string = 1;`; + const { api: disposableApi } = spawnAPIWithFS({ + "/a/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, noEmitOnError: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/a/src/index.ts": source, + }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/" }, + ); + + const response = orchestrator.build(); + assert.equal(response.status, 1); + assert.equal(response.statistics.Projects, 1); + assert.equal(response.statistics.ProjectsBuilt, 1); + assert.deepEqual(response.diagnostics, [{ + fileName: "/a/src/index.ts", + pos: source.indexOf("value"), + end: source.indexOf("value") + "value".length, + startPosition: { line: 0, character: source.indexOf("value") }, + endPosition: { line: 0, character: source.indexOf("value") + "value".length }, + sourceLines: [{ line: 0, text: source }], + code: 2322, + category: 1, + text: "Type 'number' is not assignable to type 'string'.", + }]); + }); + + test("returns build response information after clean", () => { + const { api: disposableApi } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const options = api.parseCommandLine([]); + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/", ...options }, + ); + + assert.equal((orchestrator.build()).status, 0); + + const cleanResp1 = orchestrator.clean("/a/tsconfig.json"); + assert.equal(cleanResp1.status, 0); + assert.deepEqual(cleanResp1.diagnostics, undefined); + assert.deepEqual(cleanResp1.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanResp1.statistics.Projects, 1); + assert.equal(cleanResp1.statistics.ProjectsBuilt, 0); + const buildResp1 = orchestrator.build("/a/tsconfig.json"); + assert.equal(buildResp1.status, 0); + assert.deepEqual(buildResp1.diagnostics, undefined); + assert.equal(buildResp1.statistics.Projects, 1); + assert.equal(buildResp1.statistics.ProjectsBuilt, 1); + + const cleanRefResp = orchestrator.cleanReferences("/c/tsconfig.json"); + assert.equal(cleanRefResp.status, 0); + assert.deepEqual(cleanRefResp.diagnostics, undefined); + assert.deepEqual(cleanRefResp.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + "/b/dist/index.d.ts", + "/b/dist/index.js", + "/b/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanRefResp.statistics.Projects, 2); + assert.equal(cleanRefResp.statistics.ProjectsBuilt, 0); + const buildRefsResp = orchestrator.buildReferences("/c/tsconfig.json"); + assert.equal(buildRefsResp.status, 0); + assert.deepEqual(buildRefsResp.diagnostics, undefined); + + const cleanResp2 = orchestrator.clean(); + assert.equal(cleanResp2.status, 0); + assert.deepEqual(cleanResp2.diagnostics, undefined); + assert.deepEqual(cleanResp2.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + "/a/tsconfig.tsbuildinfo", + "/b/dist/index.d.ts", + "/b/dist/index.js", + "/b/tsconfig.tsbuildinfo", + "/c/dist/index.d.ts", + "/c/dist/index.js", + "/c/tsconfig.tsbuildinfo", + ]); + assert.equal(cleanResp2.statistics.Projects, 3); + assert.equal(cleanResp2.statistics.ProjectsBuilt, 0); + const buildResp2 = orchestrator.build(); + assert.equal(buildResp2.status, 0); + assert.deepEqual(buildResp2.diagnostics, undefined); + assert.equal(buildResp2.statistics.Projects, 3); + assert.equal(buildResp2.statistics.ProjectsBuilt, 3); + }); + + test("returns deleted files from a clean response", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + "/a/dist/index.d.ts": `export declare const a = 1;`, + "/a/dist/index.js": `export const a = 1;`, + }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/a/tsconfig.json"], + { cwd: "/" }, + ); + + const response = orchestrator.clean(); + assert.equal(response.status, 0); + assert.deepEqual(response.diagnostics, undefined); + assert.deepEqual(response.filesDeleted!.sort(), [ + "/a/dist/index.d.ts", + "/a/dist/index.js", + ]); + assert.equal(response.statistics.Projects, 1); + assert.equal(response.statistics.ProjectsBuilt, 0); + assert.equal(fs.readFile!("/a/dist/index.d.ts"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + }); + + test("rebuilds projects after multiple file system changes", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/a/tsconfig.json", "/b/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + + fs.writeFile!("/a/src/index.ts", `export const a = 10;`); + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + + fs.writeFile!("/b/src/index.ts", `export const b = 20;`); + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 20/); + + fs.writeFile!("/a/src/index.ts", `export const a = 100;`); + fs.writeFile!("/b/src/index.ts", `export const b = 200;`); + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 100/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 200/); + }); + + test("clean removes build outputs", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/c/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.equal((orchestrator.clean()).status, 0); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + }); + + test("builds and cleans selected projects after file system changes", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ ...files }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.build("/a/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + fs.writeFile!("/a/src/index.ts", `export const a = 10;`); + assert.equal((orchestrator.build("/b/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + fs.writeFile!("/b/src/index.ts", `export const b = 20;`); + assert.equal((orchestrator.clean("/a/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); + + assert.equal((orchestrator.clean("/b/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); + + fs.writeFile!("/b/dist/index.js", `export const b = 2`); + assert.equal((orchestrator.clean("/b/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + }); + + test("builds only references of a selected project", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + }); + using api = disposableApi; + const options = api.parseCommandLine([]); + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.buildReferences("/c/tsconfig.json")).status, 0); + assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); + assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); + assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + }); + + test("cleans only references of a selected project", () => { + const { api: disposableApi, fs } = spawnAPIWithFS({ + ...files, + }); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.ok(fs.readFile!("/c/dist/index.js")); + + assert.equal((orchestrator.cleanReferences("/c/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.ok(fs.readFile!("/c/dist/index.js")); + + assert.equal((orchestrator.build()).status, 0); + assert.ok(fs.readFile!("/a/dist/index.js")); + assert.ok(fs.readFile!("/b/dist/index.js")); + assert.equal((orchestrator.cleanReferences()).status, 0); + assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.ok(fs.readFile!("/c/dist/index.js")); + }); + + test("handles invalidated projects and cleans the last built configuration", () => { + const writes: string[] = []; + const { api: disposableApi, fs } = spawnAPIWithFS( + { + ...files, + "/c/src/index.ts": `import { a } from "../../a/src/index"; export const c = a;`, + "/d/tsconfig.json": JSON.stringify({ + compilerOptions: { composite: true, outDir: "dist", rootDir: "src" }, + files: ["src/index.ts"], + }), + "/d/src/index.ts": `export const d = 4;`, + }, + path => writes.push(path), + ); + using api = disposableApi; + const orchestrator = api.createBuildOrchestrator( + ["/c/tsconfig.json", "/d/tsconfig.json"], + { cwd: "/" }, + ); + + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/d/dist/index.js")!, /export const d = 4/); + + fs.writeFile!( + "/d/tsconfig.json", + JSON.stringify({ + compilerOptions: { composite: true, outDir: "lib", rootDir: "src" }, + files: ["src/index.ts"], + }), + ); + fs.writeFile!("/d/lib/index.js", `export const d = 40;`); + + assert.equal((orchestrator.clean("/d/tsconfig.json")).status, 0); + assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.ok(fs.readFile!("/d/lib/index.js")); + + assert.equal((orchestrator.build()).status, 0); + assert.match(fs.readFile!("/d/lib/index.js")!, /export const d = 4/); + assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + }); +}); + describe("Checker - getImmediateAliasedSymbol", { concurrency }, () => { test("resolves one level of alias indirection", () => { using api = spawnAPI({ @@ -7950,8 +8337,15 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: FileSystem; } { +function spawnAPIWithFS(files: Record = { ...defaultFiles }, onWrite?: (path: string) => void): { api: API; fs: FileSystem; } { const fs = createVirtualFileSystem(files); + if (onWrite) { + const writeFile = fs.writeFile!; + fs.writeFile = (path, content) => { + onWrite(path); + writeFile(path, content); + }; + } const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index ad1a674bbdbd9..ee85dfc742005 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -34,6 +34,7 @@ const ( callbackGetAccessibleEntries = "getAccessibleEntries" callbackRealpath = "realpath" callbackWriteFile = "writeFile" + callbackRemoveFile = "removeFile" ) func isCallbackName(name string) bool { @@ -43,7 +44,8 @@ func isCallbackName(name string) bool { callbackDirectoryExists, callbackGetAccessibleEntries, callbackRealpath, - callbackWriteFile: + callbackWriteFile, + callbackRemoveFile: return true default: return false @@ -221,8 +223,12 @@ func (fs *callbackFS) AppendFile(path string, data string) error { return fs.base.AppendFile(path, data) } -// Remove implements vfs.FS - always delegates to base (no callback support). +// Remove implements vfs.FS. func (fs *callbackFS) Remove(path string) error { + if fs.isEnabled(callbackRemoveFile) { + _, err := fs.call(callbackRemoveFile, path) + return err + } return fs.base.Remove(path) } diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 91413d3132af7..818ce96e35816 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -5,6 +5,7 @@ package api import ( "errors" "fmt" + "sync/atomic" "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/ast" @@ -13,6 +14,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/diagnosticwriter" + "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/jsnum" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/locale" @@ -34,15 +36,22 @@ var ( type Method string type ( - SnapshotID uint64 - ModuleResolverID uint64 - SourceFileLeaseID uint64 - SymbolID uint64 - TypeID uint32 - SignatureID uint64 - NodeHandle string + SnapshotID uint64 + ModuleResolverID uint64 + SourceFileLeaseID uint64 + BuildOrchestratorID uint64 + SymbolID uint64 + TypeID uint32 + SignatureID uint64 + NodeHandle string ) +var nextBuildOrchestratorId atomic.Uint64 + +func NewBuildOrchestratorID() BuildOrchestratorID { + return BuildOrchestratorID(nextBuildOrchestratorId.Add(1)) +} + func SymbolHandle(symbol *ast.Symbol) SymbolID { return SymbolID(ast.GetSymbolId(symbol)) } @@ -59,12 +68,17 @@ const ( MethodRelease Method = "release" MethodReleaseSourceFile Method = "releaseSourceFile" - MethodBatchRequests Method = "batchRequests" - + MethodBatchRequests Method = "batchRequests" MethodInitialize Method = "initialize" MethodCreateSnapshot Method = "createSnapshot" MethodUpdateSnapshot Method = "updateSnapshot" MethodGetCurrentLanguageServerSnapshot Method = "getCurrentLanguageServerSnapshot" + MethodCreateBuildOrchestrator Method = "createBuildOrchestrator" + MethodDisposeBuildOrchestrator Method = "disposeBuildOrchestrator" + MethodBuild Method = "build" + MethodBuildReferences Method = "buildReferences" + MethodCleanBuild Method = "cleanBuild" + MethodCleanReferences Method = "cleanReferences" MethodCreateModuleResolver Method = "createModuleResolver" MethodReleaseModuleResolver Method = "releaseModuleResolver" MethodResolveModuleName Method = "resolveModuleName" @@ -560,6 +574,12 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodCreateSnapshot: unmarshallerFor[CreateSnapshotParams], MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], MethodGetCurrentLanguageServerSnapshot: unmarshallerFor[GetCurrentLanguageServerSnapshotParams], + MethodCreateBuildOrchestrator: unmarshallerFor[CreateBuildOrchestratorParams], + MethodDisposeBuildOrchestrator: unmarshallerFor[DisposeBuildOrchestratorParams], + MethodBuild: unmarshallerFor[BuildParams], + MethodBuildReferences: unmarshallerFor[BuildParams], + MethodCleanBuild: unmarshallerFor[CleanBuildParams], + MethodCleanReferences: unmarshallerFor[CleanBuildParams], MethodCreateModuleResolver: unmarshallerFor[CreateModuleResolverParams], MethodReleaseModuleResolver: unmarshallerFor[ReleaseModuleResolverParams], MethodResolveModuleName: unmarshallerFor[ResolveModuleNameParams], @@ -882,9 +902,55 @@ type ProfileResult struct { File string `json:"file"` } +type CreateBuildOrchestratorParams struct { + RootNames []string `json:"rootNames"` + Cwd string `json:"cwd,omitempty"` + // Only a subset of these options are exposed the API + *core.BuildOptions `json:"buildOptions,omitempty"` + *core.CompilerOptions `json:"compilerOptions,omitempty"` +} + +type CreateBuildOrchestratorResponse struct { + BuildOrchestratorID BuildOrchestratorID `json:"buildOrchestratorID"` +} + +type DisposeBuildOrchestratorParams struct { + BuildOrchestratorID BuildOrchestratorID `json:"buildOrchestratorID"` +} + +type BuildParams struct { + BuildOrchestratorID BuildOrchestratorID `json:"buildOrchestratorID"` + Project string `json:"project,omitempty"` +} + +type BuildResponse struct { + Status tsc.ExitStatus `json:"status"` + Diagnostics []*DiagnosticResponse `json:"diagnostics,omitempty"` + Statistics tsc.Statistics `json:"statistics"` +} + +type CleanBuildParams struct { + BuildOrchestratorID BuildOrchestratorID `json:"buildOrchestratorID"` + Project string `json:"project,omitempty"` +} + +type CleanBuildResponse struct { + Status tsc.ExitStatus `json:"status"` + Diagnostics []*DiagnosticResponse `json:"diagnostics,omitempty"` + Statistics tsc.Statistics `json:"statistics"` + FilesDeleted []string `json:"filesDeleted,omitempty"` +} + +type BuildOrchestrator struct { + Build func(project string) tsc.ExitStatus + BuildReferences func(project string) tsc.ExitStatus + CleanReferences func(project string) tsc.ExitStatus +} + type ConfigFileResponse struct { FileNames []string `json:"fileNames" nonnil:"true"` Options *core.CompilerOptions `json:"options" nonnil:"true"` + BuildOptions *core.BuildOptions `json:"buildOptions,omitempty"` ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition,omitempty"` CompileOnSave *bool `json:"compileOnSave,omitempty"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 19ff6f88c1c9e..d6f39a7df04f1 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -5,12 +5,14 @@ import ( "encoding/base64" "errors" "fmt" + "io" "runtime/debug" "slices" "strconv" "strings" "sync" "sync/atomic" + "time" "github.com/microsoft/TypeScript/tsc/internal/api/encoder" "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" @@ -21,6 +23,8 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" + "github.com/microsoft/TypeScript/tsc/internal/execute/build" + "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/format" "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/jsnum" @@ -435,6 +439,9 @@ type Session struct { languageServerUpdateMu sync.Mutex + buildOrchestrators map[BuildOrchestratorID]*build.Orchestrator + buildMu sync.Mutex + nextModuleResolverID atomic.Uint64 moduleResolvers map[ModuleResolverID]*moduleResolverRegistration moduleResolversMu sync.RWMutex @@ -491,6 +498,7 @@ func newSession(snapshotHost *project.SnapshotHost, withLocale func(context.Cont snapshotHost: snapshotHost, withLocale: withLocale, snapshots: make(map[SnapshotID]*snapshotData), + buildOrchestrators: make(map[BuildOrchestratorID]*build.Orchestrator), moduleResolvers: make(map[ModuleResolverID]*moduleResolverRegistration), programResolutionContexts: make(map[uint64]*programResolutionContext), sourceFileLeases: make(map[SourceFileLeaseID]*project.SourceFileLease), @@ -521,6 +529,13 @@ func (s *Session) FS() vfs.FS { return s.snapshotHost.FS() } +func (s *Session) DefaultLibraryPath() string { + if s.projectSession != nil { + return s.projectSession.DefaultLibraryPath() + } + return s.snapshotHost.DefaultLibraryPath() +} + func (s *Session) useCaseSensitiveFileNames() bool { return s.snapshotHost.FS().UseCaseSensitiveFileNames() } @@ -726,6 +741,18 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleParseJsonConfigFileContent(ctx, parsed.(*ParseJsonConfigFileContentParams)) case string(MethodParseConfigFile): return s.handleParseConfigFile(ctx, parsed.(*ParseConfigFileParams)) + case string(MethodCreateBuildOrchestrator): + return s.handleCreateBuildOrchestrator(ctx, parsed.(*CreateBuildOrchestratorParams)) + case string(MethodDisposeBuildOrchestrator): + return s.handleDisposeBuildOrchestrator(ctx, parsed.(*DisposeBuildOrchestratorParams)) + case string(MethodBuild): + return s.handleBuild(ctx, parsed.(*BuildParams)) + case string(MethodBuildReferences): + return s.handleBuildReferences(ctx, parsed.(*BuildParams)) + case string(MethodCleanBuild): + return s.handleCleanBuild(ctx, parsed.(*CleanBuildParams)) + case string(MethodCleanReferences): + return s.handleCleanReferences(ctx, parsed.(*CleanBuildParams)) case string(MethodCreateSourceFile): return s.handleCreateSourceFile(ctx, parsed.(*CreateSourceFileParams)) case string(MethodCreateSourceFileFromFile): @@ -1591,6 +1618,133 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return NewProjectResponse(proj), nil } +func (s *Session) handleCreateBuildOrchestrator(ctx context.Context, params *CreateBuildOrchestratorParams) (*CreateBuildOrchestratorResponse, error) { + buildSys := s.getBuildSys(params) + command := tsoptions.ParseBuildCommandLine(params.RootNames, buildSys) + createdOrchestratorResponse := &CreateBuildOrchestratorResponse{} + if params.CompilerOptions != nil { + command.CompilerOptions = params.CompilerOptions + } + if params.BuildOptions != nil { + command.BuildOptions = params.BuildOptions + } + orchestrator := build.NewOrchestrator(build.Options{ + Sys: buildSys, + Command: command, + }) + createdOrchestratorResponse.BuildOrchestratorID = NewBuildOrchestratorID() + s.buildMu.Lock() + s.buildOrchestrators[createdOrchestratorResponse.BuildOrchestratorID] = orchestrator + s.buildMu.Unlock() + return createdOrchestratorResponse, nil +} + +func (s *Session) handleDisposeBuildOrchestrator(ctx context.Context, params *DisposeBuildOrchestratorParams) (any, error) { + s.buildMu.Lock() + defer s.buildMu.Unlock() + if s.buildOrchestrators[params.BuildOrchestratorID] == nil { + return nil, errors.New("build orchestrator not found while disposing") + } + delete(s.buildOrchestrators, params.BuildOrchestratorID) + return true, nil +} + +func (s *Session) handleBuild(ctx context.Context, params *BuildParams) (*BuildResponse, error) { + s.buildMu.Lock() + defer s.buildMu.Unlock() + if s.buildOrchestrators[params.BuildOrchestratorID] == nil { + return nil, fmt.Errorf("build orchestrator not found while building %s", params.Project) + } + result := s.buildOrchestrators[params.BuildOrchestratorID].Build(ctx, params.Project) + + return &BuildResponse{ + Status: result.Result.Status, + Diagnostics: NewDiagnosticResponses(result.Errors), + Statistics: result.Statistics, + }, nil +} + +func (s *Session) handleBuildReferences(ctx context.Context, params *BuildParams) (*BuildResponse, error) { + s.buildMu.Lock() + defer s.buildMu.Unlock() + if s.buildOrchestrators[params.BuildOrchestratorID] == nil { + return nil, fmt.Errorf("build orchestrator not found for building references for %s", params.Project) + } + result := s.buildOrchestrators[params.BuildOrchestratorID].BuildReferences(ctx, params.Project) + + return &BuildResponse{ + Status: result.Result.Status, + Diagnostics: NewDiagnosticResponses(result.Errors), + Statistics: result.Statistics, + }, nil +} + +func (s *Session) handleCleanBuild(ctx context.Context, params *CleanBuildParams) (*CleanBuildResponse, error) { + s.buildMu.Lock() + defer s.buildMu.Unlock() + if s.buildOrchestrators[params.BuildOrchestratorID] == nil { + return nil, fmt.Errorf("build orchestrator not found while cleaning %s", params.Project) + } + result := s.buildOrchestrators[params.BuildOrchestratorID].Clean(params.Project) + return &CleanBuildResponse{ + Status: result.Result.Status, + Diagnostics: NewDiagnosticResponses(result.Errors), + Statistics: result.Statistics, + FilesDeleted: result.FilesToDelete, + }, nil +} + +func (s *Session) handleCleanReferences(ctx context.Context, params *CleanBuildParams) (*CleanBuildResponse, error) { + s.buildMu.Lock() + defer s.buildMu.Unlock() + if s.buildOrchestrators[params.BuildOrchestratorID] == nil { + return nil, fmt.Errorf("build orchestrator not found while cleaning references for %s", params.Project) + } + result := s.buildOrchestrators[params.BuildOrchestratorID].CleanReferences(params.Project) + return &CleanBuildResponse{ + Status: result.Result.Status, + Diagnostics: NewDiagnosticResponses(result.Errors), + Statistics: result.Statistics, + FilesDeleted: result.FilesToDelete, + }, nil +} + +func (s *Session) getBuildSys(params *CreateBuildOrchestratorParams) tsc.System { + currentDirectory := params.Cwd + if currentDirectory == "" { + currentDirectory = s.GetCurrentDirectory() + } + return &apiBuildSystem{ + session: s, + currentDirectory: currentDirectory, + start: time.Now(), + } +} + +// Wrapper for the API session for build orchestrator +type apiBuildSystem struct { + session *Session + currentDirectory string + start time.Time +} + +func (s *apiBuildSystem) Writer() io.Writer { return io.Discard } +func (s *apiBuildSystem) ErrorWriter() io.Writer { return io.Discard } +func (s *apiBuildSystem) FS() vfs.FS { return s.session.snapshotHost.FS() } +func (s *apiBuildSystem) DefaultLibraryPath() string { return s.session.DefaultLibraryPath() } +func (s *apiBuildSystem) GetCurrentDirectory() string { return s.currentDirectory } +func (s *apiBuildSystem) WriteOutputIsTTY() bool { return false } +func (s *apiBuildSystem) GetWidthOfTerminal() int { return 0 } +func (s *apiBuildSystem) GetEnvironmentVariable(name string) (string, bool) { + return "", false +} + +func (s *apiBuildSystem) Spawn(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return nil, errors.New("spawning processes is not supported by the API build orchestrator") +} +func (s *apiBuildSystem) Now() time.Time { return time.Now() } +func (s *apiBuildSystem) SinceStart() time.Duration { return time.Since(s.start) } + // handleParseCommandLine parses command-line arguments. func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseCommandLineParams) (*ConfigFileResponse, error) { return NewConfigFileResponse(tsoptions.ParseCommandLine(params.CommandLine, s.snapshotHost)), nil diff --git a/tsc/internal/execute/build/buildtask.go b/tsc/internal/execute/build/buildtask.go index f3cbb41e298c8..6ba60bf4f603a 100644 --- a/tsc/internal/execute/build/buildtask.go +++ b/tsc/internal/execute/build/buildtask.go @@ -116,16 +116,16 @@ func (t *BuildTask) reportDiagnostic(err *ast.Diagnostic) { t.result.diagnosticReporter(err) } -func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.Path, buildResult *orchestratorResult) { +func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.Path, buildResult *OrchestratorResult) { if len(t.errors) > 0 { - buildResult.errors = append(core.IfElse(buildResult.errors != nil, buildResult.errors, []*ast.Diagnostic{}), t.errors...) + buildResult.Errors = append(core.IfElse(buildResult.Errors != nil, buildResult.Errors, []*ast.Diagnostic{}), t.errors...) } fmt.Fprint(orchestrator.opts.Sys.Writer(), t.result.builder.String()) - if t.result.exitStatus > buildResult.result.Status { - buildResult.result.Status = t.result.exitStatus + if t.result.exitStatus > buildResult.Result.Status { + buildResult.Result.Status = t.result.exitStatus } if t.result.statistics != nil { - buildResult.statistics.Aggregate(t.result.statistics) + buildResult.Statistics.Aggregate(t.result.statistics) } // If we built the program, or updated timestamps, or had errors, we need to // delete files that are no longer needed @@ -134,11 +134,11 @@ func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.Path, b if orchestrator.opts.Testing != nil { orchestrator.opts.Testing.OnProgram(t.result.program) } - buildResult.statistics.ProjectsBuilt++ + buildResult.Statistics.ProjectsBuilt++ case buildKindPseudo: - buildResult.statistics.TimestampUpdates++ + buildResult.Statistics.TimestampUpdates++ } - buildResult.filesToDelete = append(buildResult.filesToDelete, t.result.filesToDelete...) + buildResult.FilesToDelete = append(buildResult.FilesToDelete, t.result.filesToDelete...) t.result = nil } diff --git a/tsc/internal/execute/build/clean_test.go b/tsc/internal/execute/build/clean_test.go new file mode 100644 index 0000000000000..b0f93ef28b1ff --- /dev/null +++ b/tsc/internal/execute/build/clean_test.go @@ -0,0 +1,123 @@ +package build_test + +import ( + "io" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/execute/build" + "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" + "github.com/microsoft/TypeScript/tsc/internal/execute/tsctests" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" + "gotest.tools/v3/assert" +) + +func TestClean(t *testing.T) { + t.Parallel() + + t.Run("cleans selected project and references", func(t *testing.T) { + t.Parallel() + sys := newCleanTestSystem() + orchestrator := newCleanTestOrchestrator(sys, "a", "c") + + result := orchestrator.Clean("a") + assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) + assert.Equal(t, result.Statistics.Projects, 2) + assert.Assert(t, !sys.FS().FileExists("/project/a/dist/index.js")) + assert.Assert(t, !sys.FS().FileExists("/project/b/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/c/dist/index.js")) + }) + + t.Run("dry run preserves outputs", func(t *testing.T) { + t.Parallel() + sys := newCleanTestSystem() + orchestrator := newCleanTestOrchestrator(sys, "--dry", "a") + + result := orchestrator.Clean("a") + assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) + assert.Equal(t, result.Statistics.Projects, 2) + assert.Assert(t, len(result.FilesToDelete) > 0) + assert.Assert(t, sys.FS().FileExists("/project/a/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/b/dist/index.js")) + }) + + t.Run("rejects project outside build", func(t *testing.T) { + t.Parallel() + sys := newCleanTestSystem() + orchestrator := newCleanTestOrchestrator(sys, "a") + + result := orchestrator.Clean("c") + assert.Equal(t, result.Result.Status, tsc.ExitStatusInvalidProject_OutputsSkipped) + assert.Assert(t, sys.FS().FileExists("/project/a/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/b/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/c/dist/index.js")) + }) + + t.Run("rejects circular build", func(t *testing.T) { + t.Parallel() + sys := newCleanTestSystem() + orchestrator := newCleanTestOrchestrator(sys, "cycle1") + + result := orchestrator.Clean("cycle1") + assert.Equal(t, result.Result.Status, tsc.ExitStatusProjectReferenceCycle_OutputsSkipped) + assert.Assert(t, len(result.Errors) > 0) + assert.Assert(t, sys.FS().FileExists("/project/cycle1/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/cycle2/dist/index.js")) + }) +} + +type cleanTestSystem struct { + *tsctests.TestSys + output strings.Builder +} + +func (s *cleanTestSystem) Writer() io.Writer { + return &s.output +} + +func (s *cleanTestSystem) ErrorWriter() io.Writer { + return &s.output +} + +func newCleanTestSystem() *cleanTestSystem { + return &cleanTestSystem{TestSys: tsctests.NewTscSystem(tsctests.FileMap{ + "/project/a/tsconfig.json": `{ + "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, + "files": ["index.ts"], + "references": [{ "path": "../b" }] + }`, + "/project/a/index.ts": "export const a = 1;", + "/project/a/dist/index.js": "export const a = 1;", + "/project/a/dist/index.d.ts": "export declare const a = 1;", + "/project/b/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, + "/project/b/index.ts": "export const b = 1;", + "/project/b/dist/index.js": "export const b = 1;", + "/project/b/dist/index.d.ts": "export declare const b = 1;", + "/project/c/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, + "/project/c/index.ts": "export const c = 1;", + "/project/c/dist/index.js": "export const c = 1;", + "/project/c/dist/index.d.ts": "export declare const c = 1;", + "/project/cycle1/tsconfig.json": `{ + "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, + "files": ["index.ts"], + "references": [{ "path": "../cycle2" }] + }`, + "/project/cycle1/index.ts": "export const cycle1 = 1;", + "/project/cycle1/dist/index.js": "export const cycle1 = 1;", + "/project/cycle2/tsconfig.json": `{ + "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, + "files": ["index.ts"], + "references": [{ "path": "../cycle1" }] + }`, + "/project/cycle2/index.ts": "export const cycle2 = 1;", + "/project/cycle2/dist/index.js": "export const cycle2 = 1;", + }, true, "/project")} +} + +func newCleanTestOrchestrator(sys tsc.System, args ...string) *build.Orchestrator { + command := tsoptions.ParseBuildCommandLine(append([]string{"--build"}, args...), sys) + return build.NewOrchestrator(build.Options{ + Sys: sys, + Command: command, + }) +} diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index a057f14b4dda4..4f3f36476868f 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -29,25 +29,28 @@ type Options struct { Command *tsoptions.ParsedBuildCommandLine Testing tsc.CommandLineTesting } +type OrchestratorResult struct { + Result tsc.CommandLineResult + Errors []*ast.Diagnostic + Statistics tsc.Statistics + FilesToDelete []string +} -type orchestratorResult struct { - result tsc.CommandLineResult - errors []*ast.Diagnostic - statistics tsc.Statistics - filesToDelete []string +func (b *OrchestratorResult) report(o *Orchestrator) { + b.reportWithFilesToDelete(o, true) } -func (b *orchestratorResult) report(o *Orchestrator) { +func (b *OrchestratorResult) reportWithFilesToDelete(o *Orchestrator, reportFilesToDelete bool) { if o.opts.Command.CompilerOptions.Watch.IsTrue() { - o.watchStatusReporter(ast.NewCompilerDiagnostic(core.IfElse(len(b.errors) == 1, diagnostics.Found_1_error_Watching_for_file_changes, diagnostics.Found_0_errors_Watching_for_file_changes), len(b.errors))) + o.watchStatusReporter(ast.NewCompilerDiagnostic(core.IfElse(len(b.Errors) == 1, diagnostics.Found_1_error_Watching_for_file_changes, diagnostics.Found_0_errors_Watching_for_file_changes), len(b.Errors))) } else { - o.errorSummaryReporter(b.errors) + o.errorSummaryReporter(b.Errors) } - if b.filesToDelete != nil { + if reportFilesToDelete && b.FilesToDelete != nil { o.createBuilderStatusReporter(nil)( ast.NewCompilerDiagnostic( diagnostics.A_non_dry_build_would_delete_the_following_files_Colon_0, - strings.Join(core.Map(b.filesToDelete, func(f string) string { + strings.Join(core.Map(b.FilesToDelete, func(f string) string { return "\r\n * " + f }), ""), ), @@ -56,8 +59,8 @@ func (b *orchestratorResult) report(o *Orchestrator) { if !o.opts.Command.CompilerOptions.Diagnostics.IsTrue() && !o.opts.Command.CompilerOptions.ExtendedDiagnostics.IsTrue() { return } - b.statistics.SetTotalTime(o.opts.Sys.SinceStart()) - b.statistics.Report(o.opts.Sys.Writer(), o.opts.Testing) + b.Statistics.SetTotalTime(o.opts.Sys.SinceStart()) + b.Statistics.Report(o.opts.Sys.Writer(), o.opts.Testing) } type Orchestrator struct { @@ -71,9 +74,10 @@ type Orchestrator struct { contentMapperHost contentmapper.Host // order generation result - tasks *collections.SyncMap[tspath.Path, *BuildTask] - order []string - errors []*ast.Diagnostic + tasks *collections.SyncMap[tspath.Path, *BuildTask] + order []string + errors []*ast.Diagnostic + graphGenerated bool errorSummaryReporter tsc.DiagnosticsReporter watchStatusReporter tsc.DiagnosticReporter @@ -279,9 +283,27 @@ func (o *Orchestrator) GenerateGraph(oldTasks *collections.SyncMap[tspath.Path, return true }) } + o.graphGenerated = true } +// tsc -b entrypoint func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { + return o.start(ctx, "", false /*onlyReferences*/).Result +} + +// orchestrator.Build() entrypoint for api +func (o *Orchestrator) Build(ctx context.Context, project string) *OrchestratorResult { + o.recheckAllProjects(project) + return o.start(ctx, project, false /*onlyReferences*/) +} + +// orchestrator.BuildReferences() entrypoint for api +func (o *Orchestrator) BuildReferences(ctx context.Context, project string) *OrchestratorResult { + o.recheckAllProjects(project) + return o.start(ctx, project, true /*onlyReferences*/) +} + +func (o *Orchestrator) start(ctx context.Context, project string, onlyReferences bool) *OrchestratorResult { o.contentMapperHost = tsc.NewContentMapperHost(ctx, o.opts.Sys, o.opts.Command.CompilerOptions) if o.contentMapperHost != nil && (!o.opts.Command.CompilerOptions.Watch.IsTrue() || o.opts.Testing == nil) { defer o.contentMapperHost.Close() @@ -289,15 +311,165 @@ func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { if o.opts.Command.CompilerOptions.Watch.IsTrue() { o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) } - o.GenerateGraph(nil) - result := o.buildOrClean() + if o.graphGenerated { + o.GenerateGraphReusingOldTasks() + } else { + o.GenerateGraph(nil) + } + order, ok := o.getBuildOrderFor(project) + if !ok { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusInvalidProject_OutputsSkipped}} + } + if onlyReferences && len(o.errors) == 0 { + if project == "" { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusInvalidProject_OutputsSkipped}} + } + order = order[:len(order)-1] + } + result := o.buildOrCleanOrder(order) if o.opts.Command.CompilerOptions.Watch.IsTrue() { o.Watch(ctx) - result.Watcher = o + result.Result.Watcher = o } return result } +func (o *Orchestrator) recheckAllProjects(project string) { + if !o.graphGenerated { + return + } + order, ok := o.getBuildOrderFor(project) + if !ok { + return + } + o.rangeTasks(order, func(path tspath.Path, task *BuildTask) { + task.resetStatus() + task.resetConfig(o, o.toPath(task.config)) + }) + o.host.mTimes = &collections.SyncMap[tspath.Path, time.Time]{} + o.resetCaches() +} + +// orchestrator.Clean() entrypoint for api +func (o *Orchestrator) Clean(project string) *OrchestratorResult { + return o.clean(project, false) +} + +// orchestrator.CleanReferences() entrypoint for api +func (o *Orchestrator) CleanReferences(project string) *OrchestratorResult { + return o.clean(project, true) +} + +func (o *Orchestrator) clean(project string, onlyReferences bool) *OrchestratorResult { + if !o.graphGenerated { + o.GenerateGraph(nil) + } + if len(o.errors) != 0 { + result := &OrchestratorResult{ + Result: tsc.CommandLineResult{Status: tsc.ExitStatusProjectReferenceCycle_OutputsSkipped}, + Errors: o.errors, + } + result.reportWithFilesToDelete(o, true) + return result + } + + order, ok := o.getBuildOrderFor(project) + if !ok { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusInvalidProject_OutputsSkipped}} + } + if onlyReferences { + order = order[:len(order)-1] + } + + result := &OrchestratorResult{} + result.Statistics.Projects = len(order) + dry := o.opts.Command.BuildOptions.Dry.IsTrue() + reportDiagnostic := o.createDiagnosticReporter(nil) + for _, config := range order { + task := o.getTask(o.toPath(config)) + if task.resolved == nil { + diagnostic := ast.NewCompilerDiagnostic(diagnostics.File_0_not_found, task.config) + reportDiagnostic(diagnostic) + result.Errors = append(result.Errors, diagnostic) + continue + } + + inputs := collections.NewSetFromItems(core.Map(task.resolved.FileNames(), o.toPath)...) + projectOutputs := task.resolved.GetOutputFileNames() + deleted := false + for outputFile := range projectOutputs { + deleted = o.cleanProjectOutput(outputFile, inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted + } + deleted = o.cleanProjectOutput(task.resolved.GetBuildInfoFileName(), inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted + if deleted { + task.resetStatus() + task.buildInfoEntryMu.Lock() + task.buildInfoEntry = nil + task.buildInfoEntryMu.Unlock() + } + } + + result.reportWithFilesToDelete(o, dry) + return result +} + +func (o *Orchestrator) getBuildOrderFor(project string) ([]string, bool) { + if project == "" { + return o.order, true + } + + config := core.ResolveConfigFileNameOfProjectReference( + tspath.ResolvePath(o.opts.Sys.GetCurrentDirectory(), project), + ) + target, ok := o.tasks.Load(o.toPath(config)) + if !ok { + return nil, false + } + + projects := collections.Set[tspath.Path]{} + var addProjectAndReferences func(*BuildTask) + addProjectAndReferences = func(task *BuildTask) { + path := o.toPath(task.config) + if projects.Has(path) { + return + } + projects.Add(path) + for _, upstream := range task.upStream { + addProjectAndReferences(upstream.task) + } + } + addProjectAndReferences(target) + + order := make([]string, 0, len(projects.M)) + for _, config := range o.order { + if projects.Has(o.toPath(config)) { + order = append(order, config) + } + } + return order, true +} + +func (o *Orchestrator) cleanProjectOutput( + outputFile string, + inputs *collections.Set[tspath.Path], + dry bool, + filesToDelete *[]string, + reportDiagnostic tsc.DiagnosticReporter, +) bool { + if outputFile == "" || inputs.Has(o.toPath(outputFile)) || !o.host.FS().FileExists(outputFile) { + return false + } + *filesToDelete = append(*filesToDelete, outputFile) + if dry { + return false + } + if err := o.host.FS().Remove(outputFile); err != nil { + reportDiagnostic(ast.NewCompilerDiagnostic(diagnostics.Failed_to_delete_file_0, outputFile)) + return false + } + return true +} + func (o *Orchestrator) Watch(ctx context.Context) { o.wm.Lock() @@ -695,46 +867,60 @@ func (o *Orchestrator) DoCycle() { } func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { + return o.buildOrCleanOrder(o.order).Result +} + +func (o *Orchestrator) buildOrCleanOrder(order []string) *OrchestratorResult { if !o.opts.Command.BuildOptions.Clean.IsTrue() && o.opts.Command.BuildOptions.Verbose.IsTrue() { o.createBuilderStatusReporter(nil)(ast.NewCompilerDiagnostic( diagnostics.Projects_in_this_build_Colon_0, - strings.Join(core.Map(o.Order(), func(p string) string { + strings.Join(core.Map(order, func(p string) string { return "\r\n * " + o.relativeFileName(p) }), ""), )) } - var buildResult orchestratorResult + var buildResult *OrchestratorResult = &OrchestratorResult{} if len(o.errors) == 0 { - buildResult.statistics.Projects = len(o.Order()) + // var prevReporter *BuildTask + // for _, config := range order { + // task := o.getTask(o.toPath(config)) + // task.prevReporter = prevReporter + // prevReporter = task + // } + buildResult.Statistics.Projects = len(order) // Builders pick up projects in scheduleOrder; results are reported in Order(), waiting for each project to finish reported := make(chan struct{}) go func() { defer close(reported) - for _, config := range o.order { + for _, config := range order { path := o.toPath(config) task := o.getTask(path) <-task.built - task.report(o, path, &buildResult) + task.report(o, path, buildResult) } }() - o.rangeTask(func(path tspath.Path, task *BuildTask) { + o.rangeTasks(order, func(path tspath.Path, task *BuildTask) { o.buildOrCleanProject(task, path) }) <-reported } else { // Circularity errors prevent any project from being built - buildResult.result.Status = tsc.ExitStatusProjectReferenceCycle_OutputsSkipped + buildResult.Result.Status = tsc.ExitStatusProjectReferenceCycle_OutputsSkipped reportDiagnostic := o.createDiagnosticReporter(nil) for _, err := range o.errors { reportDiagnostic(err) } - buildResult.errors = o.errors + buildResult.Errors = o.errors } buildResult.report(o) - return buildResult.result + return buildResult } func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { + o.rangeTasks(o.order, f) +} + +func (o *Orchestrator) rangeTasks(order []string, f func(path tspath.Path, task *BuildTask)) { numRoutines := 4 if o.opts.Command.CompilerOptions.SingleThreaded.IsTrue() { numRoutines = 1 @@ -745,10 +931,10 @@ func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { var currentTaskIndex atomic.Int64 getNextTask := func() (tspath.Path, *BuildTask, bool) { index := int(currentTaskIndex.Add(1) - 1) - if index >= len(o.scheduleOrder) { + if index >= len(order) { return "", nil, false } - config := o.scheduleOrder[index] + config := order[index] path := o.toPath(config) task := o.getTask(path) return path, task, true diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index ca4bce7cba6db..08585aa678b49 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -257,6 +257,10 @@ func (s *Session) GetCurrentDirectory() string { return s.options.CurrentDirectory } +func (s *Session) DefaultLibraryPath() string { + return s.options.DefaultLibraryPath +} + // Gets copy of current configuration func (s *Session) Config() lsutil.UserPreferences { s.userConfigRWMu.Lock() diff --git a/tsc/internal/project/snapshothost.go b/tsc/internal/project/snapshothost.go index 59f95187c7d26..6659133490421 100644 --- a/tsc/internal/project/snapshothost.go +++ b/tsc/internal/project/snapshothost.go @@ -177,6 +177,10 @@ func (s *SnapshotHost) GetCurrentDirectory() string { return s.options.CurrentDirectory } +func (s *SnapshotHost) DefaultLibraryPath() string { + return s.options.DefaultLibraryPath +} + func (s *SnapshotHost) Close() { if s.contentMapperHost != nil { _ = s.contentMapperHost.Close()