From e66f2366cfac64bec3ded5e8d1ce9cb28ac24ef2 Mon Sep 17 00:00:00 2001 From: Gavin Kline Date: Sat, 10 Oct 2026 10:45:58 -1000 Subject: [PATCH] Cache exports and paths lookups when generating module specifiers Generating a specifier for a file in a package with an "exports" map scanned every entry of the map, and lookups through a "paths" or "typesVersions" table resolved every pattern against the table's base directory and recomputed the candidate specifiers for each one. The nodebuilder caches only the final specifier per importing file, so declaration emit repeated these scans for every file that referenced the same target, which dominated emit on builds with large exports maps or paths tables. - ModuleSpecifierGenerationHost gains ModuleSpecifierGenerationCache, which may return nil. Program owns a GenerationCache and its emit host forwards to it. The cache memoizes the subpath an "exports" map publishes a target under, keyed by the parsed package.json, package directory and name, target file, resolution mode and compiler options, and the resolved patterns of each paths table, keyed by the table and its base directory. - Candidate specifiers, and the target's emitted file names for "exports" and "imports" lookups, are computed once per lookup instead of once per entry; an entry's specifier is built only when it matches. - GetVersionPaths returns VersionPaths by value and GetPaths built the table lazily on each copy, so module resolution rebuilt it on every resolution and it had no stable identity to cache by. It is now built once when the version paths are selected. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- tsc/internal/compiler/emitHost.go | 5 + tsc/internal/compiler/program.go | 7 + tsc/internal/ls/autoimport/aliasresolver.go | 7 + tsc/internal/modulespecifiers/cache.go | 119 ++++++++ tsc/internal/modulespecifiers/cache_test.go | 281 ++++++++++++++++++ tsc/internal/modulespecifiers/specifiers.go | 281 ++++++++++-------- .../modulespecifiers/specifiers_test.go | 32 +- tsc/internal/modulespecifiers/types.go | 3 + tsc/internal/packagejson/cache.go | 3 + tsc/internal/packagejson/packagejson_test.go | 22 ++ .../tstransforms/importelision_test.go | 5 + 11 files changed, 622 insertions(+), 143 deletions(-) create mode 100644 tsc/internal/modulespecifiers/cache.go create mode 100644 tsc/internal/modulespecifiers/cache_test.go diff --git a/tsc/internal/compiler/emitHost.go b/tsc/internal/compiler/emitHost.go index 46c160faa67fa..b09739fffefc5 100644 --- a/tsc/internal/compiler/emitHost.go +++ b/tsc/internal/compiler/emitHost.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/checker" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/outputpaths" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/printer" @@ -76,6 +77,10 @@ func (host *emitHost) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return host.program.GetPackageJsonInfo(pkgJsonPath) } +func (host *emitHost) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return host.program.ModuleSpecifierGenerationCache() +} + func (host *emitHost) GetSourceOfProjectReferenceIfOutputIncluded(file ast.HasFileName) tspath.RootedFilePath { return host.program.GetSourceOfProjectReferenceIfOutputIncluded(file) } diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 2c7aa08030d8a..fdc2b050f5c66 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -118,6 +118,8 @@ type Program struct { declarationDiagnosticCache collections.SyncMap[*ast.SourceFile, []*ast.Diagnostic] + moduleSpecifierGenerationCache modulespecifiers.GenerationCache + programDiagnostics []*ast.Diagnostic hasEmitBlockingDiagnostics collections.Set[tspath.PathKey] contentMapperOptionDiagnostics []*ast.Diagnostic @@ -173,6 +175,11 @@ func (p *Program) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.Root return "" } +// ModuleSpecifierGenerationCache implements modulespecifiers.ModuleSpecifierGenerationHost. +func (p *Program) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return &p.moduleSpecifierGenerationCache +} + // GetPackageJsonInfo implements checker.Program. func (p *Program) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *packagejson.InfoCacheEntry { directory := pkgJsonPath.Directory() diff --git a/tsc/internal/ls/autoimport/aliasresolver.go b/tsc/internal/ls/autoimport/aliasresolver.go index f34db1590fc0e..0e27206b19214 100644 --- a/tsc/internal/ls/autoimport/aliasresolver.go +++ b/tsc/internal/ls/autoimport/aliasresolver.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/symlinks" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" @@ -186,6 +187,12 @@ func (r *aliasResolver) GetJSXRuntimeImportSpecifier(path tspath.PathKey) (modul return "", nil } +// ModuleSpecifierGenerationCache implements checker.Program. Module specifier generation needs host +// methods the alias resolver does not implement, so it keeps no cache. +func (r *aliasResolver) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return nil +} + // GetNearestAncestorDirectoryWithPackageJson implements checker.Program. func (r *aliasResolver) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath { panic("unimplemented") diff --git a/tsc/internal/modulespecifiers/cache.go b/tsc/internal/modulespecifiers/cache.go new file mode 100644 index 0000000000000..21a3e040b7e7d --- /dev/null +++ b/tsc/internal/modulespecifiers/cache.go @@ -0,0 +1,119 @@ +package modulespecifiers + +import ( + "strings" + + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +// GenerationCache holds results that a host reuses across module specifier generation. Finding the +// subpath that a package.json "exports" map assigns to a file, and resolving the patterns of a "paths" +// or "typesVersions" table, each scan a whole table, and the same table is consulted once per file that +// needs a specifier, so hosts that generate many specifiers keep one of these. Entries are keyed by the +// table they were derived from and the compiler options, so neither a re-read package.json nor a +// different set of options is served stale results. The zero value is ready to use and safe for +// concurrent use. +type GenerationCache struct { + exports collections.SyncMap[packageExportsSpecifierKey, tspath.ModuleSpecifier] + pathPatterns collections.SyncMap[pathPatternsKey, []pathPattern] +} + +type packageExportsSpecifierKey struct { + options *core.CompilerOptions // conditions, custom conditions, and output extensions + packageJson *packagejson.PackageJson + packageDirectory tspath.RootedDirectoryPath + packageName string + targetFileName tspath.RootedFilePath + mode core.ResolutionMode +} + +// getModuleNameFromExports returns the specifier under which packageJson's "exports" map publishes +// targetFileName, or "" when it is not exported, consulting the host's cache when it has one. +func getModuleNameFromExports( + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + targetFileName tspath.RootedFilePath, + packageDirectory tspath.RootedDirectoryPath, + packageName string, + packageJson *packagejson.PackageJson, + mode core.ResolutionMode, +) tspath.ModuleSpecifier { + cache := host.ModuleSpecifierGenerationCache() + if cache == nil { + return tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + } + key := packageExportsSpecifierKey{ + options: options, + packageJson: packageJson, + packageDirectory: packageDirectory, + packageName: packageName, + targetFileName: targetFileName, + mode: mode, + } + if specifier, ok := cache.exports.Load(key); ok { + return specifier + } + specifier := tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + cache.exports.Store(key, specifier) + return specifier +} + +type pathPatternsKey struct { + paths *collections.OrderedMap[string, []string] + baseDirectory tspath.RootedDirectoryPath +} + +// pathPattern is one pattern of a "paths" or "typesVersions" table, resolved against the table's base +// directory and split at its wildcard. +type pathPattern struct { + key string // the table key the pattern belongs to + pattern string + prefix string // the pattern before its "*", or the whole pattern when it has none + suffix string // the pattern after its "*" + hasWildcard bool + hasExtension bool +} + +// getPathPatterns returns the patterns of paths in table order, resolved against baseDirectory. They +// depend only on the table, so they are resolved once per host when the host keeps a cache. The cache +// is keyed by the table's identity, so paths must be a table that lives as long as its source: the +// compiler options' "paths", or the "typesVersions" table of a parsed package.json. +func getPathPatterns(host ModuleSpecifierGenerationHost, paths *collections.OrderedMap[string, []string], baseDirectory tspath.RootedDirectoryPath) []pathPattern { + cache := host.ModuleSpecifierGenerationCache() + if cache == nil { + return resolvePathPatterns(paths, baseDirectory, host.CaseSensitivity()) + } + key := pathPatternsKey{paths: paths, baseDirectory: baseDirectory} + if patterns, ok := cache.pathPatterns.Load(key); ok { + return patterns + } + patterns, _ := cache.pathPatterns.LoadOrStore(key, resolvePathPatterns(paths, baseDirectory, host.CaseSensitivity())) + return patterns +} + +func resolvePathPatterns(paths *collections.OrderedMap[string, []string], baseDirectory tspath.RootedDirectoryPath, caseSensitivity tspath.CaseSensitivity) []pathPattern { + patterns := make([]pathPattern, 0, paths.Size()) + for key, values := range paths.Entries() { + for _, patternText := range values { + normalized := tspath.NormalizePath(patternText) + pattern := resolvePathPatternIfInSameVolume(normalized, baseDirectory, caseSensitivity) + if len(pattern) == 0 { + pattern = normalized + } + prefix, suffix, hasWildcard := strings.Cut(pattern, "*") + patterns = append(patterns, pathPattern{ + key: key, + pattern: pattern, + prefix: prefix, + suffix: suffix, + hasWildcard: hasWildcard, + hasExtension: len(tspath.TryGetExtensionFromPath(pattern)) > 0, + }) + } + } + return patterns +} diff --git a/tsc/internal/modulespecifiers/cache_test.go b/tsc/internal/modulespecifiers/cache_test.go new file mode 100644 index 0000000000000..75d28d4d6cb64 --- /dev/null +++ b/tsc/internal/modulespecifiers/cache_test.go @@ -0,0 +1,281 @@ +package modulespecifiers + +import ( + "fmt" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +type cachingModuleSpecifierGenerationHost struct { + mockModuleSpecifierGenerationHost + cache GenerationCache +} + +func (h *cachingModuleSpecifierGenerationHost) ModuleSpecifierGenerationCache() *GenerationCache { + return &h.cache +} + +func parsePackageJson(t testing.TB, content string) *packagejson.PackageJson { + t.Helper() + fields, err := packagejson.Parse([]byte(content)) + if err != nil { + t.Fatal(err) + } + return &packagejson.PackageJson{Fields: fields, Parseable: true} +} + +func TestGenerationCacheExports(t *testing.T) { + t.Parallel() + + packageJson := parsePackageJson(t, `{ + "name": "pkg", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./feature": { + "import": { "types": "./dist/esm/feature.d.mts" }, + "require": { "types": "./dist/cjs/feature.d.cts" } + }, + "./source": { "source": "./src/source.ts", "default": "./dist/source.js" }, + "./utils/*": { "types": "./dist/utils/*.d.ts" } + } + }`) + const packageDirectory = tspath.RootedDirectoryPath("/node_modules/pkg") + nodeNext := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + withSourceCondition := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext, CustomConditions: []string{"source"}} + + tests := []struct { + name string + target tspath.RootedFilePath + options *core.CompilerOptions + mode core.ResolutionMode + want string + }{ + {"root", "/node_modules/pkg/dist/index.d.ts", nodeNext, core.ResolutionModeESM, "pkg"}, + {"import condition", "/node_modules/pkg/dist/esm/feature.d.mts", nodeNext, core.ResolutionModeESM, "pkg/feature"}, + {"import condition from require", "/node_modules/pkg/dist/esm/feature.d.mts", nodeNext, core.ResolutionModeCommonJS, ""}, + {"require condition", "/node_modules/pkg/dist/cjs/feature.d.cts", nodeNext, core.ResolutionModeCommonJS, "pkg/feature"}, + {"custom condition", "/node_modules/pkg/src/source.ts", withSourceCondition, core.ResolutionModeESM, "pkg/source"}, + {"custom condition not enabled", "/node_modules/pkg/src/source.ts", nodeNext, core.ResolutionModeESM, ""}, + {"pattern", "/node_modules/pkg/dist/utils/strings.d.ts", nodeNext, core.ResolutionModeESM, "pkg/utils/strings"}, + {"not exported", "/node_modules/pkg/dist/internal.d.ts", nodeNext, core.ResolutionModeESM, ""}, + } + + uncached := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive} + cached := &cachingModuleSpecifierGenerationHost{mockModuleSpecifierGenerationHost: *uncached} + // Run every case twice against one cache, so that the second pass is answered from entries the + // other cases stored and would expose a key that omits the options, mode, or target. + for pass := range 2 { + for _, tt := range tests { + uncachedResult := getModuleNameFromExports(tt.options, uncached, tt.target, packageDirectory, "pkg", packageJson, tt.mode) + if uncachedResult.AsString() != tt.want { + t.Errorf("%s: uncached lookup = %q, want %q", tt.name, uncachedResult, tt.want) + } + if cachedResult := getModuleNameFromExports(tt.options, cached, tt.target, packageDirectory, "pkg", packageJson, tt.mode); cachedResult.AsString() != tt.want { + t.Errorf("%s (pass %d): cached lookup = %q, want %q", tt.name, pass, cachedResult, tt.want) + } + } + } + + // A re-read package.json is a different *PackageJson and must not be answered from the old entries. + reread := parsePackageJson(t, `{ "name": "pkg", "exports": { "./moved": "./dist/index.d.ts" } }`) + if got := getModuleNameFromExports(nodeNext, cached, "/node_modules/pkg/dist/index.d.ts", packageDirectory, "pkg", reread, core.ResolutionModeESM); got.AsString() != "pkg/moved" { + t.Errorf("lookup after re-reading package.json = %q, want %q", got, "pkg/moved") + } +} + +func TestGenerationCachePathPatterns(t *testing.T) { + t.Parallel() + + paths := collections.NewOrderedMapWithSizeHint[string, []string](4) + paths.Set("@org/lib", []string{"./libs/lib/src/index.ts"}) + paths.Set("@org/lib/*", []string{"./libs/lib/src/*"}) + paths.Set("@app/*", []string{"./dist/*.d.ts"}) + paths.Set("@abs/*", []string{"/repo/shared/*"}) + options := &core.CompilerOptions{Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler} + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal, ModuleSpecifierEndingIndex, ModuleSpecifierEndingJsExtension} + + tests := []struct { + name string + baseDirectory tspath.RootedDirectoryPath + target tspath.RootedFilePath + relativeToBaseUrl string + want string + }{ + {"exact entry", "/repo", "/repo/libs/lib/src/index.ts", "libs/lib/src/index.ts", "@org/lib"}, + {"wildcard entry", "/repo", "/repo/libs/lib/src/models/user.ts", "libs/lib/src/models/user.ts", "@org/lib/models/user"}, + {"pattern with extension", "/repo", "/repo/dist/haha.d.ts", "dist/haha.d.ts", "@app/haha"}, + {"not mapped", "/repo", "/repo/other/file.ts", "other/file.ts", ""}, + // An absolute pattern resolves differently against each base directory, so these two would + // disagree if the resolved patterns were shared between base directories. + {"absolute pattern from its own root", "/repo", "/repo/shared/util.ts", "shared/util.ts", "@abs/util"}, + {"absolute pattern from a sibling", "/repo/packages/app", "/repo/shared/util.ts", "../../shared/util.ts", "@abs/util"}, + } + + uncached := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive, existingFiles: map[tspath.RootedFilePath]bool{}} + cached := &cachingModuleSpecifierGenerationHost{mockModuleSpecifierGenerationHost: *uncached} + for pass := range 2 { + for _, tt := range tests { + if got := tryGetModuleNameFromPaths(tt.relativeToBaseUrl, tt.target, paths, allowedEndings, tt.baseDirectory, uncached, options); got != tt.want { + t.Errorf("%s: uncached lookup = %q, want %q", tt.name, got, tt.want) + } + if got := tryGetModuleNameFromPaths(tt.relativeToBaseUrl, tt.target, paths, allowedEndings, tt.baseDirectory, cached, options); got != tt.want { + t.Errorf("%s (pass %d): cached lookup = %q, want %q", tt.name, pass, got, tt.want) + } + } + } +} + +func TestGenerationCacheExportsKeyedByPackage(t *testing.T) { + t.Parallel() + + // One parsed package.json reached through different package directories and under different names, + // as when a package is symlinked or installed under an alias. + packageJson := parsePackageJson(t, `{ "exports": { "./sub": "./dist/sub.js" } }`) + options := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + + tests := []struct { + name string + packageDirectory tspath.RootedDirectoryPath + packageName string + target tspath.RootedFilePath + want string + }{ + {"own directory", "/a/node_modules/pkg", "pkg", "/a/node_modules/pkg/dist/sub.js", "pkg/sub"}, + {"other directory, same name", "/a/node_modules/alias", "pkg", "/a/node_modules/pkg/dist/sub.js", ""}, + {"other directory and name", "/a/node_modules/alias", "alias", "/a/node_modules/pkg/dist/sub.js", ""}, + {"alias directory", "/a/node_modules/alias", "alias", "/a/node_modules/alias/dist/sub.js", "alias/sub"}, + {"own directory, other name", "/a/node_modules/pkg", "renamed", "/a/node_modules/pkg/dist/sub.js", "renamed/sub"}, + } + + host := &cachingModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive} + for pass := range 2 { + for _, tt := range tests { + if got := getModuleNameFromExports(options, host, tt.target, tt.packageDirectory, tt.packageName, packageJson, core.ResolutionModeESM); got.AsString() != tt.want { + t.Errorf("%s (pass %d): cached lookup = %q, want %q", tt.name, pass, got, tt.want) + } + } + } +} + +func TestGenerationCachePathPatternsKeyedByTable(t *testing.T) { + t.Parallel() + + // Two tables with the same base directory, such as the "paths" of two projects in one directory. + first := collections.NewOrderedMapWithSizeHint[string, []string](1) + first.Set("@first/*", []string{"./src/*"}) + second := collections.NewOrderedMapWithSizeHint[string, []string](1) + second.Set("@second/*", []string{"./src/*"}) + options := &core.CompilerOptions{Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler} + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal} + + tests := []struct { + paths *collections.OrderedMap[string, []string] + want string + }{ + {first, "@first/util"}, + {second, "@second/util"}, + } + + host := &cachingModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive, existingFiles: map[tspath.RootedFilePath]bool{}} + for pass := range 2 { + for _, tt := range tests { + if got := tryGetModuleNameFromPaths("src/util.ts", "/repo/src/util.ts", tt.paths, allowedEndings, "/repo", host, options); got != tt.want { + t.Errorf("pass %d: cached lookup = %q, want %q", pass, got, tt.want) + } + } + } +} + +func TestGenerationCacheTypesVersionsBounded(t *testing.T) { + t.Parallel() + + packageJson := parsePackageJson(t, `{ "name": "pkg", "typesVersions": { "*": { "*": ["./types/*"] } } }`) + options := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal, ModuleSpecifierEndingIndex} + + host := &cachingModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive, existingFiles: map[tspath.RootedFilePath]bool{}} + const lookups = 50 + for range lookups { + // A fresh copy of the version paths each time, as tryDirectoryWithPackageJson gets. + versionPaths := packageJson.GetVersionPaths(nil) + if got := tryGetModuleNameFromPaths("types/a.d.ts", "/node_modules/pkg/types/a.d.ts", versionPaths.GetPaths(), allowedEndings, "/node_modules/pkg", host, options); got != "a" { + t.Fatalf("lookup through typesVersions = %q, want %q", got, "a") + } + } + entries := 0 + host.cache.pathPatterns.Range(func(pathPatternsKey, []pathPattern) bool { + entries++ + return true + }) + if entries != 1 { + t.Errorf("cache holds %d resolved pattern tables after %d lookups through one typesVersions table, want 1", entries, lookups) + } +} + +// exportsMapWithSubpaths returns a package.json with count conditional subpath exports, shaped like +// the exports map of a package that publishes one entry point per module. +func exportsMapWithSubpaths(count int) string { + var b strings.Builder + b.WriteString(`{ "name": "pkg", "exports": { ".": { "types": "./dist/dts/index.d.ts", "import": "./dist/esm/index.js", "default": "./dist/cjs/index.js" }`) + for i := range count { + fmt.Fprintf(&b, `, "./Module%d": { "types": "./dist/dts/Module%d.d.ts", "import": "./dist/esm/Module%d.js", "default": "./dist/cjs/Module%d.js" }`, i, i, i, i) + } + b.WriteString(` } }`) + return b.String() +} + +func BenchmarkTryGetModuleNameFromExports(b *testing.B) { + const subpaths = 180 + packageJson := parsePackageJson(b, exportsMapWithSubpaths(subpaths)) + options := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + conditions := module.GetConditions(options, core.ResolutionModeESM) + host := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive} + target := tspath.RootedFilePathFromNormalized(fmt.Sprintf("/Users/Dev/project/node_modules/pkg/dist/dts/Module%d.d.ts", subpaths-1)) + packageDirectory := tspath.RootedDirectoryPath("/Users/Dev/project/node_modules/pkg") + want := tspath.ToModuleSpecifier(fmt.Sprintf("pkg/Module%d", subpaths-1)) + + b.ReportAllocs() + for b.Loop() { + if got := tryGetModuleNameFromExports(options, host, target, packageDirectory, "pkg", packageJson.Fields.Exports, conditions); got != want { + b.Fatalf("got %q, want %q", got, want) + } + } +} + +func BenchmarkTryGetModuleNameFromPaths(b *testing.B) { + benchmarkTryGetModuleNameFromPaths(b, &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive, existingFiles: map[tspath.RootedFilePath]bool{}}) +} + +func BenchmarkTryGetModuleNameFromPathsCached(b *testing.B) { + benchmarkTryGetModuleNameFromPaths(b, &cachingModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive, existingFiles: map[tspath.RootedFilePath]bool{}}) +} + +// benchmarkTryGetModuleNameFromPaths looks up a file through a "paths" table shaped like the one in an Nx +// workspace, with an exact and a wildcard entry per library, where the file's library comes last. +func benchmarkTryGetModuleNameFromPaths(b *testing.B, host ModuleSpecifierGenerationHost) { + const libraries = 200 + paths := collections.NewOrderedMapWithSizeHint[string, []string](2 * libraries) + for i := range libraries { + paths.Set(fmt.Sprintf("@org/lib%d", i), []string{fmt.Sprintf("./libs/lib%d/src/index.ts", i)}) + paths.Set(fmt.Sprintf("@org/lib%d/*", i), []string{fmt.Sprintf("./libs/lib%d/src/*", i)}) + } + options := &core.CompilerOptions{Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler} + baseDirectory := tspath.RootedDirectoryPath("/Users/Dev/workspace") + target := tspath.RootedFilePathFromNormalized(fmt.Sprintf("/Users/Dev/workspace/libs/lib%d/src/models/user.ts", libraries-1)) + relativeToBaseUrl := fmt.Sprintf("libs/lib%d/src/models/user.ts", libraries-1) + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal, ModuleSpecifierEndingIndex, ModuleSpecifierEndingJsExtension} + want := fmt.Sprintf("@org/lib%d/models/user", libraries-1) + + b.ReportAllocs() + for b.Loop() { + if got := tryGetModuleNameFromPaths(relativeToBaseUrl, target, paths, allowedEndings, baseDirectory, host, options); got != want { + b.Fatalf("got %q, want %q", got, want) + } + } +} diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 96357d5d77005..65e66f725397a 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -896,18 +896,16 @@ func tryDirectoryWithPackageJson( importMode = core.ResolutionModeESM } - conditions := module.GetConditions(options, importMode) - var fromExports tspath.ModuleSpecifier if packageJsonContent != nil && packageJsonContent.Fields.Exports.Type != packagejson.JSONValueTypeNotPresent { - fromExports = tryGetModuleNameFromExports( + fromExports = getModuleNameFromExports( options, host, pathObj.FileName, packageRootDirectory, packageName, - packageJsonContent.Fields.Exports, - conditions, + packageJsonContent, + importMode, ) } if len(fromExports) > 0 { @@ -1009,6 +1007,7 @@ func tryGetModuleNameFromExports( exports packagejson.ExportsOrImports, conditions []string, ) tspath.ModuleSpecifier { + target := newExportsOrImportsTarget(targetFileName, options, host, false /*isImports*/, false /*preferTsExtension*/) if exports.IsSubpaths() { // sub-mappings // 3 cases: @@ -1016,14 +1015,13 @@ func tryGetModuleNameFromExports( // * pattern mappings (contains a *) // * exact mappings (no *, does not end with /) for k, subk := range exports.AsObject().Entries() { - subPackageName := tspath.ResolvePathWithoutTrailingDirectorySeparator(packageName, k) mode := MatchingModeExact if strings.HasSuffix(k, "/") { mode = MatchingModeDirectory } else if strings.Contains(k, "*") { mode = MatchingModePattern } - result := tryGetModuleNameFromExportsOrImports(options, host, targetFileName, packageDirectory, subPackageName, subk, conditions, mode /*isImports*/, false /*preferTsExtension*/, false) + result := tryGetModuleNameFromExportsOrImports(options, host, &target, packageDirectory, packageName, k, subk, conditions, mode) if len(result) > 0 { return tspath.ToModuleSpecifier(result) } @@ -1032,14 +1030,13 @@ func tryGetModuleNameFromExports( return tspath.ToModuleSpecifier(tryGetModuleNameFromExportsOrImports( options, host, - targetFileName, + &target, packageDirectory, packageName, + "", /*subpath*/ exports, conditions, MatchingModeExact, - /*isImports*/ false, - /*preferTsExtension*/ false, )) } @@ -1072,6 +1069,7 @@ func tryGetModuleNameFromPackageJsonImports( return "" // not present or invalid for imports case packagejson.JSONValueTypeObject: conditions := module.GetConditions(options, importMode) + target := newExportsOrImportsTarget(moduleFileName, options, host, true /*isImports*/, preferTsExtension) top := imports.AsObject() entries := top.Entries() for k, value := range entries { @@ -1090,14 +1088,13 @@ func tryGetModuleNameFromPackageJsonImports( result := tryGetModuleNameFromExportsOrImports( options, host, - moduleFileName, + &target, ancestorDirectoryWithPackageJson, k, + "", /*subpath*/ value, conditions, mode, - true, - preferTsExtension, ) if len(result) > 0 { return tspath.ToModuleSpecifier(result) @@ -1123,94 +1120,89 @@ func tryGetModuleNameFromPaths( compilerOptions *core.CompilerOptions, ) string { caseSensitivity := host.CaseSensitivity() - for key, values := range paths.Entries() { - for _, patternText := range values { - normalized := tspath.NormalizePath(patternText) - pattern := resolvePathPatternIfInSameVolume(normalized, baseDirectory, caseSensitivity) - if len(pattern) == 0 { - pattern = normalized - } - prefix, suffix, ok := strings.Cut(pattern, "*") - - // In module resolution, if `pattern` itself has an extension, a file with that extension is looked up directly, - // meaning a '.ts' or '.d.ts' extension is allowed to resolve. This is distinct from the case where a '*' substitution - // causes a module specifier to have an extension, i.e. the extension comes from the module specifier in a JS/TS file - // and matches the '*'. For example: - // - // Module Specifier | Path Mapping (key: [pattern]) | Interpolation | Resolution Action - // ---------------------->------------------------------->--------------------->--------------------------------------------------------------- - // import "@app/foo" -> "@app/*": ["./src/app/*.ts"] -> "./src/app/foo.ts" -> tryFile("./src/app/foo.ts") || [continue resolution algorithm] - // import "@app/foo.ts" -> "@app/*": ["./src/app/*"] -> "./src/app/foo.ts" -> [continue resolution algorithm] - // - // (https://github.com/microsoft/TypeScript/blob/ad4ded80e1d58f0bf36ac16bea71bc10d9f09895/src/compiler/moduleNameResolver.ts#L2509-L2516) - // - // The interpolation produced by both scenarios is identical, but only in the former, where the extension is encoded in - // the path mapping rather than in the module specifier, will we prioritize a file lookup on the interpolation result. - // (In fact, currently, the latter scenario will necessarily fail since no resolution mode recognizes '.ts' as a valid - // extension for a module specifier.) - // - // Here, this means we need to be careful about whether we generate a match from the target filename (typically with a - // .ts extension) or the possible relative module specifiers representing that file: - // - // Filename | Relative Module Specifier Candidates | Path Mapping | Filename Result | Module Specifier Results - // --------------------<----------------------------------------------<------------------------------<-------------------||---------------------------- - // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*.d.ts"] <- @app/haha || (none) - // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*"] <- (none) || @app/haha, @app/haha.js - // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*.d.ts"] <- @app/foo/index || (none) - // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*"] <- (none) || @app/foo, @app/foo/index, @app/foo/index.js - // dist/wow.js.js <- dist/wow.js, dist/wow.js.js <- "@app/*": ["./dist/*.js"] <- @app/wow.js || @app/wow, @app/wow.js - // - // The "Filename Result" can be generated only if `pattern` has an extension. Care must be taken that the list of - // relative module specifiers to run the interpolation (a) is actually valid for the module resolution mode, (b) takes - // into account the existence of other files (e.g. 'dist/wow.js' cannot refer to 'dist/wow.js.js' if 'dist/wow.js' - // exists) and (c) that they are ordered by preference. The last row shows that the filename result and module - // specifier results are not mutually exclusive. Note that the filename result is a higher priority in module - // resolution, but as long criteria (b) above is met, I don't think its result needs to be the highest priority result - // in module specifier generation. I have included it last, as it's difficult to tell exactly where it should be - // sorted among the others for a particular value of `importModuleSpecifierEnding`. - - var candidates []specPair - for _, ending := range allowedEndings { - result := processEnding( - tspath.ToModuleSpecifier(relativeToBaseUrl), - fileName, - []ModuleSpecifierEnding{ending}, - compilerOptions, - host, - ) - candidates = append(candidates, specPair{ - ending: ending, - value: result, - }) - } - if len(tspath.TryGetExtensionFromPath(pattern)) > 0 { - candidates = append(candidates, specPair{ - ending: ModuleSpecifierEndingJsExtension, - value: tspath.ToModuleSpecifier(relativeToBaseUrl), - }) - } + // The specifiers relativeToBaseUrl can be written as, in order of preference. They don't depend on the + // pattern, so they are computed once; the last entry, the file name as written, is only considered by + // patterns that have an extension themselves (see below). + candidates := make([]specPair, 0, len(allowedEndings)+1) + for _, ending := range allowedEndings { + candidates = append(candidates, specPair{ + ending: ending, + value: processEnding( + tspath.ToModuleSpecifier(relativeToBaseUrl), + fileName, + []ModuleSpecifierEnding{ending}, + compilerOptions, + host, + ), + }) + } + candidates = append(candidates, specPair{ + ending: ModuleSpecifierEndingJsExtension, + value: tspath.ToModuleSpecifier(relativeToBaseUrl), + }) + for _, p := range getPathPatterns(host, paths, baseDirectory) { + // In module resolution, if `pattern` itself has an extension, a file with that extension is looked up directly, + // meaning a '.ts' or '.d.ts' extension is allowed to resolve. This is distinct from the case where a '*' substitution + // causes a module specifier to have an extension, i.e. the extension comes from the module specifier in a JS/TS file + // and matches the '*'. For example: + // + // Module Specifier | Path Mapping (key: [pattern]) | Interpolation | Resolution Action + // ---------------------->------------------------------->--------------------->--------------------------------------------------------------- + // import "@app/foo" -> "@app/*": ["./src/app/*.ts"] -> "./src/app/foo.ts" -> tryFile("./src/app/foo.ts") || [continue resolution algorithm] + // import "@app/foo.ts" -> "@app/*": ["./src/app/*"] -> "./src/app/foo.ts" -> [continue resolution algorithm] + // + // (https://github.com/microsoft/TypeScript/blob/ad4ded80e1d58f0bf36ac16bea71bc10d9f09895/src/compiler/moduleNameResolver.ts#L2509-L2516) + // + // The interpolation produced by both scenarios is identical, but only in the former, where the extension is encoded in + // the path mapping rather than in the module specifier, will we prioritize a file lookup on the interpolation result. + // (In fact, currently, the latter scenario will necessarily fail since no resolution mode recognizes '.ts' as a valid + // extension for a module specifier.) + // + // Here, this means we need to be careful about whether we generate a match from the target filename (typically with a + // .ts extension) or the possible relative module specifiers representing that file: + // + // Filename | Relative Module Specifier Candidates | Path Mapping | Filename Result | Module Specifier Results + // --------------------<----------------------------------------------<------------------------------<-------------------||---------------------------- + // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*.d.ts"] <- @app/haha || (none) + // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*"] <- (none) || @app/haha, @app/haha.js + // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*.d.ts"] <- @app/foo/index || (none) + // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*"] <- (none) || @app/foo, @app/foo/index, @app/foo/index.js + // dist/wow.js.js <- dist/wow.js, dist/wow.js.js <- "@app/*": ["./dist/*.js"] <- @app/wow.js || @app/wow, @app/wow.js + // + // The "Filename Result" can be generated only if `pattern` has an extension. Care must be taken that the list of + // relative module specifiers to run the interpolation (a) is actually valid for the module resolution mode, (b) takes + // into account the existence of other files (e.g. 'dist/wow.js' cannot refer to 'dist/wow.js.js' if 'dist/wow.js' + // exists) and (c) that they are ordered by preference. The last row shows that the filename result and module + // specifier results are not mutually exclusive. Note that the filename result is a higher priority in module + // resolution, but as long criteria (b) above is met, I don't think its result needs to be the highest priority result + // in module specifier generation. I have included it last, as it's difficult to tell exactly where it should be + // sorted among the others for a particular value of `importModuleSpecifierEnding`. + + patternCandidates := candidates[:len(allowedEndings)] + if p.hasExtension { + patternCandidates = candidates + } - if ok { - for _, c := range candidates { - value := c.value.AsString() - if len(value) >= len(prefix)+len(suffix) && - stringutil.HasPrefix(value, prefix, caseSensitivity.IsCaseSensitive()) && // TODO: possible strada bug: these are not case-switched in strada - stringutil.HasSuffix(value, suffix, caseSensitivity.IsCaseSensitive()) && - validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) { - matchedStar := value[len(prefix) : len(value)-len(suffix)] - if !tspath.PathIsRelative(matchedStar) { - return replaceFirstStar(key, matchedStar) - } + if p.hasWildcard { + for _, c := range patternCandidates { + value := c.value.AsString() + if len(value) >= len(p.prefix)+len(p.suffix) && + stringutil.HasPrefix(value, p.prefix, caseSensitivity.IsCaseSensitive()) && // TODO: possible strada bug: these are not case-switched in strada + stringutil.HasSuffix(value, p.suffix, caseSensitivity.IsCaseSensitive()) && + validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) { + matchedStar := value[len(p.prefix) : len(value)-len(p.suffix)] + if !tspath.PathIsRelative(matchedStar) { + return replaceFirstStar(p.key, matchedStar) } } - } else if core.Some(candidates, func(c specPair) bool { - return c.ending != ModuleSpecifierEndingMinimal && pattern == c.value.AsString() - }) || - core.Some(candidates, func(c specPair) bool { - return c.ending == ModuleSpecifierEndingMinimal && pattern == c.value.AsString() && validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) - }) { - return key } + } else if core.Some(patternCandidates, func(c specPair) bool { + return c.ending != ModuleSpecifierEndingMinimal && p.pattern == c.value.AsString() + }) || + core.Some(patternCandidates, func(c specPair) bool { + return c.ending == ModuleSpecifierEndingMinimal && p.pattern == c.value.AsString() && validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) + }) { + return p.key } } return "" @@ -1227,39 +1219,69 @@ func validateEnding(c specPair, relativeToBaseUrl string, fileName tspath.Rooted return c.ending != ModuleSpecifierEndingMinimal || c.value == processEnding(tspath.ToModuleSpecifier(relativeToBaseUrl), fileName, []ModuleSpecifierEnding{c.ending}, compilerOptions, host) } +// exportsOrImportsTarget is the file that a package.json "exports" or "imports" lookup searches for, +// in each form an entry may name it. The forms depend only on the target, so a lookup computes them +// once instead of once per entry. +type exportsOrImportsTarget struct { + fileName tspath.RootedFilePath + extensionSwappedTarget tspath.RootedFilePath // the JS file a TS target is emitted as + outputFile tspath.RootedFilePath // "imports" only: the target's JS output + declarationFile tspath.RootedFilePath // "imports" only: the target's declaration output + canTryTsExtension bool +} + +func newExportsOrImportsTarget( + targetFileName tspath.RootedFilePath, + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + isImports bool, + preferTsExtension bool, +) exportsOrImportsTarget { + target := exportsOrImportsTarget{ + fileName: targetFileName, + canTryTsExtension: preferTsExtension && targetFileName.HasImplementationTSFileExtension(), + } + // possible strada bug? Always uses compilerOptions of the host project, not those applicable to the targeted package.json! + if isImports { + target.outputFile = outputpaths.GetOutputJSFileNameWorker(targetFileName, options, host) + target.declarationFile = outputpaths.GetOutputDeclarationFileNameWorker(targetFileName, options, host) + } + if targetFileName.HasTSFileExtension() { + target.extensionSwappedTarget = targetFileName.RemoveFileExtension().AppendSuffix(module.TryGetJSExtensionForFileName(targetFileName, options)) + } + return target +} + +// exportsEntryName returns the specifier published by the "exports" entry under subpath, or by the +// package itself when subpath is empty. Lookups build it only for the entry that matches. +func exportsEntryName(packageName string, subpath string) string { + if subpath == "" { + return packageName + } + return tspath.ResolvePathWithoutTrailingDirectorySeparator(packageName, subpath) +} + func tryGetModuleNameFromExportsOrImports( options *core.CompilerOptions, host ModuleSpecifierGenerationHost, - targetFileName tspath.RootedFilePath, + target *exportsOrImportsTarget, packageDirectory tspath.RootedDirectoryPath, packageName string, + subpath string, exports packagejson.ExportsOrImports, conditions []string, mode MatchingMode, - isImports bool, - preferTsExtension bool, ) string { - packageSpecifier := tspath.ToModuleSpecifier(packageName) switch exports.Type { case packagejson.JSONValueTypeNotPresent: return "" case packagejson.JSONValueTypeString: strValue := exports.Value.(string) - - // possible strada bug? Always uses compilerOptions of the host project, not those applicable to the targeted package.json! - var outputFile tspath.RootedFilePath - var declarationFile tspath.RootedFilePath - if isImports { - outputFile = outputpaths.GetOutputJSFileNameWorker(targetFileName, options, host) - declarationFile = outputpaths.GetOutputDeclarationFileNameWorker(targetFileName, options, host) - } - - var extensionSwappedTarget tspath.RootedFilePath - if targetFileName.HasTSFileExtension() { - extensionSwappedTarget = targetFileName.RemoveFileExtension().AppendSuffix(module.TryGetJSExtensionForFileName(targetFileName, options)) - } - canTryTsExtension := preferTsExtension && targetFileName.HasImplementationTSFileExtension() - + targetFileName := target.fileName + extensionSwappedTarget := target.extensionSwappedTarget + outputFile := target.outputFile + declarationFile := target.declarationFile + canTryTsExtension := target.canTryTsExtension caseSensitivity := host.CaseSensitivity() switch mode { @@ -1272,7 +1294,7 @@ func tryGetModuleNameFromExportsOrImports( caseSensitivity.CompareFilePaths(targetFileName, resolvedTarget) == 0 || len(outputFile) > 0 && caseSensitivity.CompareFilePaths(outputFile, resolvedTarget) == 0 || len(declarationFile) > 0 && caseSensitivity.CompareFilePaths(declarationFile, resolvedTarget) == 0 { - return packageName + return exportsEntryName(packageName, subpath) } case MatchingModeDirectory: resolvedTarget := packageDirectory.ResolveDirectory(tspath.RemoveTrailingDirectorySeparator(strValue)) @@ -1282,50 +1304,49 @@ func tryGetModuleNameFromExportsOrImports( tspath.RootedPath(resolvedTarget)) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, targetFileName) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if len(extensionSwappedTarget) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, extensionSwappedTarget) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, extensionSwappedTarget) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if !canTryTsExtension && caseSensitivity.ContainsFilePath(resolvedTarget, targetFileName) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, targetFileName) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if len(outputFile) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, outputFile) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, outputFile) - return packageSpecifier.CombineRelative(fragment).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).CombineRelative(fragment).AsString() } if len(declarationFile) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, declarationFile) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, declarationFile) jsExtension := getJSExtensionForFileName(declarationFile, options) fragmentWithJsExtension := fragment.ChangeExtension(jsExtension) - return packageSpecifier.CombineRelative(fragmentWithJsExtension).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).CombineRelative(fragmentWithJsExtension).AsString() } case MatchingModePattern: pathOrPattern := tspath.ResolvePath(packageDirectory.AsString(), strValue) leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") - caseSensitivity := host.CaseSensitivity() targetFilePath := targetFileName.AsString() if canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if extensionSwappedTargetString := extensionSwappedTarget.AsString(); len(extensionSwappedTargetString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(extensionSwappedTargetString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := extensionSwappedTargetString[len(leadingSlice) : len(extensionSwappedTargetString)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if !canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if outputFileString := outputFile.AsString(); len(outputFileString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(outputFileString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := outputFileString[len(leadingSlice) : len(outputFileString)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if declarationFileString := declarationFile.AsString(); len(declarationFileString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(declarationFileString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := declarationFileString[len(leadingSlice) : len(declarationFileString)-len(trailingSlice)] - substituted := replaceFirstStar(packageName, starReplacement) + substituted := replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) jsExtension := module.TryGetJSExtensionForFileName(declarationFile, options) if len(jsExtension) > 0 { return tspath.ChangeFullExtension(substituted, jsExtension) @@ -1336,7 +1357,7 @@ func tryGetModuleNameFromExportsOrImports( case packagejson.JSONValueTypeArray: arr := exports.AsArray() for _, e := range arr { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFileName, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) + result := tryGetModuleNameFromExportsOrImports(options, host, target, packageDirectory, packageName, subpath, e, conditions, mode) if len(result) > 0 { return result } @@ -1346,7 +1367,7 @@ func tryGetModuleNameFromExportsOrImports( obj := exports.AsObject() for key, value := range obj.Entries() { if key == "default" || slices.Contains(conditions, key) || slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFileName, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) + result := tryGetModuleNameFromExportsOrImports(options, host, target, packageDirectory, packageName, subpath, value, conditions, mode) if len(result) > 0 { return result } diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 3bb7fb7853f01..aa42047532fbc 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -160,6 +160,10 @@ func (h *mockModuleSpecifierGenerationHost) GetPackageJsonInfo(pkgJsonPath tspat return nil } +func (h *mockModuleSpecifierGenerationHost) ModuleSpecifierGenerationCache() *GenerationCache { + return nil +} + func (h *mockModuleSpecifierGenerationHost) GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode { return core.ResolutionModeNone } @@ -458,31 +462,31 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { Value: "./dist/internal/", } host := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive} + options := &core.CompilerOptions{} + target := newExportsOrImportsTarget("/pkg/dist/internal/file.ts", options, host, true /*isImports*/, false /*preferTsExtension*/) if result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, + options, host, - "/pkg/dist/internal/file.ts", + &target, "/pkg", "#internal/", + "", /*subpath*/ exports, nil, MatchingModeDirectory, - true, - false, ); result == "" { t.Fatal("directory target with a trailing separator should match") } if result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, + options, host, - "/pkg/dist/internal/file.ts", + &target, "/pkg", "#internal", + "", /*subpath*/ exports, nil, MatchingModeExact, - true, - false, ); result != "" { t.Fatalf("exact target with a trailing separator matched as %q", result) } @@ -511,20 +515,22 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + options := &core.CompilerOptions{} + host := &mockModuleSpecifierGenerationHost{} + target := newExportsOrImportsTarget(tspath.RootedFilePathFromNormalized(tt.targetFilePath), options, host, false /*isImports*/, false /*preferTsExtension*/) result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, - &mockModuleSpecifierGenerationHost{}, - tspath.RootedFilePathFromNormalized(tt.targetFilePath), + options, + host, + &target, "/pkg", "./src/things/*", + "", /*subpath*/ packagejson.ExportsOrImports{ Type: packagejson.JSONValueTypeString, Value: "./src/things/*/index.js", }, []string{}, MatchingModePattern, - false, - false, ) if result != tt.expected { t.Errorf("tryGetModuleNameFromExportsOrImports(targetFilePath = %q) = %v, expected %v", tt.targetFilePath, result, tt.expected) diff --git a/tsc/internal/modulespecifiers/types.go b/tsc/internal/modulespecifiers/types.go index a58549cbc358a..90ce032a90665 100644 --- a/tsc/internal/modulespecifiers/types.go +++ b/tsc/internal/modulespecifiers/types.go @@ -63,6 +63,9 @@ type ModuleSpecifierGenerationHost interface { GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *packagejson.InfoCacheEntry + // ModuleSpecifierGenerationCache returns the cache the host keeps across module specifier + // generation, or nil when it does not keep one. + ModuleSpecifierGenerationCache() *GenerationCache GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode GetResolvedModuleFromModuleSpecifier(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) *module.ResolvedModule GetModeForUsageLocation(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) core.ResolutionMode diff --git a/tsc/internal/packagejson/cache.go b/tsc/internal/packagejson/cache.go index 01004398d3238..293361f6587c8 100644 --- a/tsc/internal/packagejson/cache.go +++ b/tsc/internal/packagejson/cache.go @@ -68,6 +68,9 @@ func (p *PackageJson) GetVersionPaths(trace func(m *diagnostics.Message, args .. Version: key, pathsJSON: value.AsObject(), } + // Build the table now, so that every copy returned below shares it instead of + // rebuilding it on each GetPaths call. + p.versionPaths.GetPaths() return } } diff --git a/tsc/internal/packagejson/packagejson_test.go b/tsc/internal/packagejson/packagejson_test.go index e47bb49e293af..41107fadc7589 100644 --- a/tsc/internal/packagejson/packagejson_test.go +++ b/tsc/internal/packagejson/packagejson_test.go @@ -181,3 +181,25 @@ func TestParse(t *testing.T) { }) } } + +func TestGetVersionPathsSharesTable(t *testing.T) { + t.Parallel() + fields, err := packagejson.Parse([]byte(`{ "name": "pkg", "typesVersions": { "*": { "*": ["./types/*"] } } }`)) + if err != nil { + t.Fatal(err) + } + p := &packagejson.PackageJson{Fields: fields, Parseable: true} + first := p.GetVersionPaths(nil) + second := p.GetVersionPaths(nil) + firstPaths, secondPaths := first.GetPaths(), second.GetPaths() + if firstPaths == nil { + t.Fatal("expected typesVersions paths") + } + // Module specifier generation caches resolved patterns by table identity. + if firstPaths != secondPaths { + t.Error("each GetVersionPaths result built its own paths table") + } + if values, ok := firstPaths.Get("*"); !ok || len(values) != 1 || values[0] != "./types/*" { + t.Errorf("paths table = %v", firstPaths) + } +} diff --git a/tsc/internal/transformers/tstransforms/importelision_test.go b/tsc/internal/transformers/tstransforms/importelision_test.go index b6654955a7c5d..a3aec6638dbac 100644 --- a/tsc/internal/transformers/tstransforms/importelision_test.go +++ b/tsc/internal/transformers/tstransforms/importelision_test.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/checker" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/printer" "github.com/microsoft/TypeScript/tsc/internal/symlinks" @@ -86,6 +87,10 @@ func (p *fakeProgram) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return nil } +func (p *fakeProgram) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return nil +} + func (p *fakeProgram) GetRedirectTargets(path tspath.PathKey) []tspath.RootedFilePath { return nil }