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 }