diff --git a/_packages/native-preview/src/api/async/api.ts b/_packages/native-preview/src/api/async/api.ts index 797171cc11f..2334f3e738b 100644 --- a/_packages/native-preview/src/api/async/api.ts +++ b/_packages/native-preview/src/api/async/api.ts @@ -50,8 +50,11 @@ import { import type { CompilerOptions, CompletionInfoResponse, + CreateProgramOptions, + CreateProgramResponse, DocumentIdentifier, DocumentPosition, + FileChanges, ImportAdderActionRequest, ImportSymbolActionRequest, IndexInfoResponse, @@ -132,7 +135,66 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, StructuredType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; +export type { + APIOptions, + AssertsIdentifierTypePredicate, + AssertsThisTypePredicate, + BigIntLiteralType, + BooleanLiteralType, + ClientSocketOptions, + ClientSpawnOptions, + CompilerOptions, + CompletionEntry, + CompletionInfo, + CompletionOptions, + ConditionalType, + CreateProgramOptions, + Diagnostic, + DocumentIdentifier, + DocumentPosition, + EmitOutput, + EmitOutputFile, + EmitResult, + FileChanges, + FreshableType, + GetImportEditsForSymbolsOptions, + IdentifierTypePredicate, + ImportAdderAction, + IndexedAccessType, + IndexInfo, + IndexType, + InterfaceType, + IntersectionType, + IntrinsicType, + JSDocTagInfo, + LiteralType, + LSPConnectionOptions, + NumberLiteralType, + ObjectType, + ParsedCommandLine, + ProjectReference, + ReadConfigFileResult, + RequestTiming, + SourceFileMetadata, + StringLiteralType, + StringMappingType, + StructuredType, + SubstitutionType, + TemplateLiteralType, + TextEdit, + ThisTypePredicate, + TimingAccumulators, + TimingInfo, + TupleType, + Type, + TypeAcquisition, + TypeParameter, + TypePredicate, + TypePredicateBase, + TypeReference, + UnionOrIntersectionType, + UnionType, +}; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -334,6 +396,44 @@ export class API { resetTimingInfo(): Promise { return this.client.resetTimingInfo(); } + + /** + * Creates a program from the current filesystem state, or derives one from + * oldProgram after applying fileChanges. fileChanges requires oldProgram. + */ + async createProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: FileChanges, + ): Promise { + await this.ensureInitialized(); + + if (fileChanges && !oldProgram) { + throw new Error("fileChanges requires an oldProgram"); + } + + const data = await this.client.apiRequest("createProgram", { + rootFiles, + createProgramOptions, + oldProgram: oldProgram ? { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } : undefined, + fileChanges, + }); + const snapshot = new Snapshot( + { snapshot: data.snapshot, projects: [data.project] }, + this.client, + this.sourceFileCache, + this.toPath!, + () => { + this.activeSnapshots.delete(snapshot); + this.sourceFileCache.releaseSnapshot(snapshot.id); + }, + ); + const program = snapshot.getProjects()[0].program; + program.setOwnedSnapshot(snapshot); + this.activeSnapshots.add(snapshot); + return program; + } } export class InternalAPI { @@ -891,13 +991,15 @@ export class LanguageService { } export class Program { - private snapshotId: number; - private project: Project; - private client: Client; - private sourceFileCache: SourceFileCache; - private toPath: (fileName: string) => Path; - private decoder = new Wtf8Decoder(); - private sourceFileMetadataCache = new Map>(); + /** @internal */ + readonly snapshotId: number; + private readonly project: Project; + private readonly client: Client; + private readonly sourceFileCache: SourceFileCache; + private readonly toPath: (fileName: string) => Path; + private readonly decoder = new Wtf8Decoder(); + private readonly sourceFileMetadataCache = new Map>(); + private ownedSnapshot: Snapshot | undefined; constructor( snapshotId: number, @@ -913,6 +1015,21 @@ export class Program { this.toPath = toPath; } + /** @internal */ + setOwnedSnapshot(snapshot: Snapshot): void { + this.ownedSnapshot = snapshot; + } + + [globalThis.Symbol.dispose](): void { + this.dispose(); + } + + async dispose(): Promise { + const snapshot = this.ownedSnapshot; + this.ownedSnapshot = undefined; + await snapshot?.dispose(); + } + getCompilerOptions(): CompilerOptions { return this.project.compilerOptions; } @@ -1200,6 +1317,10 @@ export class Program { }); return toEmitOutput(response); } + + getProject(): Project { + return this.project; + } } function toEmitOutput(response: EmitOutputResponse): EmitOutput { diff --git a/_packages/native-preview/src/api/proto.ts b/_packages/native-preview/src/api/proto.ts index 1624f0e14d9..f37f9cf74cf 100644 --- a/_packages/native-preview/src/api/proto.ts +++ b/_packages/native-preview/src/api/proto.ts @@ -124,6 +124,12 @@ export interface Diagnostic { readonly relatedInformation?: readonly Diagnostic[] | undefined; } +export interface CreateProgramOptions { + compilerOptions: CompilerOptions; + projectReferences?: readonly ProjectReference[]; + configFileParsingDiagnostics?: readonly Diagnostic[]; +} + export interface ParsedCommandLine { options: CompilerOptions; fileNames: string[]; @@ -251,6 +257,13 @@ export interface UpdateSnapshotResponse { changes?: SnapshotChanges; } +export interface CreateProgramResponse { + /** Handle for the snapshot that owns the program. */ + snapshot: number; + /** The synthetic project containing the program. */ + project: ProjectResponse; +} + export interface ProjectResponse { id: Path; configFileName: string; diff --git a/_packages/native-preview/src/api/sync/api.ts b/_packages/native-preview/src/api/sync/api.ts index caaec748b1d..543b0c5dfc3 100644 --- a/_packages/native-preview/src/api/sync/api.ts +++ b/_packages/native-preview/src/api/sync/api.ts @@ -58,8 +58,11 @@ import { import type { CompilerOptions, CompletionInfoResponse, + CreateProgramOptions, + CreateProgramResponse, DocumentIdentifier, DocumentPosition, + FileChanges, ImportAdderActionRequest, ImportSymbolActionRequest, IndexInfoResponse, @@ -140,7 +143,66 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, StructuredType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; +export type { + APIOptions, + AssertsIdentifierTypePredicate, + AssertsThisTypePredicate, + BigIntLiteralType, + BooleanLiteralType, + ClientSocketOptions, + ClientSpawnOptions, + CompilerOptions, + CompletionEntry, + CompletionInfo, + CompletionOptions, + ConditionalType, + CreateProgramOptions, + Diagnostic, + DocumentIdentifier, + DocumentPosition, + EmitOutput, + EmitOutputFile, + EmitResult, + FileChanges, + FreshableType, + GetImportEditsForSymbolsOptions, + IdentifierTypePredicate, + ImportAdderAction, + IndexedAccessType, + IndexInfo, + IndexType, + InterfaceType, + IntersectionType, + IntrinsicType, + JSDocTagInfo, + LiteralType, + LSPConnectionOptions, + NumberLiteralType, + ObjectType, + ParsedCommandLine, + ProjectReference, + ReadConfigFileResult, + RequestTiming, + SourceFileMetadata, + StringLiteralType, + StringMappingType, + StructuredType, + SubstitutionType, + TemplateLiteralType, + TextEdit, + ThisTypePredicate, + TimingAccumulators, + TimingInfo, + TupleType, + Type, + TypeAcquisition, + TypeParameter, + TypePredicate, + TypePredicateBase, + TypeReference, + UnionOrIntersectionType, + UnionType, +}; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -342,6 +404,44 @@ export class API { resetTimingInfo(): void { return this.client.resetTimingInfo(); } + + /** + * Creates a program from the current filesystem state, or derives one from + * oldProgram after applying fileChanges. fileChanges requires oldProgram. + */ + createProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: FileChanges, + ): Program { + this.ensureInitialized(); + + if (fileChanges && !oldProgram) { + throw new Error("fileChanges requires an oldProgram"); + } + + const data = this.client.apiRequest("createProgram", { + rootFiles, + createProgramOptions, + oldProgram: oldProgram ? { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } : undefined, + fileChanges, + }); + const snapshot = new Snapshot( + { snapshot: data.snapshot, projects: [data.project] }, + this.client, + this.sourceFileCache, + this.toPath!, + () => { + this.activeSnapshots.delete(snapshot); + this.sourceFileCache.releaseSnapshot(snapshot.id); + }, + ); + const program = snapshot.getProjects()[0].program; + program.setOwnedSnapshot(snapshot); + this.activeSnapshots.add(snapshot); + return program; + } } export class InternalAPI { @@ -899,13 +999,15 @@ export class LanguageService { } export class Program { - private snapshotId: number; - private project: Project; - private client: Client; - private sourceFileCache: SourceFileCache; - private toPath: (fileName: string) => Path; - private decoder = new Wtf8Decoder(); - private sourceFileMetadataCache = new Map(); + /** @internal */ + readonly snapshotId: number; + private readonly project: Project; + private readonly client: Client; + private readonly sourceFileCache: SourceFileCache; + private readonly toPath: (fileName: string) => Path; + private readonly decoder = new Wtf8Decoder(); + private readonly sourceFileMetadataCache = new Map(); + private ownedSnapshot: Snapshot | undefined; constructor( snapshotId: number, @@ -921,6 +1023,21 @@ export class Program { this.toPath = toPath; } + /** @internal */ + setOwnedSnapshot(snapshot: Snapshot): void { + this.ownedSnapshot = snapshot; + } + + [globalThis.Symbol.dispose](): void { + this.dispose(); + } + + dispose(): void { + const snapshot = this.ownedSnapshot; + this.ownedSnapshot = undefined; + snapshot?.dispose(); + } + getCompilerOptions(): CompilerOptions { return this.project.compilerOptions; } @@ -1208,6 +1325,10 @@ export class Program { }); return toEmitOutput(response); } + + getProject(): Project { + return this.project; + } } function toEmitOutput(response: EmitOutputResponse): EmitOutput { diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index 2720784a624..16f2b510c33 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -296,6 +296,259 @@ describe("API", () => { } }); + test("createProgram", async () => { + const api = spawnAPI({ + "/src/index.ts": `export const value: string = 1;`, + }); + try { + // Basic creation exposes the requested options, roots, and diagnostics. + const program = await api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true, strict: true } }); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(await program.getSourceFileNames(), ["/src/index.ts"]); + assert.equal((await program.getSemanticDiagnostics("/src/index.ts")).length, 1); + + // Program disposal releases its private backing snapshot. + await program.dispose(); + await assert.rejects(program.getSourceFileNames(), /snapshot .* not found/); // @sync: assert.throws(() => program.getSourceFileNames(), /snapshot .* not found/); + } + finally { + await api.close(); + } + }); + + test("createProgram ignores an on-disk tsconfig", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: false, strict: false }, + files: ["src/from-config.ts"], + }), + "/src/index.ts": `export const explicitRoot = 1;`, + "/src/from-config.ts": `export const configRoot = 1;`, + }); + try { + // createProgram is defined entirely by its arguments; it must not + // discover the nearby tsconfig or inherit its roots/options. + const program = await api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true, strict: true } }, + ); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(await program.getSourceFileNames(), ["/src/index.ts"]); + assert.deepEqual(await program.getConfigFileNames(), []); + assert.equal(await program.getSourceFile("/src/from-config.ts"), undefined); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram includes project references", async () => { + const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json" }; + const api = spawnAPI({ + "/src/index.ts": `export const value = 1;`, + "/lib/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true, noLib: true }, files: ["index.ts"] }), + "/lib/index.ts": `export const lib = 1;`, + }); + try { + // createProgram has no root config file, but its synthetic command line + // should still carry project references through the server. + const program = await api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true }, projectReferences: [reference] }, + ); + assert.deepEqual(program.getProject().parsedCommandLine.projectReferences, [{ ...reference, circular: false }]); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram includes config file parsing diagnostics", async () => { + const diagnostic = { + pos: 0, + end: 0, + code: 9001, + category: DiagnosticCategory.Error, + text: "Synthetic config parsing error.", + }; + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + // createProgram has no parsed tsconfig of its own, so callers may + // attach diagnostics produced while constructing its options. + const program = await api.createProgram( + ["/src/index.ts"], + { + compilerOptions: { noLib: true }, + configFileParsingDiagnostics: [diagnostic], + }, + ); + + assert.deepEqual(await program.getConfigFileParsingDiagnostics(), [diagnostic]); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates roots when given an old program", async () => { + const options = { compilerOptions: { noLib: true } }; + const api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 1;`, + "/src/c.ts": `export const c = 1;`, + }); + try { + const oldProgram = await api.createProgram(["/src/a.ts", "/src/b.ts"], options); + + // The root list is part of each createProgram request, so deriving from + // oldProgram must remove b.ts and add c.ts rather than retaining old roots. + const newProgram = await api.createProgram(["/src/a.ts", "/src/c.ts"], options, oldProgram); + assert.deepEqual(await newProgram.getSourceFileNames(), ["/src/a.ts", "/src/c.ts"]); + + // Programs own isolated snapshots; changing roots for the new program + // must not alter the old program's source-file set. + assert.deepEqual(await oldProgram.getSourceFileNames(), ["/src/a.ts", "/src/b.ts"]); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram discovers imported non-root dependencies", async () => { + const api = spawnAPI({ + "/src/main.ts": `import { dependency } from "./dependency"; export const value = dependency;`, + "/src/dependency.ts": `export const dependency = 1;`, + }); + try { + // Only main.ts is a root, but module resolution should still add its + // imported dependency to the program's complete source-file set. + const program = await api.createProgram(["/src/main.ts"], { compilerOptions: { noLib: true } }); + assert.deepEqual([...await program.getSourceFileNames()].sort(), ["/src/dependency.ts", "/src/main.ts"]); + + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates an old program with file changes", async () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + // Start with an erroneous program, then update exactly one named file. + const oldProgram = await api.createProgram([fileName], options); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + [fileName], + options, + oldProgram, + { changed: [fileName] }, + ); + + // The new program sees the disk edit while the old snapshot remains immutable. + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates an old program with invalidateAll", async () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + // invalidateAll reloads inherited file state without naming individual changed files. + const oldProgram = await api.createProgram([fileName], options); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + [fileName], + options, + oldProgram, + { invalidateAll: true }, + ); + + // Full invalidation updates only the new program; the old program keeps its original diagnostics. + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram accepts a regular project program as the old program", async () => { + const fileName = "/src/index.ts"; + const { api, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, strict: true } }), + [fileName]: `export const value: string = 1;`, + }); + try { + // A Program from a regular configured-project snapshot is also a valid reuse base. + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((await project.program.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + project.parsedCommandLine.fileNames, + { + compilerOptions: project.parsedCommandLine.options, + ...(project.parsedCommandLine.projectReferences + ? { projectReferences: project.parsedCommandLine.projectReferences } + : {}), + }, + project.program, + { changed: [fileName] }, + ); + + // The derived synthetic program updates independently of the configured base program. + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await project.program.getSemanticDiagnostics(fileName)).length, 1); + await newProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram rejects file changes without an old program", async () => { + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + // A selective summary has no snapshot state to update unless oldProgram is supplied. + const createWithChanges = () => api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true } }, undefined, { changed: ["/src/index.ts"] }); + await assert.rejects(createWithChanges, /fileChanges requires an oldProgram/); // @sync: assert.throws(createWithChanges, /fileChanges requires an oldProgram/); + } + finally { + await api.close(); + } + }); + test("parseConfigFile", async () => { const api = spawnAPI(); try { diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index 5a7c27b1c82..56b2ca02cda 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -304,6 +304,259 @@ describe("API", () => { } }); + test("createProgram", () => { + const api = spawnAPI({ + "/src/index.ts": `export const value: string = 1;`, + }); + try { + // Basic creation exposes the requested options, roots, and diagnostics. + const program = api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true, strict: true } }); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(program.getSourceFileNames(), ["/src/index.ts"]); + assert.equal((program.getSemanticDiagnostics("/src/index.ts")).length, 1); + + // Program disposal releases its private backing snapshot. + program.dispose(); + assert.throws(() => program.getSourceFileNames(), /snapshot .* not found/); + } + finally { + api.close(); + } + }); + + test("createProgram ignores an on-disk tsconfig", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: false, strict: false }, + files: ["src/from-config.ts"], + }), + "/src/index.ts": `export const explicitRoot = 1;`, + "/src/from-config.ts": `export const configRoot = 1;`, + }); + try { + // createProgram is defined entirely by its arguments; it must not + // discover the nearby tsconfig or inherit its roots/options. + const program = api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true, strict: true } }, + ); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(program.getSourceFileNames(), ["/src/index.ts"]); + assert.deepEqual(program.getConfigFileNames(), []); + assert.equal(program.getSourceFile("/src/from-config.ts"), undefined); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram includes project references", () => { + const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json" }; + const api = spawnAPI({ + "/src/index.ts": `export const value = 1;`, + "/lib/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true, noLib: true }, files: ["index.ts"] }), + "/lib/index.ts": `export const lib = 1;`, + }); + try { + // createProgram has no root config file, but its synthetic command line + // should still carry project references through the server. + const program = api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true }, projectReferences: [reference] }, + ); + assert.deepEqual(program.getProject().parsedCommandLine.projectReferences, [{ ...reference, circular: false }]); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram includes config file parsing diagnostics", () => { + const diagnostic = { + pos: 0, + end: 0, + code: 9001, + category: DiagnosticCategory.Error, + text: "Synthetic config parsing error.", + }; + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + // createProgram has no parsed tsconfig of its own, so callers may + // attach diagnostics produced while constructing its options. + const program = api.createProgram( + ["/src/index.ts"], + { + compilerOptions: { noLib: true }, + configFileParsingDiagnostics: [diagnostic], + }, + ); + + assert.deepEqual(program.getConfigFileParsingDiagnostics(), [diagnostic]); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates roots when given an old program", () => { + const options = { compilerOptions: { noLib: true } }; + const api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 1;`, + "/src/c.ts": `export const c = 1;`, + }); + try { + const oldProgram = api.createProgram(["/src/a.ts", "/src/b.ts"], options); + + // The root list is part of each createProgram request, so deriving from + // oldProgram must remove b.ts and add c.ts rather than retaining old roots. + const newProgram = api.createProgram(["/src/a.ts", "/src/c.ts"], options, oldProgram); + assert.deepEqual(newProgram.getSourceFileNames(), ["/src/a.ts", "/src/c.ts"]); + + // Programs own isolated snapshots; changing roots for the new program + // must not alter the old program's source-file set. + assert.deepEqual(oldProgram.getSourceFileNames(), ["/src/a.ts", "/src/b.ts"]); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram discovers imported non-root dependencies", () => { + const api = spawnAPI({ + "/src/main.ts": `import { dependency } from "./dependency"; export const value = dependency;`, + "/src/dependency.ts": `export const dependency = 1;`, + }); + try { + // Only main.ts is a root, but module resolution should still add its + // imported dependency to the program's complete source-file set. + const program = api.createProgram(["/src/main.ts"], { compilerOptions: { noLib: true } }); + assert.deepEqual([...program.getSourceFileNames()].sort(), ["/src/dependency.ts", "/src/main.ts"]); + + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates an old program with file changes", () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + // Start with an erroneous program, then update exactly one named file. + const oldProgram = api.createProgram([fileName], options); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + [fileName], + options, + oldProgram, + { changed: [fileName] }, + ); + + // The new program sees the disk edit while the old snapshot remains immutable. + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates an old program with invalidateAll", () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + // invalidateAll reloads inherited file state without naming individual changed files. + const oldProgram = api.createProgram([fileName], options); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + [fileName], + options, + oldProgram, + { invalidateAll: true }, + ); + + // Full invalidation updates only the new program; the old program keeps its original diagnostics. + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram accepts a regular project program as the old program", () => { + const fileName = "/src/index.ts"; + const { api, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, strict: true } }), + [fileName]: `export const value: string = 1;`, + }); + try { + // A Program from a regular configured-project snapshot is also a valid reuse base. + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((project.program.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + project.parsedCommandLine.fileNames, + { + compilerOptions: project.parsedCommandLine.options, + ...(project.parsedCommandLine.projectReferences + ? { projectReferences: project.parsedCommandLine.projectReferences } + : {}), + }, + project.program, + { changed: [fileName] }, + ); + + // The derived synthetic program updates independently of the configured base program. + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((project.program.getSemanticDiagnostics(fileName)).length, 1); + newProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram rejects file changes without an old program", () => { + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + // A selective summary has no snapshot state to update unless oldProgram is supplied. + const createWithChanges = () => api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true } }, undefined, { changed: ["/src/index.ts"] }); + assert.throws(createWithChanges, /fileChanges requires an oldProgram/); + } + finally { + api.close(); + } + }); + test("parseConfigFile", () => { const api = spawnAPI(); try { diff --git a/internal/api/proto.go b/internal/api/proto.go index c7a45330b7a..56e925881bd 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -73,6 +73,7 @@ const ( MethodInitialize Method = "initialize" MethodUpdateSnapshot Method = "updateSnapshot" MethodUpdateTemporarySnapshot Method = "updateTemporarySnapshot" + MethodCreateProgram Method = "createProgram" MethodParseCommandLine Method = "parseCommandLine" MethodReadConfigFile Method = "readConfigFile" MethodParseJsonConfigFile Method = "parseJsonConfigFileContent" @@ -360,11 +361,34 @@ type UpdateSnapshotParams struct { // snapshot that overrides a single file's content. type UpdateTemporarySnapshotParams struct { // Snapshot is the current client snapshot on which to layer the temporary update. - Snapshot SnapshotID `json:"snapshot"` + Snapshot SnapshotID `json:"snapshot,omitempty"` // File identifies the file whose content is temporarily overridden. File DocumentIdentifier `json:"file"` // NewText is the temporary content for the file. - NewText string `json:"newText"` + NewText string `json:"newText,omitempty"` +} + +type CreateProgramParams struct { + RootFiles []DocumentIdentifier `json:"rootFiles"` + CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` + OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` + FileChanges *APIFileChanges `json:"fileChanges,omitempty"` +} + +type CreateProgramOptions struct { + CompilerOptions core.CompilerOptions `json:"compilerOptions"` + ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` + ConfigFileParsingDiagnostics []*DiagnosticResponse `json:"configFileParsingDiagnostics,omitempty"` +} + +type CreateProgramOldProgramParams struct { + Snapshot SnapshotID `json:"snapshot,omitempty"` + Project ProjectID `json:"project,omitempty"` +} + +type CreateProgramResponse struct { + Snapshot SnapshotID `json:"snapshot"` + Project *ProjectResponse `json:"project"` } // ProjectFileChanges describes what source files changed within a single project. @@ -403,6 +427,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodInitialize: noParams, MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], MethodUpdateTemporarySnapshot: unmarshallerFor[UpdateTemporarySnapshotParams], + MethodCreateProgram: unmarshallerFor[CreateProgramParams], MethodParseCommandLine: unmarshallerFor[ParseCommandLineParams], MethodReadConfigFile: unmarshallerFor[ReadConfigFileParams], MethodParseJsonConfigFile: unmarshallerFor[ParseJsonConfigFileContentParams], @@ -1467,6 +1492,20 @@ func NewDiagnosticResponse(d *ast.Diagnostic) *DiagnosticResponse { return resp } +func (d *DiagnosticResponse) ToDiagnostic() *ast.Diagnostic { + return ast.NewDiagnosticFromText( + nil, + core.NewTextRange(d.Pos, d.End), + d.Code, + d.Category, + d.Text, + core.Map(d.MessageChain, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + core.Map(d.RelatedInformation, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + d.ReportsUnnecessary, + d.ReportsDeprecated, + ) +} + // NewDiagnosticResponses converts a slice of ast.Diagnostics to DiagnosticResponses. func NewDiagnosticResponses(diags []*ast.Diagnostic) []*DiagnosticResponse { if len(diags) == 0 { diff --git a/internal/api/session.go b/internal/api/session.go index 1a8d8b4f22d..e733da7ae22 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -612,6 +612,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleReadConfigFile(ctx, parsed.(*ReadConfigFileParams)) case string(MethodParseJsonConfigFile): return s.handleParseJsonConfigFileContent(ctx, parsed.(*ParseJsonConfigFileContentParams)) + case string(MethodCreateProgram): + return s.handleCreateProgram(ctx, parsed.(*CreateProgramParams)) case string(MethodParseConfigFile): return s.handleParseConfigFile(ctx, parsed.(*ParseConfigFileParams)) case string(MethodTranspileModule): @@ -1125,6 +1127,74 @@ func (s *Session) handleUpdateTemporarySnapshot(ctx context.Context, params *Upd }, nil } +func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgramParams) (*CreateProgramResponse, error) { + if params.FileChanges != nil && params.OldProgram == nil { + return nil, fmt.Errorf("%w: fileChanges requires an oldProgram", ErrClientError) + } + + rootFileNames := make([]string, len(params.RootFiles)) + for i, rootFile := range params.RootFiles { + rootFileNames[i] = rootFile.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + } + + var oldSnapshot *project.Snapshot + var oldProject *project.Project + if params.OldProgram != nil { + oldSnapshotId := params.OldProgram.Snapshot + oldSD, err := s.retainSnapshotData(oldSnapshotId) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(oldSnapshotId) }() + + oldSnapshot = oldSD.snapshot + oldProject, err = oldSD.getProject(params.OldProgram.Project) + if err != nil { + return nil, err + } + } + + snapshot := s.projectSession.APICreateProgram( + ctx, + rootFileNames, + ¶ms.CreateProgramOptions.CompilerOptions, + params.CreateProgramOptions.ProjectReferences, + core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + oldSnapshot, + oldProject, + s.toFileChangeSummary(params.FileChanges), + ) + project := snapshot.ProjectCollection.InferredProject() + if project == nil { + snapshot.Deref(s.projectSession) + return nil, fmt.Errorf("%w: failed to create synthetic project", ErrClientError) + } + + handle := snapshotHandle(snapshot) + s.snapshotsMu.Lock() + if sd, exists := s.snapshots[handle]; exists { + // Same snapshot already stored — release the caller's ref since + // the stored snapshot already has one, and bump the API refcount. + snapshot.Deref(s.projectSession) + sd.refCount++ + } else { + sd = &snapshotData{ + snapshot: snapshot, + refCount: 1, + symbolRegistry: make(map[SymbolID]*ast.Symbol), + symbolCanonicalProjects: make(map[SymbolID]ProjectID), + projectRegistries: make(map[ProjectID]*projectRegistryData), + } + s.snapshots[handle] = sd + } + s.snapshotsMu.Unlock() + + return &CreateProgramResponse{ + Snapshot: handle, + Project: NewProjectResponse(project), + }, nil +} + // handleRelease decrements the ref count for a snapshot. // The snapshot and its registries are only cleaned up when the ref count reaches zero. func (s *Session) handleRelease(ctx context.Context, params *ReleaseParams) (any, error) { diff --git a/internal/api/session_createprogram_test.go b/internal/api/session_createprogram_test.go new file mode 100644 index 00000000000..670a182f44b --- /dev/null +++ b/internal/api/session_createprogram_test.go @@ -0,0 +1,478 @@ +package api + +import ( + "context" + "testing" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestCreateProgram(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + // Create a basic program snapshot without replacing the session's latest snapshot. + baseResponse, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{}) + assert.NilError(t, err) + // The valid unsaved LSP overlay is visible to createProgram. + projectSession.DidOpenFile( + ctx, + DocumentIdentifier{FileName: fileName}.ToURI(projectSession.GetCurrentDirectory()), + 1, + `export const value: string = "valid overlay";`, + lsproto.LanguageKindTypeScript, + ) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Snapshot != baseResponse.Snapshot) + assert.Equal(t, session.latestSnapshot, baseResponse.Snapshot) + assert.Assert(t, response.Project != nil) + assert.DeepEqual(t, response.Project.RootFiles, []string{fileName}) + assert.Equal(t, response.Project.CompilerOptions.Strict, core.TSTrue) + + // The program snapshot contains one synthetic project and supports program queries. + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Equal(t, len(snapshot.snapshot.ProjectCollection.Projects()), 1) + + diagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(diagnostics), 0) + + // Update from the old program using an explicit disk change summary. + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid on disk";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + + updatedDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: updatedResponse.Snapshot, + Project: updatedResponse.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(updatedDiagnostics), 0) + + oldDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(oldDiagnostics), 0) + + // Releasing the program snapshot disposes it without affecting the base snapshot. + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: updatedResponse.Snapshot}) + assert.NilError(t, err) + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: response.Snapshot}) + assert.NilError(t, err) + _, err = session.getSnapshotData(response.Snapshot) + assert.ErrorContains(t, err, "not found") + _, err = session.getSnapshotData(baseResponse.Snapshot) + assert.NilError(t, err) +} + +func TestCreateProgramWithNoRootFiles(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + + response, err := session.handleCreateProgram(context.Background(), &CreateProgramParams{ + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Project != nil) + assert.Equal(t, len(response.Project.RootFiles), 0) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + project := snapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, project != nil) + assert.Assert(t, project.Program != nil) + assert.Equal(t, len(project.Program.GetSourceFiles()), 0) +} + +func TestCreateProgramRemovesAllRootFiles(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export {};", + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Project != nil) + assert.Equal(t, len(response.Project.RootFiles), 0) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + project := snapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, project != nil) + assert.Assert(t, project.Program != nil) + assert.Equal(t, len(project.Program.GetSourceFiles()), 0) +} + +func TestCreateProgramPreservesRootFileOrder(t *testing.T) { + t.Parallel() + + const ( + fileA = "/home/projects/p/a.ts" + fileB = "/home/projects/p/b.ts" + ) + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileA: "export const a = 1;", + fileB: "export const b = 1;", + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileB}, {FileName: fileA}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, oldResponse.Project.RootFiles, []string{fileB, fileA}) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileA}, {FileName: fileB}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, response.Project.RootFiles, []string{fileA, fileB}) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Equal(t, snapshot.snapshot.ProjectCollection.InferredProject().ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) +} + +func TestCreateProgramReusesProgram(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + // Build the initial program. + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + }) + assert.NilError(t, err) + + // A single named file change should take the Program.UpdateProgram reuse path. + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindCloned) + + // Changing compiler options replaces the command line and intentionally skips reuse. + changedOptionsResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSFalse, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + changedOptionsSnapshot, err := session.getSnapshotData(changedOptionsResponse.Snapshot) + assert.NilError(t, err) + changedOptionsProject := changedOptionsSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, changedOptionsProject != nil) + assert.Equal(t, changedOptionsProject.CommandLine.CompilerOptions().Strict, core.TSFalse) + assert.Equal(t, changedOptionsProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) +} + +func TestCreateProgramProjectReferencesAndReuse(t *testing.T) { + t.Parallel() + + const ( + fileName = "/home/projects/app/index.ts" + libConfigName = "/home/projects/lib/tsconfig.json" + otherConfigName = "/home/projects/other/tsconfig.json" + ) + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + libConfigName: `{ "compilerOptions": { "composite": true, "noLib": true }, "files": ["index.ts"] }`, + "/home/projects/lib/index.ts": `export const lib = 1;`, + otherConfigName: `{ "compilerOptions": { "composite": true, "noLib": true }, "files": ["index.ts"] }`, + "/home/projects/other/index.ts": `export const other = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + libReference := &core.ProjectReference{Path: libConfigName, OriginalPath: libConfigName} + + // A config-less createProgram command line still carries and resolves project references. + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{libReference}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, oldResponse.Project.ParsedCommandLine.ProjectReferences, []*core.ProjectReference{libReference}) + oldSnapshot, err := session.getSnapshotData(oldResponse.Snapshot) + assert.NilError(t, err) + resolvedReferences := oldSnapshot.snapshot.ProjectCollection.InferredProject().Program.GetResolvedProjectReferences() + assert.Equal(t, len(resolvedReferences), 1) + assert.Equal(t, resolvedReferences[0].ConfigName(), libConfigName) + + // originalPath is display syntax only; matching path/circular values preserve + // command-line identity, so one changed file can reuse the old program. + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + equivalentLibReference := &core.ProjectReference{Path: libConfigName, OriginalPath: "../lib"} + reusedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{equivalentLibReference}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{Changed: []DocumentIdentifier{{FileName: fileName}}}, + }) + assert.NilError(t, err) + reusedSnapshot, err := session.getSnapshotData(reusedResponse.Snapshot) + assert.NilError(t, err) + assert.Equal(t, reusedSnapshot.snapshot.ProjectCollection.InferredProject().ProgramUpdateKind, project.ProgramUpdateKindCloned) + + // Changing references changes the command line and therefore requires a full program update. + otherReference := &core.ProjectReference{Path: otherConfigName, OriginalPath: otherConfigName} + changedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{otherReference}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + changedSnapshot, err := session.getSnapshotData(changedResponse.Snapshot) + assert.NilError(t, err) + changedProject := changedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Equal(t, changedProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) + assert.DeepEqual(t, changedProject.CommandLine.ProjectReferences(), []*core.ProjectReference{otherReference}) +} + +func TestCreateProgramFromConfiguredProgramDoesNotRetainOtherProjects(t *testing.T) { + t.Parallel() + + const ( + configFileName = "/home/projects/p/tsconfig.json" + fileName = "/home/projects/p/index.ts" + otherConfigFileName = "/home/projects/other/tsconfig.json" + otherFileName = "/home/projects/other/index.ts" + ) + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + configFileName: `{ "compilerOptions": { "noLib": true, "strict": true }, "files": ["index.ts"] }`, + fileName: `export const value: string = 1;`, + otherConfigFileName: `{ "files": ["index.ts"] }`, + otherFileName: `export const other = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + // Load two configured projects so the selected old program comes from a non-synthetic, multi-project snapshot. + baseResponse, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}, {FileName: otherConfigFileName}}, + }) + assert.NilError(t, err) + var baseProject *ProjectResponse + for _, candidate := range baseResponse.Projects { + if candidate.ConfigFileName == configFileName { + baseProject = candidate + break + } + } + assert.Assert(t, baseProject != nil) + rootFiles := make([]DocumentIdentifier, len(baseProject.RootFiles)) + for i, rootFile := range baseProject.RootFiles { + rootFiles[i] = DocumentIdentifier{FileName: rootFile} + } + + // Derive only the selected configured program as a synthetic createProgram project. + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: rootFiles, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: baseResponse.Snapshot, + Project: baseProject.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + // The derived snapshot must not retain configured projects or config state from the unrelated base project. + assert.Equal(t, len(updatedSnapshot.snapshot.ProjectCollection.Projects()), 1) + assert.Equal(t, len(updatedSnapshot.snapshot.ProjectCollection.ConfiguredProjects()), 0) + assert.Assert(t, updatedSnapshot.snapshot.ConfigFileRegistry.GetConfig(tspath.Path(otherConfigFileName)) == nil) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + // Configured programs carry ConfigFilePath in their compiler options, while + // createProgram options intentionally do not. The command lines therefore differ, + // so this safely rebuilds instead of taking the single-file reuse path. + assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) + updatedDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: updatedResponse.Snapshot, + Project: updatedResponse.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(updatedDiagnostics), 0) + + // Disposing the derived snapshot must leave the original configured snapshot queryable. + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: updatedResponse.Snapshot}) + assert.NilError(t, err) + baseDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: baseResponse.Snapshot, + Project: baseProject.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(baseDiagnostics), 1) +} diff --git a/internal/ast/diagnostic.go b/internal/ast/diagnostic.go index 8002d6c6530..2ec658e93f9 100644 --- a/internal/ast/diagnostic.go +++ b/internal/ast/diagnostic.go @@ -136,6 +136,30 @@ func NewDiagnosticFromSerialized( } } +func NewDiagnosticFromText( + file *SourceFile, + loc core.TextRange, + code int32, + category diagnostics.Category, + text string, + messageChain []*Diagnostic, + relatedInformation []*Diagnostic, + reportsUnnecessary bool, + reportsDeprecated bool, +) *Diagnostic { + return &Diagnostic{ + file: file, + loc: loc, + code: code, + category: category, + message: diagnostics.NewAdHocMessage(text), + messageChain: messageChain, + relatedInformation: relatedInformation, + reportsUnnecessary: reportsUnnecessary, + reportsDeprecated: reportsDeprecated, + } +} + func NewDiagnostic(file *SourceFile, loc core.TextRange, message *diagnostics.Message, args ...any) *Diagnostic { return &Diagnostic{ file: file, diff --git a/internal/compiler/projectreferencefilemapper.go b/internal/compiler/projectreferencefilemapper.go index 58fd18251ed..6532a14fd07 100644 --- a/internal/compiler/projectreferencefilemapper.go +++ b/internal/compiler/projectreferencefilemapper.go @@ -25,6 +25,13 @@ type projectReferenceFileMapper struct { realpathDtsToSource collections.SyncMap[tspath.Path, *tsoptions.SourceOutputAndProjectReference] } +func (mapper *projectReferenceFileMapper) rootConfigPath() tspath.Path { + if mapper.opts.Config.ConfigFile == nil { + return "" + } + return mapper.opts.Config.ConfigFile.SourceFile.Path() +} + func (mapper *projectReferenceFileMapper) getParseFileRedirect(file ast.HasFileName) string { if mapper.opts.canUseProjectReferenceSource() { // Map to source file from project reference @@ -46,10 +53,7 @@ func (mapper *projectReferenceFileMapper) getParseFileRedirect(file ast.HasFileN } func (mapper *projectReferenceFileMapper) getResolvedProjectReferences() []*tsoptions.ParsedCommandLine { - if mapper.opts.Config.ConfigFile == nil { - return nil - } - refs, ok := mapper.referencesInConfigFile[mapper.opts.Config.ConfigFile.SourceFile.Path()] + refs, ok := mapper.referencesInConfigFile[mapper.rootConfigPath()] var result []*tsoptions.ParsedCommandLine if ok { result = make([]*tsoptions.ParsedCommandLine, 0, len(refs)) @@ -112,12 +116,13 @@ func (mapper *projectReferenceFileMapper) getResolvedReferenceFor(path tspath.Pa func (mapper *projectReferenceFileMapper) rangeResolvedProjectReference( f func(path tspath.Path, config *tsoptions.ParsedCommandLine, parent *tsoptions.ParsedCommandLine, index int) bool, ) bool { - if mapper.opts.Config.ConfigFile == nil { + if len(mapper.opts.Config.ProjectReferences()) == 0 { return false } seenRef := collections.NewSetWithSizeHint[tspath.Path](len(mapper.referencesInConfigFile)) - seenRef.Add(mapper.opts.Config.ConfigFile.SourceFile.Path()) - refs := mapper.referencesInConfigFile[mapper.opts.Config.ConfigFile.SourceFile.Path()] + rootConfigPath := mapper.rootConfigPath() + seenRef.Add(rootConfigPath) + refs := mapper.referencesInConfigFile[rootConfigPath] return mapper.rangeResolvedReferenceWorker(refs, f, mapper.opts.Config, seenRef) } diff --git a/internal/compiler/projectreferenceparser.go b/internal/compiler/projectreferenceparser.go index cd3439173e4..9ce262a94f2 100644 --- a/internal/compiler/projectreferenceparser.go +++ b/internal/compiler/projectreferenceparser.go @@ -73,7 +73,7 @@ func (p *projectReferenceParser) initMapper(tasks []*projectReferenceParseTask) p.loader.projectReferenceFileMapper.referencesInConfigFile = make(map[tspath.Path][]tspath.Path, totalReferences) p.loader.projectReferenceFileMapper.sourceToProjectReference = make(map[tspath.Path]*tsoptions.SourceOutputAndProjectReference) p.loader.projectReferenceFileMapper.outputDtsToProjectReference = make(map[tspath.Path]*tsoptions.SourceOutputAndProjectReference) - p.loader.projectReferenceFileMapper.referencesInConfigFile[p.loader.opts.Config.ConfigFile.SourceFile.Path()] = p.initMapperWorker(tasks, &collections.Set[*projectReferenceParseTask]{}) + p.loader.projectReferenceFileMapper.referencesInConfigFile[p.loader.projectReferenceFileMapper.rootConfigPath()] = p.initMapperWorker(tasks, &collections.Set[*projectReferenceParseTask]{}) if p.loader.projectReferenceFileMapper.opts.canUseProjectReferenceSource() && len(p.loader.projectReferenceFileMapper.outputDtsToProjectReference) != 0 { p.loader.projectReferenceFileMapper.host = newProjectReferenceDtsFakingHost(p.loader) } diff --git a/internal/execute/tsc/emit_test.go b/internal/execute/tsc/emit_test.go index f2ac8b50491..dbe7143038d 100644 --- a/internal/execute/tsc/emit_test.go +++ b/internal/execute/tsc/emit_test.go @@ -116,7 +116,7 @@ export const make = (): Box => ({ value: "ok" }); NoEmit: core.TSTrue, TsBuildInfoFile: "/project/tsconfig.tsbuildinfo", } - config := tsoptions.NewParsedCommandLine(options, []string{"/lib/lib.d.ts", "/project/hub.ts", "/project/spoke.ts"}, tspath.ComparePathsOptions{ + config := tsoptions.NewParsedCommandLine(options, []string{"/lib/lib.d.ts", "/project/hub.ts", "/project/spoke.ts"}, nil, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: true, CurrentDirectory: "/project", }) diff --git a/internal/project/api.go b/internal/project/api.go index 8f6eb24f225..d803dc9fe04 100644 --- a/internal/project/api.go +++ b/internal/project/api.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" + "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) @@ -67,3 +68,46 @@ func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot }, overlays, s) return newSnapshot, nil } + +// APICreateProgram creates an isolated snapshot containing one synthetic project. +// Without an old snapshot it starts from the underlying filesystem; otherwise it +// derives from the old snapshot and applies fileChanges. The caller owns the returned +// snapshot reference and must call snapshot.Deref(s) when done. +func (s *Session) APICreateProgram( + ctx context.Context, + rootFileNames []string, + options *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + oldSnapshot *Snapshot, + oldProject *Project, + fileChanges FileChangeSummary, +) *Snapshot { + if oldSnapshot != nil { + newSnapshot := oldSnapshot.cloneForProgram( + ctx, + rootFileNames, + options, + projectReferences, + configFileParsingDiagnostics, + oldProject, + fileChanges, + s, + ) + return newSnapshot + } + + snapshot, _ := s.APIUpdate(ctx, fileChanges, nil /*apiREquest*/) + defer snapshot.Deref(s) + newSnapshot := snapshot.cloneForProgram( + ctx, + rootFileNames, + options, + projectReferences, + configFileParsingDiagnostics, + nil, + fileChanges, + s, + ) + return newSnapshot +} diff --git a/internal/project/project.go b/internal/project/project.go index 39c77aa78df..4244d4e0279 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -102,6 +102,7 @@ func NewInferredProject( currentDirectory string, compilerOptions *core.CompilerOptions, rootFileNames []string, + projectReferences []*core.ProjectReference, builder *ProjectCollectionBuilder, logger *logging.LogTree, ) *Project { @@ -124,6 +125,7 @@ func NewInferredProject( p.CommandLine = tsoptions.NewParsedCommandLine( compilerOptions, rootFileNames, + projectReferences, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: builder.fs.fs.UseCaseSensitiveFileNames(), CurrentDirectory: currentDirectory, @@ -132,6 +134,23 @@ func NewInferredProject( return p } +// newInferredProjectFromProject creates an isolated synthetic project seeded with +// the selected project's compiler state. +func newInferredProjectFromProject( + project *Project, + builder *ProjectCollectionBuilder, + logger *logging.LogTree, +) *Project { + inferred := NewProject(inferredProjectName, KindInferred, project.currentDirectory, builder, logger) + inferred.CommandLine = project.Program.CommandLine() + inferred.Program = project.Program + inferred.ProgramLastUpdate = project.ProgramLastUpdate + inferred.host = project.host + inferred.checkerPool = project.checkerPool + inferred.dirty = false + return inferred +} + func NewProject( configFileName string, kind Kind, @@ -307,11 +326,13 @@ func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine p.commandLineWithTypingsFiles = tsoptions.NewParsedCommandLine( p.CommandLine.CompilerOptions(), newRootNames, + p.CommandLine.ProjectReferences(), tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: p.host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: p.currentDirectory, }, ) + p.commandLineWithTypingsFiles.Errors = p.CommandLine.Errors } }) return p.commandLineWithTypingsFiles diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 890461a9c5d..6945f0c0c1c 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -80,6 +80,34 @@ func TestProjectProgramUpdateKind(t *testing.T) { assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindCloned) }) + t.Run("compiler options update inferred project", func(t *testing.T) { + t.Parallel() + const fileName = "/src/index.ts" + session, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const x = 1;", + }) + uri := lsproto.DocumentUri("file://" + fileName) + session.DidOpenFile(context.Background(), uri, 1, "export const x = 1;", lsproto.LanguageKindTypeScript) + oldProject := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, oldProject != nil) + oldProgram := oldProject.Program + assert.Equal(t, oldProgram.Options().Strict, core.TSUnknown) + + session.DidChangeCompilerOptionsForInferredProjects(context.Background(), &core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + updatedProject := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + assert.Equal(t, updatedProject.CommandLine.CompilerOptions().Strict, core.TSTrue) + assert.Assert(t, updatedProject.Program != oldProgram) + assert.Equal(t, updatedProject.Program.Options().Strict, core.TSTrue) + assert.Equal(t, oldProgram.Options().Strict, core.TSUnknown) + }) + t.Run("NewFiles when import resolution mode changes", func(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index 65c0023cdc1..bbaaa1a0b0c 100644 --- a/internal/project/projectcollectionbuilder.go +++ b/internal/project/projectcollectionbuilder.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "maps" + "reflect" "slices" "time" + "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" @@ -1041,6 +1043,35 @@ func (b *ProjectCollectionBuilder) findOrCreateProject( } func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool { + var projectReferences []*core.ProjectReference + var configFileParsingDiagnostics []*ast.Diagnostic + if project := b.inferredProject.Value(); project != nil { + projectReferences = project.CommandLine.ProjectReferences() + configFileParsingDiagnostics = project.CommandLine.Errors + } + return b.updateInferredProject(rootFileNames, b.compilerOptionsForInferredProjects, projectReferences, configFileParsingDiagnostics, logger) +} + +// seedInferredProjectForProgram adapts one selected project into the isolated +// synthetic-project slot used by createProgram. Subsequent mutations remain +// copy-on-write through inferredProject. +func (b *ProjectCollectionBuilder) seedInferredProjectForProgram(project *Project, logger *logging.LogTree) { + if project == nil || project.Program == nil { + return + } + b.inferredProject.Set(newInferredProjectFromProject(project, b, logger)) +} + +// updateInferredProject preserves the existing command line when roots and +// options are equivalent, allowing a subsequent single-file update to reuse the +// old program. Any config change resets derived state and forces a full rebuild. +func (b *ProjectCollectionBuilder) updateInferredProject( + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + logger *logging.LogTree, +) bool { if len(rootFileNames) == 0 { if b.inferredProject.Value() != nil { if logger != nil { @@ -1051,22 +1082,40 @@ func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []st } return false } - + rootFileNames = slices.Clone(rootFileNames) slices.Sort(rootFileNames) - if b.inferredProject.Value() == nil { - b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, b, logger)) + return b.updateOrCreateInferredProject(rootFileNames, compilerOptions, projectReferences, configFileParsingDiagnostics, logger) +} + +// updateOrCreateInferredProject always retains an inferred project, including when rootFileNames is empty. +// The caller transfers ownership of rootFileNames. +func (b *ProjectCollectionBuilder) updateOrCreateInferredProject( + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + logger *logging.LogTree, +) bool { + project := b.inferredProject.Value() + if project == nil { + project = NewInferredProject(b.sessionOptions.CurrentDirectory, compilerOptions, rootFileNames, projectReferences, b, logger) + project.CommandLine.Errors = configFileParsingDiagnostics + b.inferredProject.Set(project) } else { - newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions() - if b.compilerOptionsForInferredProjects != nil { - newCompilerOptions = b.compilerOptionsForInferredProjects + if compilerOptions == nil { + compilerOptions = project.CommandLine.CompilerOptions() } - newCommandLine := tsoptions.NewParsedCommandLine(newCompilerOptions, rootFileNames, tspath.ComparePathsOptions{ + newCommandLine := tsoptions.NewParsedCommandLine(compilerOptions, rootFileNames, projectReferences, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(), - CurrentDirectory: b.sessionOptions.CurrentDirectory, + CurrentDirectory: project.currentDirectory, }) + newCommandLine.Errors = configFileParsingDiagnostics changed := b.inferredProject.ChangeIf( func(p *Project) bool { - return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath()) + return !slices.Equal(p.CommandLine.FileNames(), newCommandLine.FileNames()) || + !reflect.DeepEqual(p.CommandLine.CompilerOptions(), compilerOptions) || + !projectReferencesEqual(p.CommandLine.ProjectReferences(), projectReferences) || + !reflect.DeepEqual(p.CommandLine.Errors, configFileParsingDiagnostics) }, func(p *Project) { if logger != nil { @@ -1082,6 +1131,15 @@ func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []st return true } +func projectReferencesEqual(a []*core.ProjectReference, b []*core.ProjectReference) bool { + return slices.EqualFunc(a, b, func(a *core.ProjectReference, b *core.ProjectReference) bool { + if a == nil || b == nil { + return a == b + } + return a.Path == b.Path && a.Circular == b.Circular + }) +} + // updateProgram updates the program for the given project entry if necessary. It returns // a boolean indicating whether the update could have caused any structure-affecting changes. func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], logger *logging.LogTree) bool { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 3ec8bda124f..df1e62beb75 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -8,7 +8,9 @@ import ( "sync/atomic" "time" + "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" @@ -19,6 +21,7 @@ import ( "github.com/microsoft/typescript-go/internal/project/dirty" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/sourcemap" + "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" ) @@ -79,6 +82,193 @@ func NewSnapshot( return s } +// Clones a snapshot and creates a single program in it containing the specified root files and compiler options. +func (s *Snapshot) cloneForProgram( + ctx context.Context, + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + oldProject *Project, + fileChanges FileChangeSummary, + session *Session, +) *Snapshot { + var logger *logging.LogTree + + // Print in-progress logs immediately if cloning fails + if session.options.LoggingEnabled { + defer func() { + if r := recover(); r != nil { + session.logger.Log(logger.String()) + panic(r) + } + }() + + logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d for program", s.id)) + } + + start := time.Now() + fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fileChanges = processFileChanges(fs, s.fs, fileChanges, logger) + + // We start out with a new config file registry and project collection instead of + // the ones from the base snapshot because we want to retain only the projects that are reachable from the new program, + // and the base snapshot isn't necessarily a result of a previous `cloneForProgram`. + configFileRegistry := &ConfigFileRegistry{} + if oldProject != nil && oldProject.Program != nil { + configFileRegistry = configFileRegistryForProgram(oldProject.Program) + } + projectCollection := &ProjectCollection{ + toPath: s.toPath, + configFileRegistry: configFileRegistry, + configuredProjects: make(map[tspath.Path]*Project), + openFiles: openFilePaths(s.fs.overlays), + fileDefaultProjects: make(map[tspath.Path]tspath.Path), + apiState: APIState{ + openProjects: make(map[tspath.Path]int), + openFiles: make(map[tspath.Path]apiOpenedFile), + }, + } + + newSnapshotID := session.snapshotID.Add(1) + projectCollectionBuilder := newProjectCollectionBuilder( + ctx, + newSnapshotID, + fs, + projectCollection, + configFileRegistry, + projectCollection.apiState, + compilerOptions, + s.sessionOptions, + configFileRegistry.customConfigFileName, + session.parseCache, + session.extendedConfigCache, + session.client, + ) + + // A program created by `createProgram` is represented by a synthetic inferred project and its program. + projectCollectionBuilder.seedInferredProjectForProgram(oldProject, logger) + if !fileChanges.IsEmpty() { + projectCollectionBuilder.DidChangeFiles(fileChanges, logger.Fork("DidChangeFiles")) + } + projectCollectionBuilder.updateOrCreateInferredProject(slices.Clone(rootFileNames), compilerOptions, projectReferences, configFileParsingDiagnostics, logger.Fork("UpdateProgramConfig")) + // Make sure the program is created and up to date. + if projectCollectionBuilder.inferredProject.Value().dirty { + projectCollectionBuilder.updateProgram(projectCollectionBuilder.inferredProject, logger.Fork("CreateProgram")) + } + projectCollectionBuilder.configFileRegistryBuilder.Cleanup() + + newProjectCollection, newConfigFileRegistry := projectCollectionBuilder.Finalize(logger) + + // !!! only cleanDiskCache if `project.ProgramUpdateKind == ProgramUpdateKindNewFiles` or + // if base snapshot wasn't a program snapshot. + cleanFilesStart := time.Now() + removedFiles := 0 + fs.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { + for _, project := range newProjectCollection.Projects() { + if project.host != nil && project.host.sourceFS.SeenFile(entry.Key()) { + return true + } + } + entry.Delete() + removedFiles++ + return true + }) + if session.options.LoggingEnabled { + logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart)) + } + + snapshotFS, _ := fs.Finalize() + newSnapshot := NewSnapshot( + newSnapshotID, + snapshotFS, + s.sessionOptions, + newConfigFileRegistry, + compilerOptions, + s.userPreferences, + nil, + nil, + s.toPath, + ) + newSnapshot.parentId = s.id + newSnapshot.ProjectCollection = newProjectCollection + newSnapshot.ConfigFileRegistry = newConfigFileRegistry + newSnapshot.builderLogs = logger + + for _, project := range newSnapshot.ProjectCollection.Projects() { + if project.Program != nil { + session.programCounter.Ref(project.Program) + if project.ProgramLastUpdate == newSnapshotID { + project.host.freeze(snapshotFS, newConfigFileRegistry) + } + } + } + + for _, config := range newSnapshot.ConfigFileRegistry.configs { + if config.commandLine != nil && config.commandLine.ConfigFile != nil { + for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles { + session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id) + } + } + } + + logger.Logf("Finished cloning snapshot %d into snapshot %d for program in %v", s.id, newSnapshot.id, time.Since(start)) + return newSnapshot +} + +// configFileRegistryForProgram retains only project-reference configs reachable +// from the selected program. Retaining-project metadata is rewritten for the +// synthetic project so unrelated projects from the base snapshot are not kept alive. +func configFileRegistryForProgram(program *compiler.Program) *ConfigFileRegistry { + registry := &ConfigFileRegistry{} + program.RangeResolvedProjectReference(func(path tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool { + if config == nil { + return true + } + if registry.configs == nil { + registry.configs = make(map[tspath.Path]*configFileEntry) + } + fileName := config.ConfigName() + if fileName == "" { + fileName = string(path) + } + registry.configs[path] = &configFileEntry{ + fileName: fileName, + commandLine: config, + retainingProjects: map[tspath.Path]struct{}{inferredProjectName: {}}, + } + return true + }) + return registry +} + +// Handles normalization of file changes and snapshotFSBuilder cache invalidation. +func processFileChanges(fs *snapshotFSBuilder, previousFS *SnapshotFS, fileChanges FileChangeSummary, logger *logging.LogTree) FileChangeSummary { + if fileChanges.HasExcessiveWatchEvents() { + invalidateStart := time.Now() + if fileChanges.InvalidateAll { + fs.invalidateCache() + logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) + } else if !fs.watchChangesOverlapCache(fileChanges) { + // All watch changes/deletes are files we haven't seen; they should be irrelevant. + fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} + fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} + } else if fileChanges.IncludesWatchChangeOutsideNodeModules { + fs.invalidateCache() + logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) + } else { + fs.invalidateNodeModulesCache() + logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) + } + } else { + fileChanges = fs.expandAndFilterWatchEvents(fileChanges) + fileChanges = previousFS.expandRealpathAliases(fileChanges) + fileChanges = fs.markDirtyFiles(fileChanges) + fileChanges = fs.convertOpenAndCloseToChanges(fileChanges) + } + return fileChanges +} + func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project { return s.ProjectCollection.GetDefaultProject(uri.Path(s.UseCaseSensitiveFileNames())) } @@ -289,32 +479,10 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma start := time.Now() fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) - if change.fileChanges.HasExcessiveWatchEvents() { - invalidateStart := time.Now() - if change.fileChanges.InvalidateAll { - fs.invalidateCache() - logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) - } else if !fs.watchChangesOverlapCache(change.fileChanges) { - // All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something) - change.fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} - change.fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} - } else if change.fileChanges.IncludesWatchChangeOutsideNodeModules { - fs.invalidateCache() - logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) - } else { - fs.invalidateNodeModulesCache() - logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) - } - } else { - change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges) - change.fileChanges = s.fs.expandRealpathAliases(change.fileChanges) - change.fileChanges = fs.markDirtyFiles(change.fileChanges) - change.fileChanges = fs.convertOpenAndCloseToChanges(change.fileChanges) - } + change.fileChanges = processFileChanges(fs, s.fs, change.fileChanges, logger) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { - // !!! mark inferred projects as dirty? compilerOptionsForInferredProjects = change.compilerOptionsForInferredProjects } @@ -345,6 +513,16 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) + // compiler options changed, update inferred project and program + if change.compilerOptionsForInferredProjects != nil && projectCollectionBuilder.inferredProject.Value() != nil { + projectCollectionBuilder.updateInferredProject( + projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), + change.compilerOptionsForInferredProjects, + projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), + projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, + logger.Fork("DidChangeCompilerOptionsForInferredProjects"), + ) + } if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index 297505e0c55..63e8b792c2e 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -51,7 +51,7 @@ func ParseCommandLine( options := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions watchOptions := convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions - result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ + result := NewParsedCommandLine(compilerOptions, parser.fileNames, nil, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: host.GetCurrentDirectory(), }) diff --git a/internal/tsoptions/parsedcommandline.go b/internal/tsoptions/parsedcommandline.go index 9f0229165bc..f2aab3868e0 100644 --- a/internal/tsoptions/parsedcommandline.go +++ b/internal/tsoptions/parsedcommandline.go @@ -60,12 +60,14 @@ type ParsedCommandLine struct { func NewParsedCommandLine( compilerOptions *core.CompilerOptions, rootFileNames []string, + projectReferences []*core.ProjectReference, comparePathsOptions tspath.ComparePathsOptions, ) *ParsedCommandLine { return &ParsedCommandLine{ ParsedConfig: &core.ParsedOptions{ - CompilerOptions: compilerOptions, - FileNames: rootFileNames, + CompilerOptions: compilerOptions, + FileNames: rootFileNames, + ProjectReferences: projectReferences, }, comparePathsOptions: comparePathsOptions, }