package project_test import ( "context" "testing" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" "github.com/microsoft/typescript-go/internal/tspath" "gotest.tools/v3/assert" ) func TestProjectLifetime(t *testing.T) { t.Parallel() if !bundled.Embedded { t.Skip("bundled files are not embedded") } t.Run("configured project", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": `{ "compilerOptions": { "noLib": true, "module": "nodenext", "strict": true }, "include": ["src"] }`, "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, "/home/projects/TS/p2/tsconfig.json": `{ "compilerOptions": { "noLib": true, "module": "nodenext", "strict": true }, "include": ["src"] }`, "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, "/home/projects/TS/p3/tsconfig.json": `{ "compilerOptions": { "noLib": true, "module": "nodenext", "strict": true }, "include": ["src"] }`, "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, } session, utils := projecttestutil.Setup(files) snapshot := session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) // Open files in two projects uri1 := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") uri2 := lsproto.DocumentUri("file:///home/projects/TS/p2/src/index.ts") session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), uri2, 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.WaitForBackgroundTasks() snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) assert.Equal(t, len(utils.Client().WatchFilesCalls()), 1) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) // Close p1 file and open p3 file session.DidCloseFile(context.Background(), uri1) uri3 := lsproto.DocumentUri("file:///home/projects/TS/p3/src/index.ts") session.DidOpenFile(context.Background(), uri3, 1, files["/home/projects/TS/p3/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.WaitForBackgroundTasks() // Should still have two projects, but p1 replaced by p3 snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p3/tsconfig.json")) != nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p3/tsconfig.json")) != nil) assert.Equal(t, len(utils.Client().WatchFilesCalls()), 1) assert.Equal(t, len(utils.Client().UnwatchFilesCalls()), 0) // Close p2 and p3 files, open p1 file again session.DidCloseFile(context.Background(), uri2) session.DidCloseFile(context.Background(), uri3) session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.WaitForBackgroundTasks() // Should have one project (p1) snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) == nil) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p3/tsconfig.json")) == nil) assert.Equal(t, len(utils.Client().WatchFilesCalls()), 1) assert.Equal(t, len(utils.Client().UnwatchFilesCalls()), 0) }) t.Run("unrooted inferred projects", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, } session, _ := projecttestutil.Setup(files) snapshot := session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) // Open files without workspace roots (empty string) - should create single inferred project uri1 := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") uri2 := lsproto.DocumentUri("file:///home/projects/TS/p2/src/index.ts") session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), uri2, 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) // Should have one inferred project snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) // Close p1 file and open p3 file session.DidCloseFile(context.Background(), uri1) uri3 := lsproto.DocumentUri("file:///home/projects/TS/p3/src/index.ts") session.DidOpenFile(context.Background(), uri3, 1, files["/home/projects/TS/p3/src/index.ts"].(string), lsproto.LanguageKindTypeScript) // Should still have one inferred project snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) // Close p2 and p3 files, open p1 file again session.DidCloseFile(context.Background(), uri2) session.DidCloseFile(context.Background(), uri3) session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) // Should still have one inferred project snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) }) t.Run("file moves from inferred to configured project", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/ts/foo.ts": `export const foo = 1;`, "/home/projects/ts/p1/tsconfig.json": `{ "compilerOptions": { "noLib": true, "module": "nodenext", "strict": true }, "include": ["main.ts"] }`, "/home/projects/ts/p1/main.ts": `import { foo } from "../foo"; console.log(foo);`, } session, _ := projecttestutil.Setup(files) // Open foo.ts first - should create inferred project since no tsconfig found initially fooUri := lsproto.DocumentUri("file:///home/projects/ts/foo.ts") session.DidOpenFile(context.Background(), fooUri, 1, files["/home/projects/ts/foo.ts"].(string), lsproto.LanguageKindTypeScript) // Should have one inferred project snapshot := session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) // Now open main.ts - should trigger discovery of tsconfig.json and move foo.ts to configured project mainUri := lsproto.DocumentUri("file:///home/projects/ts/p1/main.ts") session.DidOpenFile(context.Background(), mainUri, 1, files["/home/projects/ts/p1/main.ts"].(string), lsproto.LanguageKindTypeScript) // Should now have one configured project and no inferred project snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) // Config file should be present assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) // Close main.ts - configured project should remain because foo.ts is still open session.DidCloseFile(context.Background(), mainUri) snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) // Close foo.ts - configured project should be retained until next file open session.DidCloseFile(context.Background(), fooUri) snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) }) t.Run("file move from inferred to configured via didOpen/didClose sequence", func(t *testing.T) { t.Parallel() // Start with tsconfig.json that includes "src" but file is at root level files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "include": ["src"] }`, "/home/projects/TS/p1/index.ts": `export const x = 1;`, } session, utils := projecttestutil.Setup(files) // Open index.ts at root level - should create inferred project since it's not under src/ // Creates config file registry entry, but has no files indexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/index.ts") session.DidOpenFile(context.Background(), indexUri, 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) // Should have one inferred project only (file is not included by tsconfig) snapshot := session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) // Simulate file move: create src/index.ts on disk err := utils.FS().WriteFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/index.ts"].(string)) assert.NilError(t, err) err = utils.FS().Remove("/home/projects/TS/p1/index.ts") assert.NilError(t, err) // Simulate file move sequence as it would happen in an editor: // 1. didOpen src/index.ts (new location) // Open comes in before file create event, so the config file is not marked as needing a file name reload, // so it's not turned into a configured project yet. This is probably not ideal, but it should sort itself // out momentarily after the file watcher events are processed. When we try the config file, we mark it // as "retained by src/index.ts" so the config entry doesn't get deleted before src/index.ts is closed. // Even though we currently think src/index.ts doesn't belong to the config, the config is in its directory // path, so we'll always see it as a candidate for containing src/index.ts. srcIndexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") session.DidOpenFile(context.Background(), srcIndexUri, 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) // 2. didClose index.ts (old location) session.DidCloseFile(context.Background(), indexUri) // 3. didChangeWatchedFiles: create src/index.ts and delete index.ts // The creation event for src/index.ts now hits the config file registry, and we should notice we // got a creation event for a file that retained the config, triggering a filename reload. session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ { Uri: srcIndexUri, Type: lsproto.FileChangeTypeCreated, }, { Uri: indexUri, Type: lsproto.FileChangeTypeDeleted, }, }) // Should now have one configured project only (file is now under src/) _, err = session.GetLanguageService(context.Background(), srcIndexUri) assert.NilError(t, err) snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) }) // Regression test for https://github.com/microsoft/typescript-go/issues/3733 // // "./"-prefixed include specs combined with a dot-directory exclude ("**/.*/") // used to drop all wildcard directories for the config, so a newly created file // never triggered a root file reload. The stale nested config never claimed the // file, and default-project resolution assigned it to the ancestor project // (parsed fresh from disk, so it did contain the file) — or the inferred project // when no ancestor config matched — until the session was restarted. The file // then had the wrong compiler options (e.g. no Temporal from lib ESNext). // The failure was independent of the order in which didOpen and the create // watch event arrived, so both orderings are covered. runNewFileScenario := func(t *testing.T, openBeforeCreateEvent bool) { files := map[string]any{ "/home/projects/TS/monorepo/tsconfig.json": `{ "compilerOptions": { "target": "ES2015", "lib": ["DOM"] }, "include": ["apps"] }`, "/home/projects/TS/monorepo/apps/web/tsconfig.json": `{ "compilerOptions": { "target": "ESNext", "lib": ["ESNext"] }, "include": ["./app/**/*.ts"], "exclude": ["**/.*/"] }`, "/home/projects/TS/monorepo/apps/web/app/existing.ts": `export const existing = 1;`, } session, utils := projecttestutil.Setup(files) // Open an existing file so the nested configured project loads. existingUri := lsproto.DocumentUri("file:///home/projects/TS/monorepo/apps/web/app/existing.ts") session.DidOpenFile(context.Background(), existingUri, 1, files["/home/projects/TS/monorepo/apps/web/app/existing.ts"].(string), lsproto.LanguageKindTypeScript) ls, err := session.GetLanguageService(context.Background(), existingUri) assert.NilError(t, err) assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext) // Create a new file on disk in a directory matched by the nested config's // wildcard include, then simulate the editor events in the given order. newFileContent := `export const newFile = 1;` newFileUri := lsproto.DocumentUri("file:///home/projects/TS/monorepo/apps/web/app/subdir/new.ts") assert.NilError(t, utils.FS().WriteFile("/home/projects/TS/monorepo/apps/web/app/subdir/new.ts", newFileContent)) openNewFile := func() { session.DidOpenFile(context.Background(), newFileUri, 1, newFileContent, lsproto.LanguageKindTypeScript) } sendCreateEvent := func() { session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ {Uri: newFileUri, Type: lsproto.FileChangeTypeCreated}, }) } if openBeforeCreateEvent { openNewFile() sendCreateEvent() } else { sendCreateEvent() openNewFile() } // The new file should be served by the nested configured project, not the // root project (ES2015 target) or an inferred project. ls, err = session.GetLanguageService(context.Background(), newFileUri) assert.NilError(t, err) assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext) assert.Assert(t, session.Snapshot().ProjectCollection.InferredProject() == nil) } t.Run("newly created file joins configured project with ./-prefixed includes (didOpen before create event)", func(t *testing.T) { t.Parallel() runNewFileScenario(t, true) }) t.Run("newly created file joins configured project with ./-prefixed includes (create event before didOpen)", func(t *testing.T) { t.Parallel() runNewFileScenario(t, false) }) t.Run("tsconfig move from subdirectory to parent via didChangeWatchedFiles", func(t *testing.T) { t.Parallel() // Start with tsconfig.json in src/ that includes "src" - file won't be included initially files := map[string]any{ "/home/projects/TS/p1/src/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "include": ["src"] }`, "/home/projects/TS/p1/src/index.ts": `export const x = 1;`, } session, utils := projecttestutil.Setup(files) // Open src/index.ts - should create inferred project since tsconfig.json includes "src" // relative to its location (src/src/ which doesn't exist) indexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") session.DidOpenFile(context.Background(), indexUri, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) // Should have one inferred project only (file is not included by tsconfig at src/tsconfig.json) snapshot := session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/src/tsconfig.json")) == nil) // Simulate tsconfig.json move: create tsconfig.json at parent level, delete from src/ tsconfigContent := files["/home/projects/TS/p1/src/tsconfig.json"].(string) err := utils.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", tsconfigContent) assert.NilError(t, err) err = utils.FS().Remove("/home/projects/TS/p1/src/tsconfig.json") assert.NilError(t, err) // Simulate file move via didChangeWatchedFiles newTsconfigUri := lsproto.DocumentUri("file:///home/projects/TS/p1/tsconfig.json") oldTsconfigUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/tsconfig.json") session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ { Uri: newTsconfigUri, Type: lsproto.FileChangeTypeCreated, }, { Uri: oldTsconfigUri, Type: lsproto.FileChangeTypeDeleted, }, }) // Should now have one configured project only (tsconfig.json now includes src/index.ts) _, err = session.GetLanguageService(context.Background(), indexUri) assert.NilError(t, err) snapshot = session.Snapshot() assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) }) t.Run("deleted open file remains in project until closed", func(t *testing.T) { t.Parallel() // Scenario: // 1. Start with two files included by a tsconfig, both open // 2. In a single batch change, delete one of the files but leave it open, and create a new file included by the tsconfig // 3. Request a LS for the deleted but open file // 4. Project should include both the new file and the deleted open file // 5. Close the deleted file // 6. On next LS request, the project should exclude the deleted file files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "include": ["src"] }`, "/home/projects/TS/p1/src/index.ts": ``, "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, } session, utils := projecttestutil.Setup(files) // Step 1: Open both files indexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") xUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/x.ts") session.DidOpenFile(context.Background(), indexUri, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), xUri, 1, files["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) // Verify initial state - both files should be in the project ls, err := session.GetLanguageService(context.Background(), indexUri) assert.NilError(t, err) program := ls.GetProgram() assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/index.ts") != nil, "index.ts should be in project") assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil, "x.ts should be in project") // Step 2: In a single batch change: // - Delete x.ts from disk (but leave it open) // - Create a new file y.ts on disk err = utils.FS().Remove("/home/projects/TS/p1/src/x.ts") assert.NilError(t, err) err = utils.FS().WriteFile("/home/projects/TS/p1/src/y.ts", `export const y = 2;`) assert.NilError(t, err) // Send both events in a single batch session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ { Uri: xUri, Type: lsproto.FileChangeTypeDeleted, }, { Uri: lsproto.DocumentUri("file:///home/projects/TS/p1/src/y.ts"), Type: lsproto.FileChangeTypeCreated, }, }) // Step 3 & 4: Request LS for the deleted but still open file // Project should include: index.ts, x.ts (open overlay), y.ts (new disk file) ls, err = session.GetLanguageService(context.Background(), xUri) assert.NilError(t, err) program = ls.GetProgram() assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/index.ts") != nil, "index.ts should still be in project") assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil, "x.ts should still be in project (open overlay)") assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/y.ts") != nil, "y.ts should be in project (new file)") // Step 5: Close the deleted file session.DidCloseFile(context.Background(), xUri) // Step 6: On next LS request, x.ts should be excluded ls, err = session.GetLanguageService(context.Background(), indexUri) assert.NilError(t, err) program = ls.GetProgram() assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/index.ts") != nil, "index.ts should still be in project") assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil, "x.ts should no longer be in project (closed and deleted)") assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/y.ts") != nil, "y.ts should still be in project") }) }