diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index ec074fa3e2095..b11426afc85f6 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "sync" "sync/atomic" @@ -27,6 +28,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) } @@ -108,11 +111,20 @@ type TypingsInstallRequest struct { ProjectRootPath tspath.RootedDirectoryPath FS vfs.FS Logger logging.Logger + // OnDiscovery publishes watch inputs before reading their manifests. + OnDiscovery func([]tspath.RootedPath) error } type TypingsInstallResult struct { - TypingsFiles []tspath.RootedFilePath - FilesToWatch []tspath.RootedPath + TypingsFiles []tspath.RootedFilePath + FilesToWatch []tspath.RootedPath + CacheEntryPoints []CachedTypingEntryPoint + MissingTypingFiles []tspath.RootedFilePath +} + +type CachedTypingEntryPoint struct { + PackageName string + FileName tspath.RootedFilePath } func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { @@ -128,48 +140,110 @@ func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *Typings func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { ti.init(ctx, request.FS, request.Logger) - cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( - request.FS, - request.Logger, - request.TypingsInfo, - request.FileNames, - request.ProjectRootPath, - &ti.packageNameToTypingLocation, - ti.typesRegistry, + inferredTypings, filesToWatch, missingTypingFiles, err := discoverTypingNames( + request.FS, request.Logger, request.TypingsInfo, request.FileNames, request.ProjectRootPath, request.OnDiscovery, ) - + if err != nil { + return nil, err + } + slices.Sort(filesToWatch) + filesToWatch = slices.Compact(filesToWatch) + if request.OnDiscovery != nil { + if err := request.OnDiscovery(filesToWatch); err != nil { + return nil, err + } + } + cachedTypings := ti.resolveCachedTypings(request.FS, inferredTypings) + cachedTypingPaths, newTypingNames := getCachedTypingPaths(inferredTypings, cachedTypings, ti.typesRegistry, request.Logger) + makeResult := func(files []tspath.RootedFilePath, filesToWatch []tspath.RootedPath) *TypingsInstallResult { + result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch, MissingTypingFiles: missingTypingFiles} + cachedTypings.Range(func(name string, typing *CachedTyping) bool { + if slices.Contains(files, typing.TypingsLocation) { + result.CacheEntryPoints = append(result.CacheEntryPoints, CachedTypingEntryPoint{ + PackageName: name, FileName: typing.TypingsLocation, + }) + } + return true + }) + slices.SortFunc(result.CacheEntryPoints, func(a, b CachedTypingEntryPoint) int { + return strings.Compare(a.PackageName, b.PackageName) + }) + return result + } requestId := ti.installRunCount.Add(1) // install typings if len(newTypingNames) > 0 { - filteredTypings := ti.filterTypings(request.Logger, newTypingNames) + filteredTypings := ti.filterTypings(request.Logger, newTypingNames, cachedTypings) if len(filteredTypings) != 0 { - typingsFiles, err := ti.installTypings(ctx, requestId, cachedTypingPaths, filteredTypings, request.Logger) + typingsFiles, err := ti.installTypings(ctx, requestId, cachedTypingPaths, filteredTypings, cachedTypings, request.Logger) if err != nil { return nil, err } - return &TypingsInstallResult{ - TypingsFiles: typingsFiles, - FilesToWatch: filesToWatch, - }, nil + return makeResult(typingsFiles, filesToWatch), nil } request.Logger.Log("ATA:: All typings are known to be missing or invalid - no need to install more typings") } else { request.Logger.Log("ATA:: No new typings were requested as a result of typings discovery") } - return &TypingsInstallResult{ - TypingsFiles: cachedTypingPaths, - FilesToWatch: filesToWatch, - }, nil + return makeResult(cachedTypingPaths, filesToWatch), nil // !!! sheetal events to send // this.event(response, "setTypings"); } +// Resolve cached entry points afresh: a package can change its types field +// without changing its version or deleting the previously resolved file. +func (ti *TypingsInstaller) resolveCachedTypings(fs vfs.FS, inferredTypings map[string]tspath.RootedFilePath) *collections.SyncMap[string, *CachedTyping] { + resolver := module.NewResolver(module.ResolverOptions{ + Host: &resolutionHost{fs: fs, currentDirectory: ti.typingsLocation}, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) + result := &collections.SyncMap[string, *CachedTyping]{} + for name, inferred := range inferredTypings { + if inferred != "" { + continue + } + typingKey := module.MangleScopedPackageName(name) + if typing, ok := ti.packageNameToTypingLocation.Load(typingKey); ok { + if fileName := ti.typingToFileName(resolver, typingKey); fileName != "" { + result.Store(typingKey, &CachedTyping{TypingsLocation: fileName, Version: typing.Version}) + } + } + } + return result +} + +func NewCachedTypingEntryPointValidator(fs vfs.FS, location tspath.RootedDirectoryPath) func([]CachedTypingEntryPoint) bool { + var resolver module.Resolver + resolved := map[string]tspath.RootedFilePath{} + return func(entries []CachedTypingEntryPoint) bool { + for _, entry := range entries { + fileName, ok := resolved[entry.PackageName] + if !ok { + if resolver == nil { + resolver = module.NewResolver(module.ResolverOptions{ + Host: &resolutionHost{fs: fs, currentDirectory: location}, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) + } + result, _, _ := resolver.ResolveModuleName(entry.PackageName, location.ResolveFile("index.d.ts"), core.ModuleKindNone, nil) + fileName = result.ResolvedFileName + resolved[entry.PackageName] = fileName + } + if fileName == "" || fs.CaseSensitivity().ComparePaths(fileName.AsPath(), entry.FileName.AsPath()) != 0 { + return false + } + } + return true + } +} + func (ti *TypingsInstaller) installTypings( ctx context.Context, requestID int32, currentlyCachedTypings []tspath.RootedFilePath, filteredTypings []string, + cachedTypings *collections.SyncMap[string, *CachedTyping], logger logging.Logger, ) ([]tspath.RootedFilePath, error) { // !!! sheetal events to send @@ -217,6 +291,7 @@ func (ti *TypingsInstaller) installTypings( newVersion := semver.MustParse(useVersion) newTyping := &CachedTyping{TypingsLocation: typingFile, Version: &newVersion} ti.packageNameToTypingLocation.Store(packageName, newTyping) + cachedTypings.Store(packageName, newTyping) installedTypingFiles = append(installedTypingFiles, typingFile) } logger.Log(fmt.Sprintf("ATA:: Installed typing files %v", installedTypingFiles)) @@ -329,6 +404,7 @@ func installNpmPackages( func (ti *TypingsInstaller) filterTypings( logger logging.Logger, typingsToInstall []string, + cachedTypings *collections.SyncMap[string, *CachedTyping], ) []string { var result []string for _, typing := range typingsToInstall { @@ -349,7 +425,7 @@ func (ti *TypingsInstaller) filterTypings( logger.Log(fmt.Sprintf("ATA:: '%s':: Entry for package '%s' does not exist in local types registry - skipping...", typing, typingKey)) continue } - if typingLocation, ok := ti.packageNameToTypingLocation.Load(typingKey); ok && isTypingUpToDate(typingLocation, typesRegistryEntry) { + if typingLocation, ok := cachedTypings.Load(typingKey); ok && isTypingUpToDate(typingLocation, typesRegistryEntry) { logger.Log(fmt.Sprintf("ATA:: '%s':: '%s' already has an up-to-date typing - skipping...", typing, typingKey)) continue } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 258788185c9c0..73073fd018b08 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -2,68 +2,1884 @@ package ata_test import ( "context" + "fmt" "slices" + "strings" + "sync" + "sync/atomic" "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" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type discoveryReadCountingFS struct { + vfs.FS + discoveryReads atomic.Int32 +} + +func (fs *discoveryReadCountingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + if path == "/user/username/projects/project/bower_components/jquery/bower.json" { + fs.discoveryReads.Add(1) + } + return fs.FS.ReadFile(path) +} + +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[tspath.RootedFilePath]{} + openProjects.Add(tspath.RootedFilePathFromNormalized(config)) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + OpenProjects: openProjects, + OpenFiles: map[tspath.PathKey]tspath.RootedFilePath{utils.FS().CaseSensitivity().PathKey(tspath.RootedPath(file)): tspath.RootedFilePathFromNormalized(file)}, + }) + assert.NilError(t, err) + snapshot.Deref() + closeProjects := &collections.Set[tspath.PathKey]{} + closeProjects.Add(utils.FS().CaseSensitivity().PathKey(tspath.RootedPath(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.PathKey]{}}) + 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.PathKey]{} + closeFiles.Add(utils.FS().CaseSensitivity().PathKey(tspath.RootedPath("/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("applying ATA results does not repeat dependency discovery", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/bower_components/jquery/bower.json": `{"name":"jquery"}`, + } + init, _ := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: number;`}, + }) + fs := &discoveryReadCountingFS{FS: init.FS} + init.FS = fs + session := project.NewSession(init) + defer session.Close() + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + assert.Assert(t, fs.discoveryReads.Load() > 0, "the background installer must discover the Bower dependency") + fs.discoveryReads.Store(0) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + defer snapshot.Deref() + assert.Assert(t, !snapshot.ProjectCollection.InferredProject().ShouldTriggerATA(snapshot.ID()), "the background result must be accepted") + assert.Equal(t, fs.discoveryReads.Load(), int32(0), "applying the background result must not rediscover dependency manifests") + }) + + 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("external package declaration created during installation invalidates the result", func(t *testing.T) { + t.Parallel() + const ownedTypes = "/user/username/projects/shared/foo.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0","jquery":"^3.1.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../shared/foo.d.ts"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts": `declare const fallback: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/foo":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/foo":{"version":"1.3.0"}}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const fallback: number;`, "jquery": `declare const $: number;`}, + }) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, 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) + assert.NilError(t, utils.FS().WriteFile(ownedTypes, `declare const ownTypes: number;`)) + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject().ShouldTriggerATA(snapshot.ID()), "the obsolete cached fallback must be rejected") + snapshot.Deref() + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), ownedTypes)) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts")) + }) + + for _, applyWhileClosed := range []bool{false, true} { + name := "live" + if applyWhileClosed { + name = "dormant" + } + t.Run("external package declaration created while closed invalidates "+name+" fallback", func(t *testing.T) { + t.Parallel() + const ownedTypes = "/user/username/projects/shared/foo.d.ts" + const fallback = projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0","jquery":"^3.1.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../shared/foo.d.ts"}`, + fallback: `declare const fallback: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/foo":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/foo":{"version":"1.3.0"}}}`, + } + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const fallback: number;`, "jquery": `declare const $: number;`}, + }) + init.Options.CurrentDirectory = "/user/username/projects/project" + session := project.NewSession(init) + defer session.Close() + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + closeFile := func() { + 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.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + if applyWhileClosed { + closeFile() + } + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + if applyWhileClosed { + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + } + snapshot.Deref() + assert.Assert(t, utils.WatchesFile(ownedTypes)) + if !applyWhileClosed { + ls, serviceErr := session.GetLanguageService(ctx, uri) + assert.NilError(t, serviceErr) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + session.WaitForBackgroundTasks() + closeFile() + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + assert.Assert(t, slices.Contains(session.Snapshot().ProjectCollection.InferredProject().GetProgram().CommandLine().FileNames(), fallback), + "an unchanged missing declaration must allow immediate fallback reuse") + session.WaitForBackgroundTasks() + closeFile() + } + + assert.NilError(t, utils.FS().WriteFile(ownedTypes, `declare const ownTypes: number;`)) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), fallback), + "a newly available package declaration must prevent fallback reuse without a delivered watch event") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), ownedTypes)) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + }) + } + + t.Run("cached scoped dependency survives subsequent ATA requests", func(t *testing.T) { + t.Parallel() + const manifest = "/user/username/projects/project/package.json" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + manifest: `{"name":"test","dependencies":{"@a/b":"^1.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "a__b": `declare const scoped: number;`, + "jquery": `declare const $: number;`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/a__b/index.d.ts")) + session.WaitForBackgroundTasks() + assert.NilError(t, utils.FS().WriteFile(manifest, `{"name":"test","dependencies":{"@a/b":"^1.0.0","jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: "file://" + manifest, Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts")) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/a__b/index.d.ts"), "an up-to-date scoped dependency must remain an acquired root") + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(calls), 3, "registry initialization and one install per new dependency") + assert.Assert(t, !slices.Contains(calls[2].Args, "@types/a__b@latest"), "the cached scoped dependency must not be reinstalled") + }) + + 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.TestTypingsDirectory) + assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + assert.Equal(t, npmCalls[1].Cwd, projecttestutil.TestTypingsDirectory) + 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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.PathKey]{}, + }) + 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.TestTypingsDirectory) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsDirectory) + 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") + }) + + for _, scenario := range []string{"equivalent roots", "equivalent roots after pending install", "first install manifest change", "first install bower change", "directory deletion"} { + t.Run("inferred project discovery reuse "+scenario, func(t *testing.T) { + t.Parallel() + const directory = "/user/username/projects/project" + const app = directory + "/app.js" + const other = directory + "/other.js" + manifest := directory + "/package.json" + if scenario == "directory deletion" || scenario == "first install bower change" { + manifest = directory + "/bower.json" + } + files := map[string]any{ + app: "", other: "", manifest: `{"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://" + app) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + pendingInstall := scenario == "first install manifest change" || scenario == "first install bower change" || scenario == "equivalent roots after pending install" + if pendingInstall { + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + } + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + if pendingInstall { + waitForInstall(t, installStarted) + } else { + 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() + + switch scenario { + case "equivalent roots": + uri = lsproto.DocumentUri("file://" + other) + case "equivalent roots after pending install": + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + uri = lsproto.DocumentUri("file://" + other) + case "first install manifest change", "first install bower change": + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"test"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + 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() + case "directory deletion": + assert.NilError(t, utils.FS().Remove(tspath.RootedFilePathFromNormalized(manifest).AsPath())) + assert.NilError(t, utils.FS().Remove(app)) + assert.NilError(t, utils.FS().Remove(other)) + assert.NilError(t, utils.FS().Remove(directory)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file://" + directory, Type: lsproto.FileChangeTypeDeleted, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + assert.NilError(t, utils.FS().WriteFile(app, "")) + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"test"}`)) + } + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + hasTypings := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil + assert.Equal(t, hasTypings, strings.HasPrefix(scenario, "equivalent roots"), "initial reopened program must use only current discovery inputs") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + hasTypings = ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil + assert.Equal(t, hasTypings, strings.HasPrefix(scenario, "equivalent roots"), "fresh ATA must preserve the corrected discovery demand") + }) + } + + 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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(tspath.RootedFilePathFromNormalized(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(ctx context.Context, cwd tspath.RootedDirectoryPath, 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(ctx, 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 }`, + }, tspath.CaseInsensitive)) + 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.PathKey]tspath.RootedFilePath{ + utils.FS().CaseSensitivity().PathKey(uri.FileName().AsPath()): 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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": ``, + "/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 }`, + }, + }) + + 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.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") + + 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) + 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") + }) + + for _, state := range []string{"installed", "install pending"} { + for _, variant := range []string{"package", "manifest", "dependency directory", "bower package"} { + t.Run("real-path manifest change invalidates dormant ATA "+variant+" "+state, func(t *testing.T) { + t.Parallel() + const directory = "/user/username/projects/project" + const realManifest = "/vendor/foo/package.json" + packageDirectory := directory + "/node_modules/foo" + rootManifest := directory + "/package.json" + dependencyManifest := packageDirectory + "/package.json" + if variant == "bower package" { + packageDirectory = directory + "/bower_components/foo" + rootManifest = directory + "/bower.json" + dependencyManifest = packageDirectory + "/bower.json" + } + files := map[string]any{ + directory + "/app.js": "", + rootManifest: `{"dependencies":{"foo":"1.0.0"}}`, + realManifest: `{"name":"foo"}`, + } + switch variant { + case "package": + files[packageDirectory] = vfstest.Symlink("/vendor/foo") + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + case "manifest": + files[dependencyManifest] = vfstest.Symlink(realManifest) + files[packageDirectory+"/index.d.ts"] = "declare const ownTypes: number;" + case "dependency directory": + files[directory+"/node_modules"] = vfstest.Symlink("/vendor") + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + case "bower package": + files[packageDirectory] = vfstest.Symlink("/vendor/foo") + files["/vendor/foo/bower.json"] = files[realManifest] + delete(files, realManifest) + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + } + manifest := tspath.RootedFilePathFromNormalized(realManifest) + if variant == "bower package" { + manifest = "/vendor/foo/bower.json" + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": "declare const fallback: number;"}, + }) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + if state == "install pending" { + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/foo@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + } + ctx := context.Background() + uri := lsproto.DocumentUri("file://" + directory + "/app.js") + fallback := tspath.RootedFilePathFromNormalized(projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + if state == "install pending" { + waitForInstall(t, installStarted) + assert.Assert(t, utils.WatchesFile(manifest.AsString()), "real-path watches must be registered before installation starts") + } else { + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + 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() + assert.NilError(t, utils.FS().WriteFile(manifest, `{"name":"foo","types":"index.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest.AsString()), Type: lsproto.FileChangeTypeChanged, + }}) + 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, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), fallback), + "a real-path manifest event must discard the obsolete fallback before reopening") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), tspath.RootedFilePathFromNormalized(packageDirectory+"/index.d.ts"))) + assert.Assert(t, utils.WatchesFile(manifest.AsString()), "the real discovery manifest must be watched") + }) + } + } + + for _, variant := range []string{"regular", "symlinked cache", "symlinked package", "symlinked manifest"} { + t.Run("cached typings entry point changes without manifest discovery "+variant, func(t *testing.T) { + t.Parallel() + + packageDirectory := projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery" + switch variant { + case "symlinked cache": + packageDirectory = "/real/cache/node_modules/@types/jquery" + case "symlinked package": + packageDirectory = "/real/jquery" + } + manifest := packageDirectory + "/package.json" + indexFile := tspath.RootedFilePathFromNormalized(packageDirectory + "/index.d.ts") + newFile := tspath.RootedFilePathFromNormalized(packageDirectory + "/new.d.ts") + files := map[string]any{ + "/user/username/projects/project/jquery.js": ``, + manifest: `{"name":"@types/jquery","types":"index.d.ts"}`, + packageDirectory + "/index.d.ts": `declare const oldEntry: number;`, + packageDirectory + "/new.d.ts": `declare const newEntry: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/jquery":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/jquery":{"version":"1.3.0"}}}`, + } + switch variant { + case "symlinked cache": + files[projecttestutil.TestTypingsLocation] = vfstest.Symlink("/real/cache") + for _, name := range []string{"package.json", "package-lock.json"} { + files["/real/cache/"+name] = files[projecttestutil.TestTypingsLocation+"/"+name] + delete(files, projecttestutil.TestTypingsLocation+"/"+name) + } + case "symlinked package": + files[projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery"] = vfstest.Symlink(packageDirectory) + case "symlinked manifest": + target := "/real/jquery/package.json" + files[target] = files[manifest] + files[manifest] = vfstest.Symlink(target) + manifest = target + } + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + TypesRegistry: []string{"jquery"}, + }) + init.Options.CurrentDirectory = "/user/username/projects/project" + session := project.NewSession(init) + defer session.Close() + ctx := context.Background() + session.DidChangeCompilerOptionsForInferredProjects(ctx, &core.CompilerOptions{ + AllowJs: core.TSTrue, + Types: []string{}, + }) + uri := lsproto.DocumentUri("file:///user/username/projects/project/jquery.js") + // Filename discovery acquires jquery without scanning manifests. + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(indexFile) != nil) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(strings.ToLower(manifest))) + + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"new.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), newFile), "ATA should rediscover the current entry point") + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), indexFile), "ATA must remove the old entry point") + assert.Equal(t, len(utils.NpmExecutor().NpmInstallCalls()), 1, "changing a cached entry point should not reinstall an up-to-date package") + + 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() + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"index.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), newFile), "dormant state must not restore the obsolete entry point") + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), indexFile)) + + 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() + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"new.d.ts"}`)) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred = session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), indexFile), + "restoring dormant state must validate the entry point even without a delivered watch event") + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), newFile)) + }) + } + + 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": `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"}}`, + "/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() - testOptions := &projecttestutil.TypingsInstallerOptions{ - TypesRegistry: []string{"config"}, + 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("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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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, utils := projecttestutil.SetupWithTypingsInstaller(files, testOptions) + 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") - content := files["/user/username/projects/project/app.js"].(string) + 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) - // Open the file - session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindJavaScript) + 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() - ls, err := session.GetLanguageService(context.Background(), uri) + 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) - // 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") + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) - // 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("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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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("configured projects", func(t *testing.T) { + 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, 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 +1888,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.TestTypingsDirectory) - assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") - assert.Equal(t, npmCalls[1].Cwd, projecttestutil.TestTypingsDirectory) - 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 +1927,44 @@ 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.TestTypingsDirectory) - assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) - assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsDirectory) - 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"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": `declare const $: { x: number }`, + }, tspath.CaseInsensitive)) + 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() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + OpenFiles: map[tspath.PathKey]tspath.RootedFilePath{ + replacement.CaseSensitivity().PathKey(uri.FileName().AsPath()): uri.FileName(), + }, + }) + assert.NilError(t, err) + defer snapshot.Deref() + inferred := 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 not be reused after replacing the filesystem") }) t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { @@ -427,6 +2268,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 d09f0fa68b4c1..393b142d53f74 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -8,7 +8,9 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/semver" @@ -35,13 +37,34 @@ func DiscoverTypings( packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], typesRegistry map[string]map[string]string, ) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath) { + inferredTypings, filesToWatch, _, err := discoverTypingNames(fs, logger, typingsInfo, fileNames, projectRootPath, nil) + debug.Assert(err == nil, "discovery without a watch publisher cannot fail") + cachedTypingPaths, newTypingNames = getCachedTypingPaths(inferredTypings, packageNameToTypingLocation, typesRegistry, logger) + return cachedTypingPaths, newTypingNames, filesToWatch +} + +func discoverTypingNames( + fs vfs.FS, + logger logging.Logger, + typingsInfo *TypingsInfo, + fileNames []tspath.RootedFilePath, + projectRootPath tspath.RootedDirectoryPath, + onDiscovery func([]tspath.RootedPath) error, +) (inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, missingTypingFiles []tspath.RootedFilePath, err error) { // A typing name to typing file path mapping - inferredTypings := map[string]tspath.RootedFilePath{} + inferredTypings = map[string]tspath.RootedFilePath{} // Only infer typings for .js and .jsx files fileNames = core.Filter(fileNames, func(fileName tspath.RootedFilePath) bool { return fileName.HasJSFileExtension() }) + if onDiscovery != nil { + inputs := discoveryManifestWatchInputs(typingsInfo, fileNames, projectRootPath) + inputs = appendRealTypingWatchInputs(fs, inputs) + if err = onDiscovery(inputs); err != nil { + return nil, nil, nil, err + } + } if typingsInfo.TypeAcquisition.Include != nil { addInferredTypings(fs, logger, inferredTypings, typingsInfo.TypeAcquisition.Include, "Explicitly included types") @@ -50,14 +73,13 @@ func DiscoverTypings( // Directories to search for package.json, bower.json and other typing information if typingsInfo.CompilerOptions.Types == nil { - possibleSearchDirs := map[tspath.RootedDirectoryPath]bool{} - for _, fileName := range fileNames { - possibleSearchDirs[fileName.Directory()] = true - } - possibleSearchDirs[projectRootPath] = true - for searchDir := range possibleSearchDirs { - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components") - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules") + for _, searchDir := range discoveryDirectories(typingsInfo, fileNames, projectRootPath) { + for _, pair := range [][2]string{{"bower.json", "bower_components"}, {"package.json", "node_modules"}} { + filesToWatch, err = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, pair[0], pair[1], onDiscovery) + if err != nil { + return nil, nil, nil, err + } + } } } @@ -82,15 +104,33 @@ func DiscoverTypings( delete(inferredTypings, excludeTypingName) logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) } + filesToWatch = appendRealTypingWatchInputs(fs, filesToWatch) + return inferredTypings, filesToWatch, missingTypingFiles, nil +} +func appendRealTypingWatchInputs(fs vfs.FS, filesToWatch []tspath.RootedPath) []tspath.RootedPath { + for _, path := range filesToWatch { + if realPath := fs.Realpath(path); fs.CaseSensitivity().ComparePaths(path, realPath) != 0 { + filesToWatch = append(filesToWatch, realPath) + } + } + return filesToWatch +} + +func getCachedTypingPaths( + inferredTypings map[string]tspath.RootedFilePath, + packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], + typesRegistry map[string]map[string]string, + logger logging.Logger, +) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string) { // 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) { + for name, inferred := range inferredTypings { + typingKey := module.MangleScopedPackageName(name) + registryEntry := typesRegistry[typingKey] + if typing, ok := packageNameToTypingLocation.Load(typingKey); ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } - return true - }) + } for typing, inferred := range inferredTypings { if inferred != "" { @@ -99,8 +139,54 @@ func DiscoverTypings( newTypingNames = append(newTypingNames, typing) } } - logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch)) - return cachedTypingPaths, newTypingNames, filesToWatch + logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v", cachedTypingPaths, newTypingNames)) + return cachedTypingPaths, newTypingNames +} + +func discoveryManifestWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { + var paths []tspath.RootedPath + for _, directory := range discoveryDirectories(info, files, projectDirectory) { + paths = append(paths, directory.ResolveFile("package.json").AsPath(), directory.ResolveFile("bower.json").AsPath(), + directory.ResolveDirectory("node_modules").AsPath(), directory.ResolveDirectory("bower_components").AsPath()) + } + return paths +} + +func DiscoveryWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { + paths := discoveryManifestWatchInputs(info, files, projectDirectory) + for _, file := range files { + paths = append(paths, file.AsPath()) + } + slices.Sort(paths) + return slices.Compact(paths) +} + +func discoveryDirectories(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedDirectoryPath { + if info.CompilerOptions.Types != nil { + return nil + } + directories := collections.NewSetFromItems(projectDirectory) + for _, file := range files { + if file.HasJSFileExtension() { + directories.Add(file.Directory()) + } + } + result := slices.Collect(maps.Keys(directories.Keys())) + slices.Sort(result) + return result +} + +func DiscoveryInputsEqual(info *TypingsInfo, files, otherFiles []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath, caseSensitivity tspath.CaseSensitivity) bool { + directoryKeys := func(files []tspath.RootedFilePath) []tspath.PathKey { + keys := core.Map(discoveryDirectories(info, files, projectDirectory), func(dir tspath.RootedDirectoryPath) tspath.PathKey { + return caseSensitivity.PathKey(dir.AsPath()) + }) + slices.Sort(keys) + return slices.Compact(keys) + } + return slices.Equal(directoryKeys(files), directoryKeys(otherFiles)) && + (info.TypeAcquisition.DisableFilenameBasedTypeAcquisition.IsTrue() || + slices.Equal(typingNamesFromSourceFileNames(files), typingNamesFromSourceFileNames(otherFiles))) } func addInferredTyping(inferredTypings map[string]tspath.RootedFilePath, typingName string) { @@ -133,23 +219,26 @@ func getTypingNamesFromSourceFileNames( inferredTypings map[string]tspath.RootedFilePath, fileNames []tspath.RootedFilePath, ) { - hasJsxFile := false + addInferredTypings(fs, logger, inferredTypings, typingNamesFromSourceFileNames(fileNames), "Inferred typings from file names") +} + +func typingNamesFromSourceFileNames(fileNames []tspath.RootedFilePath) []string { var fromFileNames []string for _, fileName := range fileNames { - hasJsxFile = hasJsxFile || fileName.ExtensionIs(tspath.ExtensionJsx) + if !fileName.HasJSFileExtension() { + continue + } + if fileName.ExtensionIs(tspath.ExtensionJsx) { + fromFileNames = append(fromFileNames, "react") + } inferredTypingName := tspath.RemoveFileExtension(tspath.ToFileNameLowerCase(fileName.BaseName())) cleanedTypingName := removeMinAndVersionNumbers(inferredTypingName) if typeName, ok := safeFileNameToTypeName[cleanedTypingName]; ok { fromFileNames = append(fromFileNames, typeName) } } - if len(fromFileNames) > 0 { - addInferredTypings(fs, logger, inferredTypings, fromFileNames, "Inferred typings from file names") - } - if hasJsxFile { - logger.Log("ATA:: Inferred 'react' typings due to presence of '.jsx' extension") - addInferredTyping(inferredTypings, "react") - } + slices.Sort(fromFileNames) + return slices.Compact(fromFileNames) } /** @@ -165,10 +254,12 @@ func addTypingNamesAndGetFilesToWatch( logger logging.Logger, inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, + missingTypingFiles *[]tspath.RootedFilePath, projectRootPath tspath.RootedDirectoryPath, manifestName string, modulesDirName string, -) []tspath.RootedPath { + onDiscovery func([]tspath.RootedPath) error, +) ([]tspath.RootedPath, error) { // First, we check the manifests themselves. They're not // _required_, but they allow us to do some filtering when dealing // with big flat dep directories. @@ -196,7 +287,7 @@ func addTypingNamesAndGetFilesToWatch( packagesFolderPath := projectRootPath.ResolveDirectory(modulesDirName) filesToWatch = append(filesToWatch, packagesFolderPath.AsPath()) if !fs.DirectoryExists(packagesFolderPath) { - return filesToWatch + return filesToWatch, nil } // There's two cases we have to take into account here: @@ -252,6 +343,20 @@ func addTypingNamesAndGetFilesToWatch( // Once we have the names of things to look up, we iterate over // and either collect their included typings, or add them to the // list of typings we need to look up separately. + watchCount := len(filesToWatch) + for _, manifestPath := range dependencyManifestNames { + // These reads bypass the compiler host's realpath alias cache. + for _, path := range []tspath.RootedPath{manifestPath.AsPath(), manifestPath.Directory().AsPath()} { + if realPath := fs.Realpath(path); fs.CaseSensitivity().ComparePaths(path, realPath) != 0 { + filesToWatch = append(filesToWatch, path, realPath) + } + } + } + if onDiscovery != nil && len(filesToWatch) != watchCount { + if err := onDiscovery(filesToWatch); err != nil { + return nil, err + } + } for _, manifestPath := range dependencyManifestNames { manifestContents, ok := fs.ReadFile(manifestPath) if !ok { @@ -269,10 +374,12 @@ func addTypingNamesAndGetFilesToWatch( } if len(ownTypes) != 0 { absolutePath := manifestPath.Directory().ResolveFile(ownTypes) + filesToWatch = append(filesToWatch, absolutePath.AsPath()) if fs.FileExists(absolutePath) { logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types.", manifest.Name.Value)) inferredTypings[manifest.Name.Value] = absolutePath } else { + *missingTypingFiles = append(*missingTypingFiles, absolutePath) logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types but they are missing.", manifest.Name.Value)) } } else { @@ -280,7 +387,7 @@ func addTypingNamesAndGetFilesToWatch( } } addInferredTypings(fs, logger, inferredTypings, packageNames, " Found package names") - return filesToWatch + return filesToWatch, nil } /** diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index bff1aef4c1cd4..be60f2f9d8cfa 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -15,8 +15,60 @@ import ( "gotest.tools/v3/assert" ) +func TestDiscoveryInputsEqual(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + files []tspath.RootedFilePath + otherFiles []tspath.RootedFilePath + types []string + disableNames bool + caseSensitivity tspath.CaseSensitivity + equal bool + }{ + {name: "same directory", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b.js"}, equal: true}, + {name: "different directories", files: []tspath.RootedFilePath{"/project/a/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b/b.js"}}, + {name: "filename typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.js"}}, + {name: "filename typing suffix", files: []tspath.RootedFilePath{"/project/jquery.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.min.1.2.js"}, equal: true}, + {name: "JSX typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a.jsx"}}, + {name: "disabled filename typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.jsx"}, disableNames: true, equal: true}, + {name: "disabled manifest discovery", files: []tspath.RootedFilePath{"/project/a/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b/b.js"}, types: []string{}, equal: true}, + {name: "case insensitive directories", files: []tspath.RootedFilePath{"/project/A/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a/b.js"}, equal: true}, + {name: "case sensitive directories", files: []tspath.RootedFilePath{"/project/A/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a/b.js"}, caseSensitivity: tspath.CaseSensitive}, + {name: "duplicate directory", files: []tspath.RootedFilePath{"/project/a.js", "/project/b.js"}, otherFiles: []tspath.RootedFilePath{"/project/c.js"}, equal: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + info := &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: test.types}, + TypeAcquisition: &core.TypeAcquisition{DisableFilenameBasedTypeAcquisition: core.BoolToTristate(test.disableNames)}, + } + assert.Equal(t, ata.DiscoveryInputsEqual(info, test.files, test.otherFiles, "/project", test.caseSensitivity), test.equal) + }) + } +} + func TestDiscoverTypings(t *testing.T) { t.Parallel() + t.Run("should reuse cached scoped typings", func(t *testing.T) { + t.Parallel() + const typingFile = "/cache/node_modules/@types/a__b/index.d.ts" + fs := vfstest.FromMap(map[string]string{typingFile: ""}, tspath.CaseSensitive) + cache := &collections.SyncMap[string, *ata.CachedTyping]{} + version := semver.MustParse("1.3.0") + cache.Store("a__b", &ata.CachedTyping{TypingsLocation: typingFile, Version: &version}) + var logger *logging.LogTree + paths, names, _ := ata.DiscoverTypings( + fs, logger, &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: []string{}}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue, Include: []string{"@a/b"}}, + UnresolvedImports: &collections.Set[string]{}, + }, nil, "/project", cache, map[string]map[string]string{"a__b": {"latest": "1.3.0"}}, + ) + assert.DeepEqual(t, paths, []tspath.RootedFilePath{typingFile}) + assert.Equal(t, len(names), 0) + }) + t.Run("should use mappings from safe list", func(t *testing.T) { t.Parallel() logger := logging.NewLogTree("DiscoverTypings") @@ -85,8 +137,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, tspath.CaseInsensitive /*caseSensitivity*/) cache := collections.SyncMap[string, *ata.CachedTyping]{} @@ -95,6 +148,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, @@ -108,7 +165,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, []tspath.RootedFilePath{ diff --git a/tsc/internal/project/ata/installnpmpackages_test.go b/tsc/internal/project/ata/installnpmpackages_test.go index 8f98b96156fd8..51880099d0b03 100644 --- a/tsc/internal/project/ata/installnpmpackages_test.go +++ b/tsc/internal/project/ata/installnpmpackages_test.go @@ -1,13 +1,134 @@ package ata import ( + "context" + "errors" "fmt" "sync/atomic" "testing" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/semver" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type cacheReadCountingFS struct { + vfs.FS + unrelatedReads int +} + +type discoveryWatchCheckingFS struct { + vfs.FS + t *testing.T + watched collections.Set[tspath.PathKey] + checkedReads int +} + +func (fs *discoveryWatchCheckingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + realPath := fs.FS.Realpath(path.AsPath()) + if fs.CaseSensitivity().ComparePaths(path.AsPath(), realPath) != 0 { + assert.Assert(fs.t, fs.watched.Has(fs.CaseSensitivity().PathKey(realPath)), "real discovery inputs must be published before reading them") + fs.checkedReads++ + } + return fs.FS.ReadFile(path) +} + +func TestTypingsInstallerPublishesDiscoveryWatchesBeforeReadingManifests(t *testing.T) { + t.Parallel() + fs := &discoveryWatchCheckingFS{ + t: t, + FS: vfstest.FromMap(map[string]any{ + "/project/package.json": vfstest.Symlink("/vendor/project/package.json"), + "/vendor/project/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/project/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/vendor/foo/package.json": `{"name":"foo","types":"index.d.ts"}`, + "/vendor/foo/index.d.ts": "", + }, tspath.CaseInsensitive), + } + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + var logger *logging.LogTree + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + OnDiscovery: func(inputs []tspath.RootedPath) error { + for _, input := range inputs { + fs.watched.Add(fs.CaseSensitivity().PathKey(input)) + } + return nil + }, + }) + assert.NilError(t, err) + assert.Equal(t, fs.checkedReads, 2) + assert.DeepEqual(t, result.TypingsFiles, []tspath.RootedFilePath{"/project/node_modules/foo/index.d.ts"}) +} + +func TestTypingsInstallerPropagatesDiscoveryPublicationErrors(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{}, tspath.CaseSensitive) + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + var logger *logging.LogTree + publicationError := errors.New("watch publication failed") + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + OnDiscovery: func([]tspath.RootedPath) error { return publicationError }, + }) + assert.ErrorIs(t, err, publicationError) + assert.Assert(t, result == nil) +} + +func (fs *cacheReadCountingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + if path == "/cache/node_modules/@types/unrelated/package.json" { + fs.unrelatedReads++ + } + return fs.FS.ReadFile(path) +} + +func TestTypingsInstallerResolvesOnlyDemandedCacheEntries(t *testing.T) { + t.Parallel() + fs := &cacheReadCountingFS{FS: vfstest.FromMap(map[string]string{ + "/cache/node_modules/@types/node/package.json": `{"name":"@types/node","types":"new.d.ts"}`, + "/cache/node_modules/@types/node/index.d.ts": "", + "/cache/node_modules/@types/node/new.d.ts": "", + "/cache/node_modules/@types/unrelated/package.json": `{"name":"@types/unrelated","types":"index.d.ts"}`, + "/cache/node_modules/@types/unrelated/index.d.ts": "", + }, tspath.CaseSensitive)} + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + version := semver.MustParse("1.3.0") + for _, name := range []string{"node", "unrelated"} { + ti.packageNameToTypingLocation.Store(name, &CachedTyping{ + TypingsLocation: tspath.RootedFilePathFromNormalized("/cache/node_modules/@types/" + name + "/index.d.ts"), Version: &version, + }) + } + ti.typesRegistry = map[string]map[string]string{"node": {"latest": "1.3.0"}, "unrelated": {"latest": "1.3.0"}} + var logger *logging.LogTree + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: []string{}}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue, Include: []string{"node"}}, + UnresolvedImports: &collections.Set[string]{}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + }) + assert.NilError(t, err) + assert.DeepEqual(t, result.TypingsFiles, []tspath.RootedFilePath{"/cache/node_modules/@types/node/new.d.ts"}) + assert.Equal(t, fs.unrelatedReads, 0, "resolving a demanded entry point must not resolve unrelated cached packages") +} + func TestInstallNpmPackages(t *testing.T) { t.Parallel() packageNames := []string{ diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index db1459c760465..89dbee6af23a7 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().AsString(), "@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..37b484fd6b57b 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,25 @@ func mergeFileChangeSummary(dst *FileChangeSummary, src FileChangeSummary) { dst.IncludesWatchChangeOutsideNodeModules = true } } + +func (f FileChangeSummary) withoutChangesWithin(directory tspath.RootedPath, caseSensitivity tspath.CaseSensitivity) FileChangeSummary { + if directory == "" { + return f + } + 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().AsPath() + directoryKey := caseSensitivity.PathKey(directory) + fileNameKey := caseSensitivity.PathKey(fileName) + if directoryKey != fileNameKey && !directoryKey.ContainsPath(fileNameKey) { + 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 f88a09b297a35..02d53280e855a 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -188,6 +188,108 @@ type Project struct { installedTypingsInfo *ata.TypingsInfo // typingsFiles are the root files added by the typings installer. typingsFiles []tspath.RootedFilePath + // installedTypingsFileNames are the JavaScript files used during the most + // recently completed typings installation. + installedTypingsFileNames []tspath.RootedFilePath + // installedTypingsFilesToWatch are discovery inputs whose changes require + // typings discovery to run again. + installedTypingsFilesToWatch []tspath.RootedPath + installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint + installedMissingTypingFiles []tspath.RootedFilePath + // 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 []tspath.RootedFilePath + installedTypingsFilesToWatch []tspath.RootedPath + installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint + installedMissingTypingFiles []tspath.RootedFilePath + typingsFiles []tspath.RootedFilePath + 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()) && + p.typingsDiscoveryInputsEqual(p.installedTypingsFileNames) && + p.ProgramLastUpdate > snapshotID { + snapshotID = p.ProgramLastUpdate + } + return &inferredProjectATAState{ + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), + installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), + installedTypingCacheEntryPoints: slices.Clone(p.installedTypingCacheEntryPoints), + installedMissingTypingFiles: slices.Clone(p.installedMissingTypingFiles), + 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 || len(s.typingsFiles) > 0) { + return false + } + if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || + !project.typingsDiscoveryInputsEqual(s.installedTypingsFileNames) { + return false + } + for _, fileName := range s.typingsFiles { + if !fs.FileExists(fileName, fs.fs.CaseSensitivity().PathKey(fileName.AsPath())) { + return false + } + } + return !slices.ContainsFunc(s.installedMissingTypingFiles, fs.fs.FileExists) +} + +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.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) + project.installedMissingTypingFiles = slices.Clone(s.installedMissingTypingFiles) + 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 && + project.typingsDiscoveryInputsEqual(s.installedTypingsFileNames) +} + +func (s *inferredProjectATAState) applyWatchState(project *Project) { + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Concat( + slices.Clone(s.installedTypingsFilesToWatch), + core.Map(typingDiscoveryFiles(s.typingsFiles, s.installedTypingCacheEntryPoints), func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), + ) + project.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) + project.installedMissingTypingFiles = slices.Clone(s.installedMissingTypingFiles) + project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID } var _ ls.Project = (*Project)(nil) @@ -218,6 +320,7 @@ func NewInferredProject( logger *logging.LogTree, ) *Project { p := NewProject(inferredProjectID.AsID(), KindInferred, projectDirectory, builder, logger) + p.ataInvalidationSnapshotID = builder.inferredProjectATAInvalidationSnapshotID if compilerOptions == nil { compilerOptions = &core.CompilerOptions{ AllowJs: core.TSTrue, @@ -290,10 +393,11 @@ func NewProject( logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), id, projectDirectory)) } project := &Project{ - Kind: kind, - id: id, - projectDirectory: projectDirectory, - dirty: true, + Kind: kind, + id: id, + projectDirectory: projectDirectory, + dirty: true, + ataInvalidationSnapshotID: builder.newSnapshotID, } project.programFilesWatch = NewWatchedFiles( @@ -308,12 +412,7 @@ func NewProject( ), ) 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), @@ -325,6 +424,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() tspath.RootedDirectoryPath { return p.projectDirectory } @@ -435,8 +543,14 @@ 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, + installedTypingCacheEntryPoints: p.installedTypingCacheEntryPoints, + installedMissingTypingFiles: p.installedMissingTypingFiles, + typingsFiles: p.typingsFiles, + ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, + installedTypingsSnapshotID: p.installedTypingsSnapshotID, } } @@ -456,6 +570,14 @@ func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) { p.dirtyFilePath = "" } +func (p *Project) setTypingsFiles(typingsFiles []tspath.RootedFilePath) { + 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 { @@ -675,7 +797,13 @@ func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { return true } - return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) + return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) || + !p.typingsDiscoveryInputsEqual(p.installedTypingsFileNames) +} + +func (p *Project) typingsDiscoveryInputsEqual(fileNames []tspath.RootedFilePath) bool { + info := p.ComputeTypingsInfo() + return ata.DiscoveryInputsEqual(&info, fileNames, p.ComputeTypingsFileNames(), p.projectDirectory, p.host.FS().CaseSensitivity()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { @@ -685,3 +813,17 @@ func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { UnresolvedImports: p.GetUnresolvedImports(), } } + +func (p *Project) ComputeTypingsFileNames() []tspath.RootedFilePath { + if p.Program == nil { + return nil + } + var fileNames []tspath.RootedFilePath + for _, file := range p.Program.GetSourceFiles() { + if file.FileName().HasJSFileExtension() && !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 4f64a3a275095..b036ab0efa646 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 @@ -72,6 +78,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.PathKey) *Project { return c.configuredProjects[ConfiguredProjectIDFromPathKey(path)] } @@ -329,14 +345,16 @@ func (c *ProjectCollection) findDefaultConfiguredProjectWorker(path tspath.PathK // clone creates a shallow copy of the project collection. func (c *ProjectCollection) clone() *ProjectCollection { return &ProjectCollection{ - caseSensitivity: c.caseSensitivity, - configFileRegistry: c.configFileRegistry, - configuredProjects: c.configuredProjects, - syntheticProjects: c.syntheticProjects, - openFiles: c.openFiles, - inferredProject: c.inferredProject, - fileDefaultProjects: c.fileDefaultProjects, - apiState: c.apiState, + caseSensitivity: c.caseSensitivity, + 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 2a190503e8468..cda54d1b161d9 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -14,9 +14,11 @@ 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" + "github.com/microsoft/TypeScript/tsc/internal/project/ata" "github.com/microsoft/TypeScript/tsc/internal/project/dirty" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" @@ -33,11 +35,13 @@ const ( ) type ProjectCollectionBuilder struct { - sessionOptions *SessionOptions - parseCache *ParseCache - contentMappedParseCache *ContentMappedParseCache - extendedConfigCache *ExtendedConfigCache - contentMapperHost contentmapper.Host + sessionOptions *SessionOptions + hasTypingsInstaller bool + cachedTypingEntryPointsAreCurrent func([]ata.CachedTypingEntryPoint) bool + parseCache *ParseCache + contentMappedParseCache *ContentMappedParseCache + extendedConfigCache *ExtendedConfigCache + contentMapperHost contentmapper.Host ctx context.Context fs *snapshotFSBuilder @@ -55,11 +59,13 @@ type ProjectCollectionBuilder struct { defaultProjectsInvalidated bool openFilesChanged bool - fileDefaultProjects map[tspath.PathKey]ID - configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] - syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] - inferredProject *dirty.Box[*Project] - createdPrograms []*Project + fileDefaultProjects map[tspath.PathKey]ID + configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] + syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] + inferredProject *dirty.Box[*Project] + inferredProjectATAState *inferredProjectATAState + inferredProjectATAInvalidationSnapshotID uint64 + createdPrograms []*Project apiState APIState } @@ -76,6 +82,7 @@ func newProjectCollectionBuilder( inferredContentMappers []*contentmapper.Mapper, inferredContentMapperExtensions []string, sessionOptions *SessionOptions, + hasTypingsInstaller bool, customConfigFileName string, parseCache *ParseCache, contentMappedParseCache *ContentMappedParseCache, @@ -85,26 +92,30 @@ func newProjectCollectionBuilder( ) *ProjectCollectionBuilder { openFiles := openFilePaths(overlays) return &ProjectCollectionBuilder{ - ctx: ctx, - fs: fs, - overlays: overlays, - 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.PathKey) 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, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + inferredContentMappers: inferredContentMappers, + inferredContentMapperExtensions: inferredContentMapperExtensions, + sessionOptions: sessionOptions, + hasTypingsInstaller: hasTypingsInstaller, + cachedTypingEntryPointsAreCurrent: ata.NewCachedTypingEntryPointValidator(fs.fs, sessionOptions.TypingsLocation), + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: extendedConfigCache, + contentMapperHost: contentMapperHost, + base: oldProjectCollection, + configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.PathKey) 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, } } @@ -150,6 +161,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 { @@ -471,6 +495,218 @@ 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, + typingDiscoveryFiles(project.typingsFiles, project.installedTypingCacheEntryPoints), + b.fs.fs.CaseSensitivity(), + ) + }, + 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, + typingDiscoveryFiles(b.inferredProjectATAState.typingsFiles, b.inferredProjectATAState.installedTypingCacheEntryPoints), + b.fs.fs.CaseSensitivity(), + ) { + b.invalidateInferredProjectATAState("typings watch changes", logger) + } +} + +func fileChangeSummaryAffectsTypingsWatch( + summary FileChangeSummary, + filesToWatch []tspath.RootedPath, + typingsFiles []tspath.RootedFilePath, + caseSensitivity tspath.CaseSensitivity, +) bool { + if summary.InvalidateAll { + return true + } + if len(filesToWatch) == 0 && len(typingsFiles) == 0 { + return false + } + watchedPaths := slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })) + affectsWatch := func(uri lsproto.DocumentUri, deleted bool) bool { + fileName := uri.FileName().AsPath() + fileNameKey := caseSensitivity.PathKey(fileName) + return slices.ContainsFunc(watchedPaths, func(watchedPath tspath.RootedPath) bool { + watchedPathKey := caseSensitivity.PathKey(watchedPath) + if watchedPathKey == fileNameKey || watchedPathKey.ContainsPath(fileNameKey) || + deleted && fileNameKey.ContainsPath(watchedPathKey) { + return true + } + switch watchedPath.BaseName() { + case "node_modules": + return caseSensitivity.ComparePaths(watchedPath.Directory().ResolveFile("package.json").AsPath(), fileName) == 0 + case "bower_components": + return caseSensitivity.ComparePaths(watchedPath.Directory().ResolveFile("bower.json").AsPath(), fileName) == 0 + } + return false + }) + } + for uri := range summary.Changed.Keys() { + if affectsWatch(uri, false) { + return true + } + } + for uri := range summary.Created.Keys() { + if affectsWatch(uri, false) { + return true + } + } + for uri := range summary.Deleted.Keys() { + if affectsWatch(uri, true) { + return true + } + } + return false +} + +func typingDiscoveryFiles(files []tspath.RootedFilePath, entries []ata.CachedTypingEntryPoint) []tspath.RootedFilePath { + return core.Filter(files, func(file tspath.RootedFilePath) bool { + return !slices.ContainsFunc(entries, func(entry ata.CachedTypingEntryPoint) bool { return entry.FileName == file }) + }) +} + +func (b *ProjectCollectionBuilder) DidChangeCachedTypingEntryPoints(summary FileChangeSummary, logger *logging.LogTree) { + // Cache outputs do not advance the discovery invalidation generation: a + // result delivered with an install's writes can already contain the new entry points. + affected := func(entries []ata.CachedTypingEntryPoint) bool { + return fileChangeSummaryAffectsTypingsWatch(summary, b.cachedTypingWatchInputs(entries), nil, b.fs.fs.CaseSensitivity()) && + !b.cachedTypingEntryPointsAreCurrent(entries) + } + b.forEachProject(func(entry dirty.Value[*Project]) bool { + entry.ChangeIf( + func(project *Project) bool { + return project.installedTypingsInfo != nil && + affected(project.installedTypingCacheEntryPoints) + }, + func(project *Project) { + project.installedTypingsInfo = nil + project.setTypingsFiles(nil) + project.dirty = true + project.dirtyFilePath = "" + }, + ) + return true + }) + if b.inferredProjectATAState != nil && + affected(b.inferredProjectATAState.installedTypingCacheEntryPoints) { + b.clearInferredProjectATAState("cached typing entry points changed", logger) + } +} + +func (b *ProjectCollectionBuilder) cachedTypingWatchInputs(entries []ata.CachedTypingEntryPoint) []tspath.RootedPath { + var paths []tspath.RootedPath + for _, entry := range entries { + directory := b.sessionOptions.TypingsLocation.ResolveDirectory("node_modules/@types/" + entry.PackageName) + paths = append(paths, directory.AsPath(), b.fs.fs.Realpath(directory.AsPath()), + b.fs.fs.Realpath(directory.ResolveFile("package.json").AsPath()), entry.FileName.AsPath()) + } + return paths +} + +func (b *ProjectCollectionBuilder) typingsWatchGlobs(filesToWatch []tspath.RootedPath, typingsFiles []tspath.RootedFilePath, entries []ata.CachedTypingEntryPoint) PatternsAndIgnored { + return getTypingsLocationsGlobs( + slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), b.cachedTypingWatchInputs(entries)), + b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), + ) +} + +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.PathKey, refreshAll bool, logger *logging.LogTree) { project := entry.Value() if project.Program == nil || project.contentMapperWatchedFiles == nil { @@ -820,7 +1056,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, watchOnly bool, 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 { @@ -830,30 +1090,104 @@ 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()) && + p.typingsDiscoveryInputsEqual(ataChange.FileNames) }, 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 - typingsWatchGlobs := getTypingsLocationsGlobs( - ataChange.TypingsFilesToWatch, - b.sessionOptions.TypingsLocation, - b.sessionOptions.CurrentDirectory, - b.fs.fs.CaseSensitivity(), - ) + if !watchOnly { + p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + p.installedMissingTypingFiles = slices.Clone(ataChange.MissingTypingFiles) + p.setTypingsFiles(ataChange.TypingsFiles) + p.dirty = true + p.dirtyFilePath = "" + } + p.installedTypingsSnapshotID = ataChange.SnapshotID + p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + if watchOnly { + p.installedTypingsFilesToWatch = slices.Concat(p.installedTypingsFilesToWatch, ataChange.TypingsFilesToWatch) + slices.Sort(p.installedTypingsFilesToWatch) + p.installedTypingsFilesToWatch = slices.Compact(p.installedTypingsFilesToWatch) + } else { + p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + } + typingsWatchGlobs := b.typingsWatchGlobs(p.installedTypingsFilesToWatch, p.typingsFiles, p.installedTypingCacheEntryPoints) p.typingsWatch = p.typingsWatch.Clone(typingsWatchGlobs) - p.dirty = true - p.dirtyFilePath = "" }, ) } for projectID, ataChange := range ataChanges { - logger.Embed(ataChange.Logs) + if ataChange.Logs != nil { + logger.Embed(ataChange.Logs) + } + if !b.cachedTypingEntryPointsAreCurrent(ataChange.TypingCacheEntryPoints) { + if logger != nil { + logger.Logf("Ignoring ATA state with obsolete cached typing entry points for project %s", projectID) + } + continue + } + 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, core.Map(ataChange.FileNames, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + typingDiscoveryFiles(ataChange.TypingsFiles, ataChange.TypingCacheEntryPoints), + b.fs.fs.CaseSensitivity(), + ) { + 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 slices.ContainsFunc(ataChange.TypingsFiles, func(file tspath.RootedFilePath) bool { return !b.fs.fs.FileExists(file) }) || + slices.ContainsFunc(ataChange.MissingTypingFiles, b.fs.fs.FileExists) { + if logger != nil { + logger.Logf("Ignoring ATA state with changed typing file availability 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 { + state := &inferredProjectATAState{} + if watchOnly && b.inferredProjectATAState != nil { + *state = *b.inferredProjectATAState + } + if !watchOnly { + state.installedTypingsInfo = ataChange.TypingsInfo + state.typingsFiles = slices.Clone(ataChange.TypingsFiles) + state.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + state.installedMissingTypingFiles = slices.Clone(ataChange.MissingTypingFiles) + } + state.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + if watchOnly { + state.installedTypingsFilesToWatch = slices.Concat(state.installedTypingsFilesToWatch, ataChange.TypingsFilesToWatch) + slices.Sort(state.installedTypingsFilesToWatch) + state.installedTypingsFilesToWatch = slices.Compact(state.installedTypingsFilesToWatch) + } else { + state.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + } + state.snapshotID = ataChange.SnapshotID + typingsWatch := newTypingsWatch(b) + if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { + typingsWatch = b.inferredProjectATAState.typingsWatch + } + typingsWatchGlobs := b.typingsWatchGlobs(state.installedTypingsFilesToWatch, state.typingsFiles, state.installedTypingCacheEntryPoints) + state.typingsWatch = typingsWatch.Clone(typingsWatchGlobs) + b.inferredProjectATAState = state + } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { updateProject(project, ataChange) @@ -902,6 +1236,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 @@ -1377,6 +1715,9 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } + if project.Program != nil { + b.inferredProjectATAState = project.inferredProjectATAState() + } b.inferredProject.Delete() return true } @@ -1539,6 +1880,7 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo } project.dirty = false project.dirtyFilePath = "" + b.prepareForTypingsInstallation(project) b.releaseDroppedProjectReferences(oldProgram, result.Program, project.ID()) if oldCheckerPool != nil { oldCheckerPool.Discard() @@ -1553,9 +1895,71 @@ 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) && + b.cachedTypingEntryPointsAreCurrent(state.installedTypingCacheEntryPoints) + }, + func(project *Project) { + state.apply(project) + project.typingsWatch = project.typingsWatch.Clone(b.typingsWatchGlobs( + project.installedTypingsFilesToWatch, project.typingsFiles, project.installedTypingCacheEntryPoints, + )) + }, + ) { + if logger != nil { + logger.Log("Reusing cached inferred project ATA state") + } + filesChanged = b.updateProgram(entry, logger) || filesChanged + } else { + project := entry.Value() + if !project.typingsDiscoveryInputsEqual(state.installedTypingsFileNames) || + (state.installedTypingsInfo != nil && !state.installedTypingsInfo.Equals(project.ComputeTypingsInfo())) { + b.invalidateProjectATAState(projectID) + } + entry.ChangeIf( + func(project *Project) bool { + return state.canApplyWatchState(project, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.applyWatchState(project) + }, + ) + } + } return filesChanged } +func (b *ProjectCollectionBuilder) prepareForTypingsInstallation(project *Project) { + if !b.hasTypingsInstaller || !project.ShouldTriggerATA(b.newSnapshotID) { + return + } + info := project.ComputeTypingsInfo() + if project.installedTypingsInfo != nil && project.installedTypingsInfo.Equals(info) && + project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) { + return + } + // Watch discovery inputs before the first install can finish or the + // project can close, so intervening changes invalidate its generation. + if len(project.installedTypingsFilesToWatch) != 0 && + (!project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) || + (project.installedTypingsInfo != nil && !project.installedTypingsInfo.Equals(info))) { + project.ataInvalidationSnapshotID = b.newSnapshotID + if _, inferred := project.ID().Inferred(); inferred { + b.inferredProjectATAInvalidationSnapshotID = b.newSnapshotID + } + } + project.installedTypingsInfo = nil + project.installedTypingsFileNames = project.ComputeTypingsFileNames() + project.installedTypingsFilesToWatch = ata.DiscoveryWatchInputs(&info, project.installedTypingsFileNames, project.projectDirectory) + project.typingsWatch = project.typingsWatch.Clone(b.typingsWatchGlobs( + project.installedTypingsFilesToWatch, project.typingsFiles, project.installedTypingCacheEntryPoints, + )) +} + func (b *ProjectCollectionBuilder) markFilesChanged(entry dirty.Value[*Project], paths []tspath.PathKey, changeType lsproto.FileChangeType, logger *logging.LogTree) { var dirty bool var dirtyFilePath tspath.PathKey diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index 9af28b85f6d05..52f890bc2efea 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -44,6 +44,7 @@ const ( UpdateReasonDidCloseFile UpdateReasonDidChangeCompilerOptionsForInferredProjects UpdateReasonRequestedLanguageServicePendingChanges + UpdateReasonATADiscovery UpdateReasonRequestedLanguageServiceProjectNotLoaded UpdateReasonRequestedLanguageServiceForFileNotOpen UpdateReasonRequestedLanguageServiceProjectDirty @@ -136,6 +137,8 @@ type Session struct { snapshot *Snapshot snapshotMu sync.RWMutex snapshotUpdateMu sync.Mutex + // Protected by snapshotMu; watch deltas must register in snapshot order. + watchUpdatesDone <-chan struct{} // scheduledSnapshotUpdateCancel is the cancelation function for a scheduled // snapshot update. Snapshot updates are scheduled and debounced after file closes. @@ -1411,10 +1414,17 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.PathKe oldSnapshot.Deref() contentMapperTimings = s.takeContentMapperTimingDelta() } + var previousWatchUpdatesDone <-chan struct{} + var watchUpdatesDone chan struct{} + if s.options.WatchEnabled { + previousWatchUpdatesDone = s.watchUpdatesDone + watchUpdatesDone = make(chan struct{}) + s.watchUpdatesDone = watchUpdatesDone + } s.snapshotMu.Unlock() // Enqueue ATA updates if needed - if s.typingsInstaller != nil && !s.Config().IsATADisabled() { + if len(change.ataDiscoveryChanges) == 0 && s.typingsInstaller != nil && !s.Config().IsATADisabled() { s.triggerATAForUpdatedProjects(newSnapshot) } @@ -1430,10 +1440,26 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.PathKe s.logContentMapperTimings(contentMapperTimings) s.logger.Log("") } + var watchError error if s.options.WatchEnabled { - if err := s.updateWatches(oldSnapshot, newSnapshot); err != nil && s.options.LoggingEnabled { - s.logger.Log(err) + if previousWatchUpdatesDone != nil { + select { + case <-previousWatchUpdatesDone: + // The preceding snapshot's registrations are complete. + case <-ctx.Done(): + watchError = ctx.Err() + } + } + if watchError == nil { + watchError = s.updateWatches(oldSnapshot, newSnapshot) } + if watchError != nil && s.options.LoggingEnabled { + s.logger.Log(watchError) + } + close(watchUpdatesDone) + } + if change.watchUpdatesDone != nil { + change.watchUpdatesDone <- watchError } _ = s.updateContentMapperRegistrations(ctx, newSnapshot) s.publishProgramDiagnostics(oldSnapshot, newSnapshot) @@ -1660,12 +1686,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) { @@ -1676,11 +1706,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() { @@ -1690,6 +1722,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)...) @@ -2024,6 +2063,33 @@ func (s *Session) publishGlobalDiagnostics(ctx context.Context) { } } +func (s *Session) publishATADiscovery(ctx context.Context, projectID ID, change *ATAStateChange) error { + s.snapshotUpdateMu.Lock() + if err := ctx.Err(); err != nil { + s.snapshotUpdateMu.Unlock() + return err + } + snapshot := s.Snapshot() + watchesDone := make(chan error, 1) + snapshotChange := SnapshotChange{ + reason: UpdateReasonATADiscovery, + ataDiscoveryChanges: map[ID]*ATAStateChange{projectID: change}, + watchUpdatesDone: watchesDone, + fileSystemOverride: snapshot.fileSystemOverride, + } + if snapshot.fileSystemOverride { + snapshotChange.fs = snapshot.fs.fs + } + s.updateSnapshot(ctx, snapshot.overlays(), snapshotChange, false) + s.snapshotUpdateMu.Unlock() + select { + case err := <-watchesDone: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { @@ -2034,13 +2100,25 @@ 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) tspath.RootedFilePath { return file.FileName() }), + FileNames: fileNames, ProjectRootPath: project.projectDirectory, FS: s.fs, Logger: logTree, + OnDiscovery: func(filesToWatch []tspath.RootedPath) error { + filesToWatch = slices.Clone(filesToWatch) + slices.Sort(filesToWatch) + filesToWatch = slices.Compact(filesToWatch) + return s.publishATADiscovery(ctx, project.ID(), &ATAStateChange{ + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFilesToWatch: filesToWatch, + }) + }, } projectDisplayName := project.DisplayName(s.options.CurrentDirectory) @@ -2057,16 +2135,32 @@ 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) || + !project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) || + !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) || + !slices.Equal(result.CacheEntryPoints, project.installedTypingCacheEntryPoints) { 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, + TypingCacheEntryPoints: result.CacheEntryPoints, + MissingTypingFiles: result.MissingTypingFiles, + 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 4203b49cc47ad..dbdaa43496684 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -409,21 +409,29 @@ type SnapshotChange struct { contentMapperContributions *ContentMapperContributions newConfig *lsutil.UserPreferences // ataChanges contains ATA-related changes to apply to projects in the new snapshot. - ataChanges map[ID]*ATAStateChange - apiRequest *APISnapshotRequest + ataChanges map[ID]*ATAStateChange + ataDiscoveryChanges map[ID]*ATAStateChange + watchUpdatesDone chan<- error + apiRequest *APISnapshotRequest // cleanFileCache triggers cleaning of cached files not referenced by any open project. cleanFileCache bool } // 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 []tspath.RootedFilePath // TypingsFiles is the new list of typing files for the project. TypingsFiles []tspath.RootedFilePath // TypingsFilesToWatch is the new list of typing files to watch for changes. - TypingsFilesToWatch []tspath.RootedPath - Logs *logging.LogTree + TypingsFilesToWatch []tspath.RootedPath + TypingCacheEntryPoints []ata.CachedTypingEntryPoint + MissingTypingFiles []tspath.RootedFilePath + Logs *logging.LogTree } func (s *Snapshot) Clone( @@ -490,10 +498,17 @@ func (s *Snapshot) Clone( logger.Logf("Reason: DidChangeConfigFile - %v", getDetails()) case UpdateReasonDidChangeContentMapperContributions: logger.Logf("Reason: DidChangeContentMapperContributions - %v", getDetails()) + case UpdateReasonATADiscovery: + logger.Log("Reason: ATADiscovery") } } start := time.Now() + hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() + var unfilteredFileChanges FileChangeSummary + if hadExcessiveWatchEvents || change.fileChanges.Deleted.Len() > 0 { + unfilteredFileChanges = change.fileChanges.Clone() + } inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -514,6 +529,23 @@ func (s *Snapshot) Clone( overlays = layeredFS.Overlays() fs := newSnapshotFSBuilderFromSource(layeredFS, s.fs.cacheFiles, s.fs.cacheDirectories, s.fs.nodeModulesRealpathAliases) 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 + } else if unfilteredFileChanges.Deleted.Len() > 0 { + typingsWatchChanges = typingsWatchChanges.Clone() + for uri := range unfilteredFileChanges.Deleted.Keys() { + typingsWatchChanges.Deleted.Add(uri) + } + } + typingCacheChanges := typingsWatchChanges + if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(typingsLocation.AsPath(), fs.fs.CaseSensitivity()) + if realTypingsLocation := fs.fs.Realpath(typingsLocation.AsPath()); realTypingsLocation != typingsLocation.AsPath() { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(realTypingsLocation, fs.fs.CaseSensitivity()) + } + } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { @@ -539,6 +571,7 @@ func (s *Snapshot) Clone( inferredContentMappers, inferredContentMapperExtensions, store.options, + store.hasTypingsInstaller, customConfigFileName, store.parseCache, store.contentMappedParseCache, @@ -547,22 +580,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 { @@ -572,6 +609,18 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if len(change.ataDiscoveryChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataDiscoveryChanges, typingsWatchChanges, true, logger.Fork("DidDiscoverATAWatchInputs")) + } + if !typingsWatchChanges.IsEmpty() { + projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) + } + if !typingCacheChanges.IsEmpty() { + projectCollectionBuilder.DidChangeCachedTypingEntryPoints(typingCacheChanges, logger.Fork("DidChangeCachedTypingEntryPoints")) + } + if len(change.ataChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, false, logger.Fork("DidUpdateATAState")) + } var apiError error if change.apiRequest != nil { diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 76a1b07d8cf7a..81d33abc60896 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -14,6 +14,73 @@ import ( "gotest.tools/v3/assert" ) +func TestTypingsWatchWithoutDiscoveryDirectories(t *testing.T) { + t.Parallel() + + const file = "/workspace/typings/jquery.d.ts" + for _, changeType := range []lsproto.FileChangeType{ + lsproto.FileChangeTypeCreated, lsproto.FileChangeTypeChanged, lsproto.FileChangeTypeDeleted, + } { + summary := FileChangeSummary{} + uri := lsproto.DocumentUri("file://" + file) + switch changeType { + case lsproto.FileChangeTypeCreated: + summary.Created.Add(uri) + case lsproto.FileChangeTypeChanged: + summary.Changed.Add(uri) + case lsproto.FileChangeTypeDeleted: + summary.Deleted.Add(uri) + } + assert.Assert(t, fileChangeSummaryAffectsTypingsWatch(summary, nil, []tspath.RootedFilePath{file}, tspath.CaseInsensitive)) + assert.Assert(t, !fileChangeSummaryAffectsTypingsWatch(summary, nil, nil, tspath.CaseInsensitive)) + } +} + +func TestTypingsWatchAncestorDeletion(t *testing.T) { + t.Parallel() + for _, path := range []tspath.RootedPath{"/workspace/project/bower.json", "/workspace/project/node_modules"} { + for _, deleted := range []string{"/workspace", "/workspace/project", "/workspace/project-other", "/workspace/other"} { + summary := FileChangeSummary{} + summary.Deleted.Add(lsproto.DocumentUri("file://" + deleted)) + assert.Equal(t, fileChangeSummaryAffectsTypingsWatch(summary, []tspath.RootedPath{path}, nil, tspath.CaseInsensitive), + deleted == "/workspace" || deleted == "/workspace/project") + } + } +} + +func TestTypingsWatchBatchAllocations(t *testing.T) { //nolint:paralleltest // allocation counting requires sequential execution + var events collections.Set[lsproto.DocumentUri] + for i := range 500 { + events.Add(lsproto.DocumentUri(fmt.Sprintf("file:///unrelated/file%d.js", i))) + } + var filesToWatch []tspath.RootedPath + var typingsFiles []tspath.RootedFilePath + for i := range 16 { + filesToWatch = append(filesToWatch, tspath.RootedFilePathFromNormalized(fmt.Sprintf("/workspace/package%d/package.json", i)).AsPath()) + typingsFiles = append(typingsFiles, tspath.RootedFilePathFromNormalized(fmt.Sprintf("/workspace/package%d/index.d.ts", i))) + } + for _, kind := range []string{"changed", "created", "deleted"} { //nolint:paralleltest // allocation counting requires sequential execution + t.Run(kind, func(t *testing.T) { + summary := FileChangeSummary{} + switch kind { + case "changed": + summary.Changed = events + case "created": + summary.Created = events + case "deleted": + summary.Deleted = events + } + allocations := testing.AllocsPerRun(10, func() { + if fileChangeSummaryAffectsTypingsWatch(summary, filesToWatch, typingsFiles, tspath.CaseSensitive) { + t.Fatal("unrelated events must not affect the typings watch") + } + }) + // URI decoding allocates once per event; building the watch list should not. + assert.Assert(t, allocations <= float64(events.Len()+4), "watch-list allocations must not scale with the event count: %v", allocations) + }) + } +} + func TestSnapshot(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -37,6 +104,47 @@ func TestSnapshot(t *testing.T) { return session } + t.Run("discarding provisional discovery coverage invalidates older requests", func(t *testing.T) { + t.Parallel() + session := setup(map[string]any{ + "/x/app.js": "", + "/x/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/y/app.js": "", + "/typings/foo.d.ts": "declare const foo: number;", + }) + defer session.Close() + session.hasTypingsInstaller = true + ctx := context.Background() + session.DidOpenFile(ctx, "file:///x/app.js", 1, "", lsproto.LanguageKindJavaScript) + firstSnapshotID := session.Snapshot().ID() + firstProject := session.Snapshot().ProjectCollection.InferredProject() + firstInfo := firstProject.ComputeTypingsInfo() + firstFiles := firstProject.ComputeTypingsFileNames() + session.DidCloseFile(ctx, "file:///x/app.js") + session.DidOpenFile(ctx, "file:///y/app.js", 1, "", lsproto.LanguageKindJavaScript) + assert.Assert(t, session.Snapshot().ProjectCollection.inferredProjectATAInvalidationSnapshotID > firstSnapshotID, + "discarding the x discovery watches must invalidate requests dispatched under them") + assert.NilError(t, session.fs.WriteFile("/x/package.json", `{}`)) + snapshot, err := session.APIUpdate(ctx, FileChangeSummary{ + Changed: *collections.NewSetFromItems(lsproto.DocumentUri("file:///x/package.json")), + }, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidCloseFile(ctx, "file:///y/app.js") + session.DidOpenFile(ctx, "file:///x/app.js", 1, "", lsproto.LanguageKindJavaScript) + session.pendingATAChanges[firstProject.ID()] = &ATAStateChange{ + SnapshotID: firstSnapshotID, + TypingsInfo: &firstInfo, + FileNames: firstFiles, + TypingsFiles: []tspath.RootedFilePath{"/typings/foo.d.ts"}, + } + snapshot, err = session.APIUpdate(ctx, FileChangeSummary{}, nil) + assert.NilError(t, err) + defer snapshot.Deref() + assert.Assert(t, snapshot.ProjectCollection.InferredProject().installedTypingsInfo == nil, + "returning to x must not make its obsolete result valid again") + }) + t.Run("creates and removes synthetic programs", func(t *testing.T) { t.Parallel() session := setup(map[string]any{ diff --git a/tsc/internal/project/snapshothost.go b/tsc/internal/project/snapshothost.go index 7ca6e02444c06..158809cc237b9 100644 --- a/tsc/internal/project/snapshothost.go +++ b/tsc/internal/project/snapshothost.go @@ -27,6 +27,7 @@ type SnapshotHost struct { extendedConfigCache *ExtendedConfigCache programCounter *programCounter contentMapperHost contentmapper.Host + hasTypingsInstaller bool snapshotID atomic.Uint64 } @@ -93,6 +94,7 @@ func NewSnapshotHost(init *SessionInit) *SnapshotHost { extendedConfigCache: NewExtendedConfigCache(), programCounter: &programCounter{}, contentMapperHost: newContentMapperHost(init), + hasTypingsInstaller: init.Options.TypingsLocation != "" && init.NpmExecutor != nil, } } diff --git a/tsc/internal/project/watchtimeout_test.go b/tsc/internal/project/watchtimeout_test.go index 129749dd0e1bb..f36a480f24f4e 100644 --- a/tsc/internal/project/watchtimeout_test.go +++ b/tsc/internal/project/watchtimeout_test.go @@ -2,6 +2,7 @@ package project_test import ( "context" + "strings" "sync" "sync/atomic" "testing" @@ -12,9 +13,79 @@ import ( "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 TestATADiscoveryWaitsForSharedWatchRegistration(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + synctest.Test(t, func(t *testing.T) { + files := map[string]any{ + "/user/username/projects/p1/app.js": "", + "/user/username/projects/p1/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/p1/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/user/username/projects/p1/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/user/username/projects/p2/app.js": "", + "/user/username/projects/p2/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/p2/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/user/username/projects/p2/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/vendor/foo/package.json": `{"name":"foo"}`, + } + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": "declare const foo: number;"}, + }) + init.Options.CurrentDirectory = "/user/username/projects" + registered := make(chan struct{}) + var pending atomic.Bool + var once sync.Once + release := func() { once.Do(func() { close(registered) }) } + utils.Client().WatchFilesFunc = func(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { + for _, watcher := range watchers { + external := watcher.GlobPattern.Pattern != nil && strings.HasPrefix(*watcher.GlobPattern.Pattern, "/vendor/") + if pattern := watcher.GlobPattern.RelativePattern; pattern != nil && pattern.BaseUri.URI != nil { + external = strings.HasPrefix(string(*pattern.BaseUri.URI), "file:///vendor") + } + if external && pending.CompareAndSwap(false, true) { + select { + case <-registered: + // Complete the simulated registration. + case <-ctx.Done(): + return ctx.Err() + } + } + } + return nil + } + var installs atomic.Int32 + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + for _, arg := range args { + if arg == "@types/foo@latest" { + installs.Add(1) + } + } + return install(ctx, cwd, args) + } + session := project.NewSession(init) + defer session.Close() + defer release() + ctx := context.Background() + session.DidOpenFile(ctx, "file:///user/username/projects/p1/app.js", 1, "", lsproto.LanguageKindJavaScript) + synctest.Wait() + assert.Assert(t, pending.Load(), "the first external watch registration must be pending") + session.DidOpenFile(ctx, "file:///user/username/projects/p2/app.js", 1, "", lsproto.LanguageKindJavaScript) + synctest.Wait() + assert.Equal(t, installs.Load(), int32(0), "sharing an in-flight registration must not release another discovery publisher") + release() + session.WaitForBackgroundTasks() + assert.Assert(t, installs.Load() > 0) + }) +} + func TestUpdateWatchTimeoutAndRollback(t *testing.T) { t.Parallel() if !bundled.Embedded {