diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 8a96a261b2fcd..497e292795e2e 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -1339,6 +1339,7 @@ export interface SnapshotRequestChangesParams { * tsconfig that contains it; if found, that configured project is loaded and * becomes the file's default project. Otherwise the file is loaded into the * inferred project (e.g. a node_modules d.ts not in any project's import graph). + * If a file cannot be loaded into any project, the request fails. */ openFiles?: readonly DocumentIdentifier[] | undefined; /** diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 04d9a437ab71e..5db333944c527 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -6972,6 +6972,47 @@ describe("Program - diagnostics", () => { }); describe("getDefaultProjectForFile", () => { + test("snapshot opens reject unreadable virtual files without panicking", async () => { + const fileName = "/src/App.vue.ts"; + const source = `export const component = 1;`; + const fs = createVirtualFileSystem({ + "/tsconfig.json": JSON.stringify({ files: ["/src/index.ts"] }), + "/src/index.ts": `export const x = 1;`, + "/src/App.vue": source, + }); + let virtualFileAvailable = false; + await using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + ...fs, + readFile: path => virtualFileAvailable && path === fileName ? source : fs.readFile!(path), + fileExists: path => virtualFileAvailable && path === fileName ? true : fs.fileExists!(path), + }, + }); + const snapshot = await api.createSnapshot({ + openProjects: ["/tsconfig.json"], + openFiles: ["/src/index.ts"], + }); + + const expectedError = /client error: failed to .*snapshot: no project found for opened file: \/src\/App\.vue\.ts/; + await assert.rejects(snapshot.update({ openFiles: [fileName] }), expectedError); // @sync: assert.throws(() => snapshot.update({ openFiles: [fileName] }), expectedError); + await assert.rejects(api.createSnapshot({ openFiles: [fileName] }), expectedError); // @sync: assert.throws(() => api.createSnapshot({ openFiles: [fileName] }), expectedError); + + virtualFileAvailable = true; + const updated = await snapshot.update({ openFiles: [fileName] }); + const project = await updated.getDefaultProjectForFile(fileName); + assert.ok(project); + assert.equal(project.configFileName, ""); + assert.equal((await project.program.getSourceFile(fileName))?.text, source); + assert.equal(await snapshot.getDefaultProjectForFile(fileName), undefined); + assert.ok(await updated.getConfiguredProject("/tsconfig.json")); + + virtualFileAvailable = false; + const reopen = { openFiles: [fileName], fileNotifications: { deleted: [fileName] } }; + await assert.rejects(updated.update(reopen), expectedError); // @sync: assert.throws(() => updated.update(reopen), expectedError); + assert.equal((await project.program.getSourceFile(fileName))?.text, source); + }); + test("finds inferred project for d.ts in node_modules after openFiles", async () => { await using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index d6bae9e4ae241..5c5ca05f7ee32 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -6840,6 +6840,47 @@ describe("Program - diagnostics", () => { }); describe("getDefaultProjectForFile", () => { + test("snapshot opens reject unreadable virtual files without panicking", () => { + const fileName = "/src/App.vue.ts"; + const source = `export const component = 1;`; + const fs = createVirtualFileSystem({ + "/tsconfig.json": JSON.stringify({ files: ["/src/index.ts"] }), + "/src/index.ts": `export const x = 1;`, + "/src/App.vue": source, + }); + let virtualFileAvailable = false; + using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + ...fs, + readFile: path => virtualFileAvailable && path === fileName ? source : fs.readFile!(path), + fileExists: path => virtualFileAvailable && path === fileName ? true : fs.fileExists!(path), + }, + }); + const snapshot = api.createSnapshot({ + openProjects: ["/tsconfig.json"], + openFiles: ["/src/index.ts"], + }); + + const expectedError = /client error: failed to .*snapshot: no project found for opened file: \/src\/App\.vue\.ts/; + assert.throws(() => snapshot.update({ openFiles: [fileName] }), expectedError); + assert.throws(() => api.createSnapshot({ openFiles: [fileName] }), expectedError); + + virtualFileAvailable = true; + const updated = snapshot.update({ openFiles: [fileName] }); + const project = updated.getDefaultProjectForFile(fileName); + assert.ok(project); + assert.equal(project.configFileName, ""); + assert.equal((project.program.getSourceFile(fileName))?.text, source); + assert.equal(snapshot.getDefaultProjectForFile(fileName), undefined); + assert.ok(updated.getConfiguredProject("/tsconfig.json")); + + virtualFileAvailable = false; + const reopen = { openFiles: [fileName], fileNotifications: { deleted: [fileName] } }; + assert.throws(() => updated.update(reopen), expectedError); + assert.equal((project.program.getSourceFile(fileName))?.text, source); + }); + test("finds inferred project for d.ts in node_modules after openFiles", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index e51b75fb9e8a3..6260b5eb77465 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -356,6 +356,7 @@ type SnapshotRequestChangesParams struct { // tsconfig that contains it; if found, that configured project is loaded and // becomes the file's default project. Otherwise the file is loaded into the // inferred project (e.g. a node_modules d.ts not in any project's import graph). + // If a file cannot be loaded into any project, the request fails. OpenFiles []DocumentIdentifier `json:"openFiles,omitempty"` // CloseFiles lists files to release in the new snapshot. A file is only fully // closed once every API client that opened it closes it. diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 6264229c890f6..639c34c44bb9b 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -352,6 +352,9 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque b.createdPrograms = createdPrograms for uri := range apiRequest.EnsureFiles.Keys() { b.DidRequestFile(uri, false /*configuredProjectsOnly*/, logger) + if b.findDefaultProject(uri.FileName(), b.toPath(uri.FileName())) == nil { + return fmt.Errorf("no project found for opened file: %s", uri.FileName()) + } } for projectID := range apiRequest.EnsurePrograms.Keys() { b.DidRequestProject(projectID, logger)