diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index e6f0b1c7504db..71c4704b1c861 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -27,6 +27,8 @@ type TypingsInfo struct { func (ti TypingsInfo) Equals(other TypingsInfo) bool { return ti.TypeAcquisition.Equals(other.TypeAcquisition) && ti.CompilerOptions.GetAllowJS() == other.CompilerOptions.GetAllowJS() && + (ti.CompilerOptions.Types == nil) == (other.CompilerOptions.Types == nil) && + slices.Equal(ti.CompilerOptions.Types, other.CompilerOptions.Types) && ti.UnresolvedImports.Equals(other.UnresolvedImports) } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index abedba27dc5ed..4e9273b45d25e 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -2,68 +2,1374 @@ package ata_test import ( "context" + "fmt" "slices" + "strings" + "sync" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/glob" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +func waitForInstall(t *testing.T, installStarted <-chan struct{}) { + t.Helper() + select { + case <-installStarted: + return + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for typings installation to start") + } +} + +func typingsWatchIDs(utils *projecttestutil.SessionUtils) []project.WatcherID { + var ids []project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + if strings.HasPrefix(string(call.ID), "typings installer files watcher") { + ids = append(ids, call.ID) + } + } + return ids +} + +func unwatchedAny(utils *projecttestutil.SessionUtils, ids []project.WatcherID) bool { + return slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return slices.Contains(ids, call.ID) + }) +} + +func leaveUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + const config = "/user/username/projects/other/tsconfig.json" + const file = "/user/username/projects/other/x.js" + openProjects := &collections.Set[string]{} + openProjects.Add(config) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + OpenProjects: openProjects, + OpenFiles: map[tspath.Path]string{utils.ToPath(file): file}, + }) + assert.NilError(t, err) + snapshot.Deref() + closeProjects := &collections.Set[tspath.Path]{} + closeProjects.Add(utils.ToPath(config)) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseProjects: closeProjects}) + assert.NilError(t, err) + snapshot.Deref() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: &collections.Set[tspath.Path]{}}) + assert.NilError(t, err) + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + snapshot.Deref() +} + +func closeUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + closeFiles := &collections.Set[tspath.Path]{} + closeFiles.Add(utils.ToPath("/user/username/projects/other/x.js")) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: closeFiles}) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() +} + func TestATA(t *testing.T) { t.Parallel() if !bundled.Embedded { t.Skip("bundled files are not embedded") } - t.Run("local module should not be picked up", func(t *testing.T) { + t.Run("local module should not be picked up", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/user/username/projects/project/app.js": `const c = require('./config');`, + "/user/username/projects/project/config.js": `export let x = 1`, + "/user/username/projects/project/jsconfig.json": `{ + "compilerOptions": { "moduleResolution": "commonjs" }, + "typeAcquisition": { "enable": true } + }`, + } + + testOptions := &projecttestutil.TypingsInstallerOptions{ + TypesRegistry: []string{"config"}, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, testOptions) + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + content := files["/user/username/projects/project/app.js"].(string) + + // Open the file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + // Verify the local config.js file is included in the program + program := ls.GetProgram() + assert.Assert(t, program != nil) + configFile := program.GetSourceFile("/user/username/projects/project/config.js") + assert.Assert(t, configFile != nil, "local config.js should be included") + + // Verify that only types-registry was installed (no @types/config since it's a local module) + npmCalls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(npmCalls), 1) + assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + }) + + t.Run("configured projects", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + npmCalls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(npmCalls), 2) + assert.Equal(t, npmCalls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + assert.Equal(t, npmCalls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Assert(t, slices.Contains(npmCalls[1].Args, "@types/jquery@latest")) + assert.Equal(t, len(utils.Client().RefreshDiagnosticsCalls()), 1) + }) + + t.Run("configured project ignores ATA results from before manifest invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied after the manifest changes") + }) + + t.Run("configured project ignores ATA results from a previous project lifetime", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + CloseFiles: &collections.Set[tspath.Path]{}, + }) + assert.NilError(t, err) + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied to a replacement project") + }) + + t.Run("inferred projects", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + + // Verify the types file was installed + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + assert.NilError(t, err) + program := ls.GetProgram() + jqueryTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") + }) + + t.Run("inferred project retains typings after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("inferred project retains ATA result completed after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil, "the ATA result should be delivered while no inferred project exists") + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + typingsFile := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("ATA install writes delivered with the result do not discard it", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/a/app.js": ``, + "/user/username/projects/a/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/a/package.json": `{"name":"a","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/b/app.js": ``, + "/user/username/projects/b/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/b/package.json": `{"name":"b","dependencies":{"commander":"^14.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + aURI := lsproto.DocumentUri("file:///user/username/projects/a/app.js") + bURI := lsproto.DocumentUri("file:///user/username/projects/b/app.js") + commanderTypings := projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts" + session.DidOpenFile(ctx, aURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, aURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(strings.ToLower(commanderTypings)), "the typings location should be watched") + + session.DidOpenFile(ctx, bURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + commanderTypings), + Type: lsproto.FileChangeTypeCreated, + }}) + ls, err := session.GetLanguageService(ctx, bURI) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(commanderTypings) != nil, "the install's own writes should not discard its result") + }) + + t.Run("source edit delivered with an ATA result rejects obsolete typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "jquery";`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + jqueryInstallStarted := make(chan struct{}, 1) + releaseJqueryInstall := make(chan struct{}) + releaseCommanderInstall := make(chan struct{}) + var releaseOnce sync.Once + releaseCommander := func() { releaseOnce.Do(func() { close(releaseCommanderInstall) }) } + defer releaseCommander() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + switch { + case slices.Contains(args, "@types/jquery@latest"): + jqueryInstallStarted <- struct{}{} + <-releaseJqueryInstall + case slices.Contains(args, "@types/commander@latest"): + <-releaseCommanderInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, jqueryInstallStarted) + close(releaseJqueryInstall) + session.WaitForBackgroundTasks() + + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + t.Run("package-owned typings deleted with the result invalidate discovery", func(t *testing.T) { + t.Parallel() + + const bundledTypes = "/user/username/projects/typings/foo.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": `import "foo";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../typings/foo.d.ts"}`, + bundledTypes: `declare const bundledFoo: { x: number }`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const foo: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().Remove(bundledTypes)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + bundledTypes), + Type: lsproto.FileChangeTypeDeleted, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), bundledTypes)) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts") != nil) + }) + + t.Run("ATA install for another project does not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/c/app.js": ``, + "/user/username/projects/c/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/c/package.json": `{"name":"c","dependencies":{"commander":"^14.0.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + cURI := lsproto.DocumentUri("file:///user/username/projects/c/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, cURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts"), + Type: lsproto.FileChangeTypeCreated, + }}) + _, err = session.GetLanguageService(ctx, cURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("excessive typings cache writes do not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + events := make([]*lsproto.FileEvent, 1001) + for i := range events { + events[i] = &lsproto.FileEvent{ + Uri: lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i)), + Type: lsproto.FileChangeTypeCreated, + } + } + session.DidChangeWatchedFiles(ctx, events) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("excessive cache writes do not hide filesystem replacement invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + summary := project.FileChangeSummary{} + for i := range 1001 { + summary.Created.Add(lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i))) + } + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": `declare const $: { x: number }`, + }, false)) + snapshot, err = session.APIUpdate(ctx, summary, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) + assert.NilError(t, err) + snapshot.Deref() + + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + OpenFiles: map[tspath.Path]string{ + utils.ToPath(uri.FileName()): uri.FileName(), + }, + }) + assert.NilError(t, err) + defer snapshot.Deref() + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + for _, configured := range []bool{false, true} { + name := "inferred" + if configured { + name = "configured" + } + t.Run("typings added by a manifest change reach the "+name+" program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + if configured { + files["/user/username/projects/project/tsconfig.json"] = `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}` + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().WriteFile(packageJSONURI.FileName(), `{"name":"test","dependencies":{"commander":"^14.0.0","jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: packageJSONURI, Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + program := ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + }) + } + + t.Run("inferred project rejects ATA result invalidated after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be retained after the manifest changes") + }) + + t.Run("inferred project retains typings after closing changed file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + const changedContent = "// changed" + session.DidChangeFile(context.Background(), uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: changedContent}, + }}) + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, changedContent, lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening a changed file") + }) + + t.Run("inferred project does not reuse typings for unrelated roots", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project1/app.js": ``, + "/user/username/projects/project1/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + "/user/username/projects/project2/app.js": ``, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + firstURI := lsproto.DocumentUri("file:///user/username/projects/project1/app.js") + session.DidOpenFile(context.Background(), firstURI, 1, files["/user/username/projects/project1/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), firstURI) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), firstURI) + session.WaitForBackgroundTasks() + + secondURI := lsproto.DocumentUri("file:///user/username/projects/project2/app.js") + session.DidOpenFile(context.Background(), secondURI, 1, files["/user/username/projects/project2/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(context.Background(), secondURI) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused for an unrelated root") + }) + + t.Run("inferred project does not reuse typings after manifest change", func(t *testing.T) { t.Parallel() + files := map[string]any{ - "/user/username/projects/project/app.js": `const c = require('./config');`, - "/user/username/projects/project/config.js": `export let x = 1`, - "/user/username/projects/project/jsconfig.json": `{ - "compilerOptions": { "moduleResolution": "commonjs" }, - "typeAcquisition": { "enable": true } + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } }`, } - testOptions := &projecttestutil.TypingsInstallerOptions{ - TypesRegistry: []string{"config"}, - } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) - session, utils := projecttestutil.SetupWithTypingsInstaller(files, testOptions) uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") - content := files["/user/username/projects/project/app.js"].(string) + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + var typingsWatcherID project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + for _, watcher := range call.Watchers { + if watcher.GlobPattern.Pattern != nil { + g, parseErr := glob.Parse(*watcher.GlobPattern.Pattern) + if parseErr == nil && g.Match("/user/username/projects/project/package.json") { + typingsWatcherID = call.ID + break + } + } + } + if typingsWatcherID != "" { + break + } + } + assert.Assert(t, typingsWatcherID != "", "expected typings watcher to be registered") - // Open the file - session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindJavaScript) + session.DidCloseFile(context.Background(), uri) session.WaitForBackgroundTasks() + assert.Assert(t, !slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return call.ID == typingsWatcherID + }), "typings watcher should remain registered while the inferred project is inactive") + err = utils.FS().WriteFile("/user/username/projects/project/package.json", `{"name":"test"}`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{ + Uri: "file:///user/username/projects/project/package.json", + Type: lsproto.FileChangeTypeChanged, + }}) + + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) ls, err := session.GetLanguageService(context.Background(), uri) assert.NilError(t, err) - // Verify the local config.js file is included in the program - program := ls.GetProgram() - assert.Assert(t, program != nil) - configFile := program.GetSourceFile("/user/username/projects/project/config.js") - assert.Assert(t, configFile != nil, "local config.js should be included") + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") + }) - // Verify that only types-registry was installed (no @types/config since it's a local module) - npmCalls := utils.NpmExecutor().NpmInstallCalls() - assert.Equal(t, len(npmCalls), 1) - assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + t.Run("cached inferred typings survive an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + leaveUnbuiltInferredProject(t, ctx, session, utils) + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) }) - t.Run("configured projects", func(t *testing.T) { + t.Run("ATA result survives an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + leaveUnbuiltInferredProject(t, ctx, session, utils) + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("cached inferred typings survive an LSP-created unbuilt project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/y.js": ``, + "/user/username/projects/other/z.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + yURI := lsproto.DocumentUri("file:///user/username/projects/other/y.js") + zURI := lsproto.DocumentUri("file:///user/username/projects/other/z.js") + configURI := lsproto.DocumentUri("file:///user/username/projects/other/tsconfig.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + session.DidOpenFile(ctx, yURI, 1, "", lsproto.LanguageKindJavaScript) + assert.NilError(t, utils.FS().WriteFile(configURI.FileName(), `{"compilerOptions":{"allowJs":true},"exclude":["y.js"]}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: configURI, Type: lsproto.FileChangeTypeChanged}}) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, zURI, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + session.DidCloseFile(ctx, yURI) + session.WaitForBackgroundTasks() + assert.Assert(t, session.Snapshot().ProjectCollection.InferredProject() == nil) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer cached state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer built project state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"commander":"^1.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + manifestURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + assert.NilError(t, utils.FS().WriteFile(manifestURI.FileName(), `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: manifestURI, Type: lsproto.FileChangeTypeChanged}}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 2, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not invalidate newer state while closing", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("superseded ATA result does not replace state confirmed by a newer program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 3, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 4, "", lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("inferred project does not reuse typings after compiler options change", func(t *testing.T) { t.Parallel() files := map[string]any{ "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/tsconfig.json": `{ - "compilerOptions": { "allowJs": true }, - "typeAcquisition": { "enable": true }, - }`, "/user/username/projects/project/package.json": `{ "name": "test", "dependencies": { @@ -72,24 +1378,33 @@ func TestATA(t *testing.T) { }`, } - session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ PackageToFile: map[string]string{ "jquery": `declare const $: { x: number }`, }, }) - session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) session.WaitForBackgroundTasks() - npmCalls := utils.NpmExecutor().NpmInstallCalls() - assert.Equal(t, len(npmCalls), 2) - assert.Equal(t, npmCalls[0].Cwd, projecttestutil.TestTypingsLocation) - assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") - assert.Equal(t, npmCalls[1].Cwd, projecttestutil.TestTypingsLocation) - assert.Assert(t, slices.Contains(npmCalls[1].Args, "@types/jquery@latest")) - assert.Equal(t, len(utils.Client().RefreshDiagnosticsCalls()), 1) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidChangeCompilerOptionsForInferredProjects(context.Background(), &core.CompilerOptions{ + AllowJs: core.TSTrue, + Types: []string{}, + }) + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after inferred compiler options change") }) - t.Run("inferred projects", func(t *testing.T) { + t.Run("inferred project does not reuse typings after filesystem replacement", func(t *testing.T) { t.Parallel() files := map[string]any{ @@ -102,28 +1417,37 @@ func TestATA(t *testing.T) { }`, } - session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ PackageToFile: map[string]string{ "jquery": `declare const $: { x: number }`, }, }) - session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) session.WaitForBackgroundTasks() - // Check that npm install was called twice - calls := utils.NpmExecutor().NpmInstallCalls() - assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") - assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) - assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) - assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) - assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) - // Verify the types file was installed - ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + }, false)) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) assert.NilError(t, err) - program := ls.GetProgram() - jqueryTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") - assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after replacing the filesystem") }) t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { @@ -427,6 +1751,15 @@ func TestATA(t *testing.T) { assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) // JS resolution should be dropped assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + program = ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) }) t.Run("expired cache entry (inferred project, should install typings)", func(t *testing.T) { diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index ad0b9e760cfe8..879058de07017 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -86,7 +86,7 @@ func DiscoverTypings( // Add the cached typing locations for inferred typings that are already installed packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { registryEntry := typesRegistry[name] - if inferredTypings[name] == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { + if inferred, ok := inferredTypings[name]; ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } return true diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index 5bf9b33b4e6f2..c601e22dec2fd 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -84,8 +84,9 @@ func TestDiscoverTypings(t *testing.T) { t.Parallel() logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ - "/home/src/projects/project/app.js": "", - "/home/src/projects/project/node.d.ts": "", + "/home/src/projects/project/app.js": "", + "/home/src/projects/project/jquery.d.ts": "", + "/home/src/projects/project/node.d.ts": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) cache := collections.SyncMap[string, *ata.CachedTyping]{} @@ -94,6 +95,10 @@ func TestDiscoverTypings(t *testing.T) { TypingsLocation: "/home/src/projects/project/node.d.ts", Version: &version, }) + cache.Store("jquery", &ata.CachedTyping{ + TypingsLocation: "/home/src/projects/project/jquery.d.ts", + Version: &version, + }) unresolvedImports := collections.NewSetFromItems("fs", "bar") cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, @@ -107,7 +112,8 @@ func TestDiscoverTypings(t *testing.T) { "/home/src/projects/project", &cache, map[string]map[string]string{ - "node": projecttestutil.TypesRegistryConfig(), + "jquery": projecttestutil.TypesRegistryConfig(), + "node": projecttestutil.TypesRegistryConfig(), }, ) assert.DeepEqual(t, cachedTypingPaths, []string{ diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index c95d7eeec4479..a704bdf119ece 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -982,6 +982,7 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { PositionEncoding: lsproto.PositionEncodingKindUTF8, LoggingEnabled: true, RunExternalCode: true, + WatchEnabled: true, }, &projecttestutil.TypingsInstallerOptions{ PackageToFile: map[string]string{ "jquery": `declare const $: { x: number }`, @@ -1032,6 +1033,23 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { } } assert.Assert(t, typingsFile != nil, "expected installed typings in the inferred program (the typings-augmented rebuild did not happen)") + + session.DidCloseFile(ctx, boxURI) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, boxURI, 1, files["/home/loose/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err = session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + boxFile = languageService.GetProgram().GetSourceFile("/home/loose/app.box") + assert.Assert(t, boxFile != nil) + assert.Assert(t, boxFile.ContentMapper() != "") + typingsFile = nil + for _, file := range languageService.GetProgram().SourceFiles() { + if strings.HasSuffix(file.FileName(), "@types/jquery/index.d.ts") { + typingsFile = file + break + } + } + assert.Assert(t, typingsFile != nil, "expected cached typings immediately after reopening a content-mapped root") } func TestContentMapperCreatedFileAdoptedByConfiguredProject(t *testing.T) { diff --git a/tsc/internal/project/filechange.go b/tsc/internal/project/filechange.go index 251b63fcf335d..dcec6e9e5d1ed 100644 --- a/tsc/internal/project/filechange.go +++ b/tsc/internal/project/filechange.go @@ -3,6 +3,7 @@ package project import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/tspath" ) const excessiveChangeThreshold = 1000 @@ -96,3 +97,24 @@ func mergeFileChangeSummary(dst *FileChangeSummary, src FileChangeSummary) { dst.IncludesWatchChangeOutsideNodeModules = true } } + +func (f FileChangeSummary) withoutChangesWithin(directory string, useCaseSensitiveFileNames bool) FileChangeSummary { + if directory == "" { + return f + } + options := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: useCaseSensitiveFileNames} + filter := func(uris collections.Set[lsproto.DocumentUri]) collections.Set[lsproto.DocumentUri] { + var result collections.Set[lsproto.DocumentUri] + for uri := range uris.Keys() { + fileName := uri.FileName() + if tspath.ComparePaths(directory, fileName, options) != 0 && !tspath.ContainsPath(directory, fileName, options) { + result.Add(uri) + } + } + return result + } + f.Changed = filter(f.Changed) + f.Created = filter(f.Created) + f.Deleted = filter(f.Deleted) + return f +} diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 79954831210c9..32b99bc4d0671 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -173,6 +173,98 @@ type Project struct { installedTypingsInfo *ata.TypingsInfo // typingsFiles are the root files added by the typings installer. typingsFiles []string + // installedTypingsFileNames are the JavaScript files used during the most + // recently completed typings installation. + installedTypingsFileNames []string + // installedTypingsFilesToWatch are discovery inputs whose changes require + // typings discovery to run again. + installedTypingsFilesToWatch []string + // ataInvalidationSnapshotID is the latest snapshot that invalidated this + // project's ATA discovery inputs. + ataInvalidationSnapshotID uint64 + // installedTypingsSnapshotID is the snapshot that triggered the most recently + // applied typings installation. + installedTypingsSnapshotID uint64 +} + +type inferredProjectATAState struct { + installedTypingsInfo *ata.TypingsInfo + installedTypingsFileNames []string + installedTypingsFilesToWatch []string + typingsFiles []string + typingsWatch *WatchedFiles[PatternsAndIgnored] + snapshotID uint64 +} + +func (p *Project) inferredProjectATAState() *inferredProjectATAState { + if p.installedTypingsInfo == nil && len(p.installedTypingsFilesToWatch) == 0 { + return nil + } + snapshotID := p.installedTypingsSnapshotID + if p.installedTypingsInfo != nil && + p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) && + p.ProgramLastUpdate > snapshotID { + snapshotID = p.ProgramLastUpdate + } + return &inferredProjectATAState{ + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), + installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), + typingsFiles: slices.Clone(p.typingsFiles), + typingsWatch: p.typingsWatch, + snapshotID: snapshotID, + } +} + +func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuilder, watchEnabled bool) bool { + if s == nil || s.installedTypingsInfo == nil { + return false + } + if !watchEnabled && len(s.installedTypingsFilesToWatch) > 0 { + return false + } + if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || + !slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) { + return false + } + for _, fileName := range s.typingsFiles { + if !fs.FileExists(fileName, fs.toPath(fileName)) { + return false + } + } + return true +} + +func (s *inferredProjectATAState) apply(project *Project) { + typingsFilesChanged := !slices.Equal(project.typingsFiles, s.typingsFiles) + project.installedTypingsInfo = s.installedTypingsInfo + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) + project.setTypingsFiles(slices.Clone(s.typingsFiles)) + project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID + if typingsFilesChanged { + project.dirty = true + project.dirtyFilePath = "" + } +} + +func (s *inferredProjectATAState) canApplyWatchState(project *Project, watchEnabled bool) bool { + return s != nil && + s.installedTypingsInfo == nil && + watchEnabled && + slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) +} + +func (s *inferredProjectATAState) applyWatchState(project *Project) { + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Concat( + slices.Clone(s.installedTypingsFilesToWatch), + s.typingsFiles, + ) + project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID } var _ ls.Project = (*Project)(nil) @@ -203,6 +295,7 @@ func NewInferredProject( logger *logging.LogTree, ) *Project { p := NewProject(inferredProjectID.AsID(), KindInferred, currentDirectory, builder, logger) + p.ataInvalidationSnapshotID = builder.inferredProjectATAInvalidationSnapshotID if compilerOptions == nil { compilerOptions = &core.CompilerOptions{ AllowJs: core.TSTrue, @@ -278,10 +371,11 @@ func NewProject( logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), id, currentDirectory)) } project := &Project{ - Kind: kind, - id: id, - currentDirectory: currentDirectory, - dirty: true, + Kind: kind, + id: id, + currentDirectory: currentDirectory, + dirty: true, + ataInvalidationSnapshotID: builder.newSnapshotID, } project.programFilesWatch = NewWatchedFiles( @@ -291,12 +385,7 @@ func NewProject( createResolutionLookupGlobMapper(builder.sessionOptions.CurrentDirectory, builder.sessionOptions.DefaultLibraryPath, project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()), ) if builder.sessionOptions.TypingsLocation != "" { - project.typingsWatch = NewWatchedFiles( - "typings installer files", - lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, - lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, - core.Identity, - ) + project.typingsWatch = newTypingsWatch(builder) } project.contentMapperWatch = NewWatchedFilesForPaths( "content mapper configuration files for "+string(id), @@ -309,6 +398,15 @@ func NewProject( return project } +func newTypingsWatch(builder *ProjectCollectionBuilder) *WatchedFiles[PatternsAndIgnored] { + return NewWatchedFiles( + "typings installer files", + lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, + lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, + core.Identity, + ) +} + func (p *Project) CurrentDirectory() string { return p.currentDirectory } @@ -418,8 +516,12 @@ func (p *Project) Clone() *Project { moduleResolverFactory: p.moduleResolverFactory, moduleResolverID: p.moduleResolverID, - installedTypingsInfo: p.installedTypingsInfo, - typingsFiles: p.typingsFiles, + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: p.installedTypingsFileNames, + installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, + typingsFiles: p.typingsFiles, + ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, + installedTypingsSnapshotID: p.installedTypingsSnapshotID, } } @@ -439,6 +541,14 @@ func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) { p.dirtyFilePath = "" } +func (p *Project) setTypingsFiles(typingsFiles []string) { + if !slices.Equal(p.typingsFiles, typingsFiles) { + p.commandLineWithTypingsFiles = nil + p.commandLineWithTypingsFilesOnce = sync.Once{} + } + p.typingsFiles = typingsFiles +} + // getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled. func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine { if len(p.typingsFiles) == 0 { @@ -666,7 +776,8 @@ func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { return true } - return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) + return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) || + !slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { @@ -676,3 +787,17 @@ func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { UnresolvedImports: p.GetUnresolvedImports(), } } + +func (p *Project) ComputeTypingsFileNames() []string { + if p.Program == nil { + return nil + } + var fileNames []string + for _, file := range p.Program.GetSourceFiles() { + if tspath.HasJSFileExtension(file.FileName()) && !p.Program.IsSourceFileFromExternalLibrary(file) { + fileNames = append(fileNames, file.FileName()) + } + } + slices.Sort(fileNames) + return fileNames +} diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index c43d9da8d633b..0b6b805416a73 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -31,6 +31,12 @@ type ProjectCollection struct { // inferredProject is a fallback project that is used when no configured // project can be found for an open file. inferredProject *Project + // inferredProjectATAState preserves acquired typings while there is no active + // inferred project, so reopening a loose file does not wait for ATA again. + inferredProjectATAState *inferredProjectATAState + // inferredProjectATAInvalidationSnapshotID is the latest snapshot that + // invalidated inferred-project ATA discovery inputs. + inferredProjectATAInvalidationSnapshotID uint64 // apiState tracks the projects and files that API clients have explicitly // opened so they are kept loaded across snapshots. apiState APIState @@ -71,6 +77,16 @@ type apiOpenedFile struct { func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry } +func (c *ProjectCollection) inferredProjectTypingsWatch() *WatchedFiles[PatternsAndIgnored] { + if c.inferredProjectATAState != nil { + return c.inferredProjectATAState.typingsWatch + } + if c.inferredProject != nil { + return c.inferredProject.typingsWatch + } + return nil +} + func (c *ProjectCollection) ConfiguredProject(path tspath.Path) *Project { return c.configuredProjects[ConfiguredProjectID(path)] } @@ -328,14 +344,16 @@ func (c *ProjectCollection) findDefaultConfiguredProjectWorker(path tspath.Path, // clone creates a shallow copy of the project collection. func (c *ProjectCollection) clone() *ProjectCollection { return &ProjectCollection{ - toPath: c.toPath, - configFileRegistry: c.configFileRegistry, - configuredProjects: c.configuredProjects, - syntheticProjects: c.syntheticProjects, - openFiles: c.openFiles, - inferredProject: c.inferredProject, - fileDefaultProjects: c.fileDefaultProjects, - apiState: c.apiState, + toPath: c.toPath, + configFileRegistry: c.configFileRegistry, + configuredProjects: c.configuredProjects, + syntheticProjects: c.syntheticProjects, + openFiles: c.openFiles, + inferredProject: c.inferredProject, + inferredProjectATAState: c.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: c.inferredProjectATAInvalidationSnapshotID, + fileDefaultProjects: c.fileDefaultProjects, + apiState: c.apiState, } } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index eb3752678485c..2816b610b637d 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -56,11 +57,13 @@ type ProjectCollectionBuilder struct { defaultProjectsInvalidated bool openFilesChanged bool - fileDefaultProjects map[tspath.Path]ID - configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] - syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] - inferredProject *dirty.Box[*Project] - createdPrograms []*Project + fileDefaultProjects map[tspath.Path]ID + configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] + syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] + inferredProject *dirty.Box[*Project] + inferredProjectATAState *inferredProjectATAState + inferredProjectATAInvalidationSnapshotID uint64 + createdPrograms []*Project apiState APIState } @@ -86,27 +89,29 @@ func newProjectCollectionBuilder( ) *ProjectCollectionBuilder { openFiles := openFilePaths(overlays) return &ProjectCollectionBuilder{ - ctx: ctx, - fs: fs, - overlays: overlays, - toPath: fs.toPath, - compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, - inferredContentMappers: inferredContentMappers, - inferredContentMapperExtensions: inferredContentMapperExtensions, - sessionOptions: sessionOptions, - parseCache: parseCache, - contentMappedParseCache: contentMappedParseCache, - extendedConfigCache: extendedConfigCache, - contentMapperHost: contentMapperHost, - base: oldProjectCollection, - configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.Path) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), - newSnapshotID: newSnapshotID, - openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), - configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), - syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), - inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), - apiState: oldAPIState.clone(), - client: client, + ctx: ctx, + fs: fs, + overlays: overlays, + toPath: fs.toPath, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + inferredContentMappers: inferredContentMappers, + inferredContentMapperExtensions: inferredContentMapperExtensions, + sessionOptions: sessionOptions, + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: extendedConfigCache, + contentMapperHost: contentMapperHost, + base: oldProjectCollection, + configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.Path) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), + newSnapshotID: newSnapshotID, + openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), + configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), + syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), + inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), + inferredProjectATAState: oldProjectCollection.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: oldProjectCollection.inferredProjectATAInvalidationSnapshotID, + apiState: oldAPIState.clone(), + client: client, } } @@ -148,6 +153,19 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo ensureCloned() newProjectCollection.inferredProject = newInferredProject } + if b.inferredProjectATAState != nil { + if inferred := b.inferredProject.Value(); inferred != nil { + debug.Assert(inferred.Program == nil, "cached inferred ATA state coexists with a built inferred project") + } + } + if b.inferredProjectATAState != b.base.inferredProjectATAState { + ensureCloned() + newProjectCollection.inferredProjectATAState = b.inferredProjectATAState + } + if b.inferredProjectATAInvalidationSnapshotID != b.base.inferredProjectATAInvalidationSnapshotID { + ensureCloned() + newProjectCollection.inferredProjectATAInvalidationSnapshotID = b.inferredProjectATAInvalidationSnapshotID + } configFileRegistry := b.configFileRegistryBuilder.Finalize() if configFileRegistry != b.base.configFileRegistry { @@ -462,6 +480,167 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log } } +func (b *ProjectCollectionBuilder) DidChangeTypingsWatchInputs(summary FileChangeSummary, logger *logging.LogTree) { + b.forEachProject(func(entry dirty.Value[*Project]) bool { + projectID := entry.Value().ID() + if entry.ChangeIf( + func(project *Project) bool { + return fileChangeSummaryAffectsTypingsWatch( + summary, + project.installedTypingsFilesToWatch, + project.typingsFiles, + b.fs.fs.UseCaseSensitiveFileNames(), + ) + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + return true + }) + + if b.inferredProjectATAState != nil && fileChangeSummaryAffectsTypingsWatch( + summary, + b.inferredProjectATAState.installedTypingsFilesToWatch, + b.inferredProjectATAState.typingsFiles, + b.fs.fs.UseCaseSensitiveFileNames(), + ) { + b.invalidateInferredProjectATAState("typings watch changes", logger) + } +} + +func fileChangeSummaryAffectsTypingsWatch( + summary FileChangeSummary, + filesToWatch []string, + typingsFiles []string, + useCaseSensitiveFileNames bool, +) bool { + if summary.InvalidateAll { + return true + } + if len(filesToWatch) == 0 { + return false + } + comparePathsOptions := tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: useCaseSensitiveFileNames, + } + affectsWatch := func(uri lsproto.DocumentUri) bool { + fileName := uri.FileName() + return slices.ContainsFunc(slices.Concat(filesToWatch, typingsFiles), func(watchedPath string) bool { + if tspath.ComparePaths(watchedPath, fileName, comparePathsOptions) == 0 || + tspath.ContainsPath(watchedPath, fileName, comparePathsOptions) { + return true + } + switch tspath.GetBaseFileName(watchedPath) { + case "node_modules": + return tspath.ComparePaths(tspath.CombinePaths(tspath.GetDirectoryPath(watchedPath), "package.json"), fileName, comparePathsOptions) == 0 + case "bower_components": + return tspath.ComparePaths(tspath.CombinePaths(tspath.GetDirectoryPath(watchedPath), "bower.json"), fileName, comparePathsOptions) == 0 + } + return false + }) + } + for uri := range summary.Changed.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Created.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Deleted.Keys() { + if affectsWatch(uri) { + return true + } + } + return false +} + +func (b *ProjectCollectionBuilder) DidInvalidateTypingsWatchState(logger *logging.LogTree) { + b.forEachProject(func(entry dirty.Value[*Project]) bool { + b.invalidateProjectATAState(entry.Value().ID()) + entry.ChangeIf( + func(project *Project) bool { + return project.installedTypingsInfo != nil + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) + return true + }) + b.invalidateInferredProjectATAState("excessive file changes", logger) +} + +func (b *ProjectCollectionBuilder) invalidateProjectATAState(projectID ID) { + setProjectGeneration := func(project dirty.Value[*Project]) { + if project == nil || project.Value() == nil { + return + } + project.ChangeIf( + func(project *Project) bool { + return project.ataInvalidationSnapshotID != b.newSnapshotID + }, + func(project *Project) { + project.ataInvalidationSnapshotID = b.newSnapshotID + }, + ) + } + if _, inferred := projectID.Inferred(); inferred { + b.inferredProjectATAInvalidationSnapshotID = b.newSnapshotID + setProjectGeneration(b.inferredProject) + return + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + setProjectGeneration(project) + } + return + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + setProjectGeneration(project) + } + } +} + +func (b *ProjectCollectionBuilder) ataInvalidationSnapshotID(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + return b.inferredProjectATAInvalidationSnapshotID + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + return 0 + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + } + return 0 +} + +func (b *ProjectCollectionBuilder) invalidateInferredProjectATAState(reason string, logger *logging.LogTree) { + b.invalidateProjectATAState(inferredProjectID.AsID()) + b.clearInferredProjectATAState(reason, logger) +} + +func (b *ProjectCollectionBuilder) clearInferredProjectATAState(reason string, logger *logging.LogTree) { + b.inferredProjectATAState = nil + if logger != nil { + logger.Log("Invalidating cached inferred project ATA state due to " + reason) + } +} + func (b *ProjectCollectionBuilder) refreshContentMapperProjectForChanges(entry dirty.Value[*Project], paths []tspath.Path, refreshAll bool, logger *logging.LogTree) { project := entry.Value() if project.Program == nil || project.contentMapperWatchedFiles == nil { @@ -812,7 +991,31 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } } -func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, logger *logging.LogTree) { +func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { + installedTypingsSnapshotID := func(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + if state := b.inferredProjectATAState; state != nil { + return state.snapshotID + } + if project := b.inferredProject.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } else if syntheticID, synthetic := projectID.Synthetic(); synthetic { + if entry, loaded := b.syntheticProjects.Load(syntheticID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } else if configuredID, configured := projectID.Configured(); configured { + if entry, loaded := b.configuredProjects.Load(configuredID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } + return 0 + } + updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { project.ChangeIf( func(p *Project) bool { @@ -822,15 +1025,17 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState // Consistency check: the ATA demands (project options, unresolved imports) of this project // has not changed since the time the ATA request was dispatched; the change can still be // applied to this project in its current state. - return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) + return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(ataChange.FileNames, p.ComputeTypingsFileNames()) }, func(p *Project) { - // We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that - // the set of typings files is actually different. p.installedTypingsInfo = ataChange.TypingsInfo - p.typingsFiles = ataChange.TypingsFiles + p.installedTypingsSnapshotID = ataChange.SnapshotID + p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + p.setTypingsFiles(ataChange.TypingsFiles) typingsWatchGlobs := getTypingsLocationsGlobs( - ataChange.TypingsFilesToWatch, + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.TypingsFiles), b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, p.currentDirectory, @@ -845,8 +1050,53 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if ataChange.SnapshotID < installedTypingsSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring ATA state older than installed state for project %s", projectID) + } + continue + } + if fileChangeSummaryAffectsTypingsWatch( + fileChanges, + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.FileNames), + ataChange.TypingsFiles, + b.fs.fs.UseCaseSensitiveFileNames(), + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + if ataChange.SnapshotID < b.ataInvalidationSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring stale ATA state for project %s", projectID) + } + continue + } if _, ok := projectID.Inferred(); ok { - updateProject(b.inferredProject, ataChange) + if inferred := b.inferredProject.Value(); inferred != nil && inferred.Program != nil { + updateProject(b.inferredProject, ataChange) + } else { + typingsWatch := newTypingsWatch(b) + if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { + typingsWatch = b.inferredProjectATAState.typingsWatch + } + typingsWatchGlobs := getTypingsLocationsGlobs( + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.TypingsFiles), + b.sessionOptions.TypingsLocation, + b.sessionOptions.CurrentDirectory, + b.sessionOptions.CurrentDirectory, + b.fs.fs.UseCaseSensitiveFileNames(), + ) + b.inferredProjectATAState = &inferredProjectATAState{ + installedTypingsInfo: ataChange.TypingsInfo, + installedTypingsFileNames: slices.Clone(ataChange.FileNames), + installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), + typingsFiles: slices.Clone(ataChange.TypingsFiles), + typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + snapshotID: ataChange.SnapshotID, + } + } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { updateProject(project, ataChange) @@ -895,6 +1145,10 @@ func (b *ProjectCollectionBuilder) markProjectsAffectedByConfigChanges( logger *logging.LogTree, ) bool { for projectID := range configChangeResult.affectedProjects { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("config changes", logger) + } var project dirty.Value[*Project] if _, ok := projectID.Inferred(); ok { project = b.inferredProject @@ -1365,6 +1619,9 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } + if project.Program != nil { + b.inferredProjectATAState = project.inferredProjectATAState() + } b.inferredProject.Delete() return true } @@ -1537,6 +1794,32 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo elapsed := time.Since(startTime) logger.Log(fmt.Sprintf("Program update for %s completed in %v", projectID, elapsed)) } + if _, inferred := projectID.Inferred(); inferred && b.inferredProjectATAState != nil { + state := b.inferredProjectATAState + b.inferredProjectATAState = nil + if entry.ChangeIf( + func(project *Project) bool { + return state.canApply(project, b.fs, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.apply(project) + }, + ) { + if logger != nil { + logger.Log("Reusing cached inferred project ATA state") + } + filesChanged = b.updateProgram(entry, logger) || filesChanged + } else { + entry.ChangeIf( + func(project *Project) bool { + return state.canApplyWatchState(project, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.applyWatchState(project) + }, + ) + } + } return filesChanged } diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index ca4bce7cba6db..a98a199e545c9 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -1629,12 +1629,16 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er newSnapshot.ProjectCollection.ProjectsByID(), func(_ ID, addedProject *Project) { errors = append(errors, s.updateWatch(ctx, nil, addedProject.programFilesWatch)...) - errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + if addedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + } errors = append(errors, s.updateWatch(ctx, nil, addedProject.contentMapperWatch)...) }, func(_ ID, removedProject *Project) { errors = append(errors, s.updateWatch(ctx, removedProject.programFilesWatch, nil)...) - errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + if removedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + } errors = append(errors, s.updateWatch(ctx, removedProject.contentMapperWatch, nil)...) }, func(_ ID, oldProject, newProject *Project) { @@ -1645,11 +1649,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er errors = append(errors, s.updateWatch(ctx, nil, newProject.programFilesWatch)...) } } - if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { - errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) - } else { - if s.watches.IsPending(newProject.typingsWatch.ID()) { - errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + if newProject.Kind != KindInferred { + if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) + } else { + if s.watches.IsPending(newProject.typingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + } } } if oldProject.contentMapperWatch.ID() != newProject.contentMapperWatch.ID() { @@ -1659,6 +1665,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er } }, ) + oldInferredTypingsWatch := oldSnapshot.ProjectCollection.inferredProjectTypingsWatch() + newInferredTypingsWatch := newSnapshot.ProjectCollection.inferredProjectTypingsWatch() + if oldInferredTypingsWatch.ID() != newInferredTypingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldInferredTypingsWatch, newInferredTypingsWatch)...) + } else if newInferredTypingsWatch != nil && s.watches.IsPending(newInferredTypingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newInferredTypingsWatch)...) + } if oldSnapshot.autoImportsWatch.ID() != newSnapshot.autoImportsWatch.ID() { errors = append(errors, s.updateWatch(ctx, oldSnapshot.autoImportsWatch, newSnapshot.autoImportsWatch)...) @@ -1996,10 +2009,11 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { } typingsInfo := project.ComputeTypingsInfo() + fileNames := project.ComputeTypingsFileNames() request := &ata.TypingsInstallRequest{ ProjectID: project.ID(), TypingsInfo: &typingsInfo, - FileNames: core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }), + FileNames: fileNames, ProjectRootPath: project.currentDirectory, CompilerOptions: project.CommandLine.CompilerOptions(), CurrentDirectory: s.options.CurrentDirectory, @@ -2022,16 +2036,29 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { s.logger.Log(logTree.String()) } } else { - if !slices.Equal(result.TypingsFiles, project.typingsFiles) { + if !slices.Equal(result.TypingsFiles, project.typingsFiles) || + project.installedTypingsInfo == nil || + !typingsInfo.Equals(*project.installedTypingsInfo) || + !slices.Equal(fileNames, project.installedTypingsFileNames) || + !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) { s.pendingATAChangesMu.Lock() - defer s.pendingATAChangesMu.Unlock() - s.pendingATAChanges[project.ID()] = &ATAStateChange{ - TypingsInfo: &typingsInfo, - TypingsFiles: result.TypingsFiles, - TypingsFilesToWatch: result.FilesToWatch, - Logs: logTree, + projectID := project.ID() + pendingChange := s.pendingATAChanges[projectID] + updatedPendingChange := pendingChange == nil || pendingChange.SnapshotID <= newSnapshot.ID() + if updatedPendingChange { + s.pendingATAChanges[projectID] = &ATAStateChange{ + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFiles: result.TypingsFiles, + TypingsFilesToWatch: result.FilesToWatch, + Logs: logTree, + } + } + s.pendingATAChangesMu.Unlock() + if updatedPendingChange { + s.ScheduleDiagnosticsRefresh() } - s.ScheduleDiagnosticsRefresh() } } }) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 6040a639a2697..625721d0f1868 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -419,8 +419,12 @@ type SnapshotChange struct { // ATAStateChange represents a change to a project's ATA state. type ATAStateChange struct { + // SnapshotID is the snapshot whose project state triggered the ATA request. + SnapshotID uint64 // TypingsInfo is the new typings info for the project. TypingsInfo *ata.TypingsInfo + // FileNames are the JavaScript files used for typings discovery. + FileNames []string // TypingsFiles is the new list of typing files for the project. TypingsFiles []string // TypingsFilesToWatch is the new list of typing files to watch for changes. @@ -496,6 +500,11 @@ func (s *Snapshot) Clone( } start := time.Now() + hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() + var unfilteredFileChanges FileChangeSummary + if hadExcessiveWatchEvents { + unfilteredFileChanges = change.fileChanges.Clone() + } inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -516,6 +525,17 @@ func (s *Snapshot) Clone( overlays = layeredFS.Overlays() fs := newSnapshotFSBuilderFromSource(layeredFS, s.fs.cacheFiles, s.fs.cacheDirectories, s.fs.nodeModulesRealpathAliases, store.toPath) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions, s.overlays(), overlays) + typingsWatchChanges := change.fileChanges + if hadExcessiveWatchEvents { + typingsWatchChanges = unfilteredFileChanges + typingsWatchChanges.InvalidateAll = typingsWatchChanges.InvalidateAll || change.fileChanges.InvalidateAll + } + if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(typingsLocation, fs.fs.UseCaseSensitiveFileNames()) + if realTypingsLocation := fs.fs.Realpath(typingsLocation); realTypingsLocation != typingsLocation { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(realTypingsLocation, fs.fs.UseCaseSensitiveFileNames()) + } + } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { @@ -549,22 +569,26 @@ func (s *Snapshot) Clone( client, ) - if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState")) + if typingsWatchChanges.HasExcessiveWatchEvents() { + projectCollectionBuilder.DidInvalidateTypingsWatchState(logger.Fork("DidInvalidateTypingsWatchState")) } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) - 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, - projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), - logger.Fork("DidChangeCompilerOptionsForInferredProjects"), - ) + if change.compilerOptionsForInferredProjects != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("inferred compiler options changes", logger.Fork("InvalidateInferredProjectATAState")) + if projectCollectionBuilder.inferredProject.Value() != nil { + projectCollectionBuilder.updateInferredProject( + projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), + change.compilerOptionsForInferredProjects, + projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), + projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, + projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), + logger.Fork("DidChangeCompilerOptionsForInferredProjects"), + ) + } } if change.contentMapperContributions != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("content mapper changes", logger.Fork("InvalidateInferredProjectATAState")) projectCollectionBuilder.DidChangeContentMapperContributions(logger.Fork("DidChangeContentMapperContributions")) } if change.newConfig != nil { @@ -574,6 +598,12 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if !typingsWatchChanges.IsEmpty() { + projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) + } + if len(change.ataChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, logger.Fork("DidUpdateATAState")) + } var apiError error if change.apiRequest != nil {