Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tsc/internal/compiler/emitHost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions tsc/internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 9 additions & 3 deletions tsc/internal/ls/autoimport/aliasresolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -25,9 +26,10 @@ type aliasResolver struct {

rootFiles []*ast.SourceFile
// symlinks maps from realpath to symlinked path and file name
symlinks map[tspath.PathKey]pathAndFileName
onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string)
resolvedModules collections.SyncMap[tspath.PathKey, *collections.SyncMap[module.ModeAwareCacheKey, *module.ResolvedModule]]
symlinks map[tspath.PathKey]pathAndFileName
onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string)
resolvedModules collections.SyncMap[tspath.PathKey, *collections.SyncMap[module.ModeAwareCacheKey, *module.ResolvedModule]]
moduleSpecifierGenerationCache modulespecifiers.GenerationCache
}

func newAliasResolver(
Expand Down Expand Up @@ -187,6 +189,10 @@ func (r *aliasResolver) GetJSXRuntimeImportSpecifier(path tspath.PathKey) (modul
}

// GetNearestAncestorDirectoryWithPackageJson implements checker.Program.
func (r *aliasResolver) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache {
return &r.moduleSpecifierGenerationCache
}

func (r *aliasResolver) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath {
panic("unimplemented")
}
Expand Down
119 changes: 119 additions & 0 deletions tsc/internal/modulespecifiers/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
194 changes: 194 additions & 0 deletions tsc/internal/modulespecifiers/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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)
}
}
}
}

// 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)
}
}
}
Loading