From 98cb2ed72cfc4187f45fe1a6abe5417ad613356b Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 7 Jun 2024 22:52:31 -0400 Subject: [PATCH] fix #3782: support `${configDir}` in tsconfig.json --- CHANGELOG.md | 16 ++++ .../bundler_tests/bundler_tsconfig_test.go | 95 +++++++++++++++++++ .../snapshots/snapshots_tsconfig.txt | 18 ++++ internal/resolver/resolver.go | 34 +++---- internal/resolver/tsconfig_json.go | 24 +++-- 5 files changed, 160 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a980049096..7fc60330c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ import tasty from "./tasty.bagel" with { type: "bagel" } ``` +* Support `${configDir}` in `tsconfig.json` files ([#3782](https://github.com/evanw/esbuild/issues/3782)) + + This adds support for a new feature from the upcoming TypeScript 5.5 release. The character sequence `${configDir}` is now respected at the start of `baseUrl` and `paths` values, which are used by esbuild during bundling to correctly map import paths to file system paths. This feature lets base `tsconfig.json` files specified via `extends` refer to the directory of the top-level `tsconfig.json` file. Here is an example: + + ```json + { + "compilerOptions": { + "paths": { + "js/*": ["${configDir}/dist/js/*"] + } + } + } + ``` + + You can read more in [TypeScript's blog post about their upcoming 5.5 release](https://devblogs.microsoft.com/typescript/announcing-typescript-5-5-rc/#the-configdir-template-variable-for-configuration-files). Note that this feature does not make use of template literals (you need to use `"${configDir}/dist/js/*"` not `` `${configDir}/dist/js/*` ``). The syntax for `tsconfig.json` is still just JSON with comments, and JSON syntax does not allow template literals. This feature only recognizes `${configDir}` in strings for certain path-like properties, and only at the beginning of the string. + * Fix internal error with `--supported:object-accessors=false` ([#3794](https://github.com/evanw/esbuild/issues/3794)) This release fixes a regression in 0.21.0 where some code that was added to esbuild's internal runtime library of helper functions for JavaScript decorators fails to parse when you configure esbuild with `--supported:object-accessors=false`. The reason is that esbuild introduced code that does `{ get [name]() {} }` which uses both the `object-extensions` feature for the `[name]` and the `object-accessors` feature for the `get`, but esbuild was incorrectly only checking for `object-extensions` and not for `object-accessors`. Additional tests have been added to avoid this type of issue in the future. A workaround for this issue in earlier releases is to also add `--supported:object-extensions=false`. diff --git a/internal/bundler_tests/bundler_tsconfig_test.go b/internal/bundler_tests/bundler_tsconfig_test.go index e2abe434211..50ff57e6989 100644 --- a/internal/bundler_tests/bundler_tsconfig_test.go +++ b/internal/bundler_tests/bundler_tsconfig_test.go @@ -2654,3 +2654,98 @@ func TestTsconfigPackageJsonExportsYarnPnP(t *testing.T) { }, }) } + +func TestTsconfigJsonConfigDirBaseURL(t *testing.T) { + tsconfig_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import "foo/bar" + `, + "/Users/user/project/lib/foo/bar": ` + console.log('works') + `, + "/Users/user/project/src/tsconfig.json": ` + { + "extends": "@scope/configs/tsconfig" + } + `, + "/Users/user/project/node_modules/@scope/configs/tsconfig.json": ` + { + "compilerOptions": { + "baseUrl": "${configDir}../lib" + } + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} + +func TestTsconfigJsonConfigDirPaths(t *testing.T) { + tsconfig_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import "library/foo/bar" + `, + "/Users/user/project/lib/foo/bar": ` + console.log('works') + `, + "/Users/user/project/src/tsconfig.json": ` + { + "extends": "@scope/configs/tsconfig" + } + `, + "/Users/user/project/node_modules/@scope/configs/tsconfig.json": ` + { + "compilerOptions": { + "paths": { + "library/*": ["${configDir}../lib/*"] + } + } + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} + +func TestTsconfigJsonConfigDirBaseURLInheritedPaths(t *testing.T) { + tsconfig_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import "library/foo/bar" + `, + "/Users/user/project/lib/foo/bar": ` + console.log('works') + `, + "/Users/user/project/src/tsconfig.json": ` + { + "extends": "@scope/configs/tsconfig" + } + `, + "/Users/user/project/node_modules/@scope/configs/tsconfig.json": ` + { + "compilerOptions": { + "baseUrl": "${configDir}..", + "paths": { + "library/*": ["./lib/*"] + } + } + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} diff --git a/internal/bundler_tests/snapshots/snapshots_tsconfig.txt b/internal/bundler_tests/snapshots/snapshots_tsconfig.txt index b9885e3e7a8..1e05b86c4f0 100644 --- a/internal/bundler_tests/snapshots/snapshots_tsconfig.txt +++ b/internal/bundler_tests/snapshots/snapshots_tsconfig.txt @@ -260,6 +260,24 @@ var require_util = __commonJS({ var import_util = __toESM(require_util()); console.log((0, import_util.default)()); +================================================================================ +TestTsconfigJsonConfigDirBaseURL +---------- /Users/user/project/out.js ---------- +// Users/user/project/lib/foo/bar +console.log("works"); + +================================================================================ +TestTsconfigJsonConfigDirBaseURLInheritedPaths +---------- /Users/user/project/out.js ---------- +// Users/user/project/lib/foo/bar +console.log("works"); + +================================================================================ +TestTsconfigJsonConfigDirPaths +---------- /Users/user/project/out.js ---------- +// Users/user/project/lib/foo/bar +console.log("works"); + ================================================================================ TestTsconfigJsonExtends ---------- /out.js ---------- diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index f53b5ab90c8..ccc21309caf 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -331,14 +331,14 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca if r.log.Level <= logger.LevelDebug { r.debugLogs = &debugLogs{what: fmt.Sprintf("Resolving tsconfig file %q", options.TSConfigPath)} } - res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited) + res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited, fs.Dir(options.TSConfigPath)) } else { source := logger.Source{ KeyPath: logger.Path{Text: fs.Join(fs.Cwd(), ""), Namespace: "file"}, PrettyPath: "", Contents: options.TSConfigRaw, } - res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited) + res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited, fs.Cwd()) } if err != nil { if err == syscall.ENOENT { @@ -1164,7 +1164,7 @@ var errParseErrorAlreadyLogged = errors.New("(error already logged)") // // Nested calls may also return "parseErrorImportCycle". In that case the // caller is responsible for logging an appropriate error message. -func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSConfigJSON, error) { +func (r resolverQuery) parseTSConfig(file string, visited map[string]bool, configDir string) (*TSConfigJSON, error) { // Resolve any symlinks first before parsing the file if !r.options.PreserveSymlinks { if real, ok := r.fs.EvalSymlinks(file); ok { @@ -1199,15 +1199,15 @@ func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSC PrettyPath: PrettyPath(r.fs, keyPath), Contents: contents, } - return r.parseTSConfigFromSource(source, visited) + return r.parseTSConfigFromSource(source, visited, configDir) } -func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map[string]bool) (*TSConfigJSON, error) { +func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map[string]bool, configDir string) (*TSConfigJSON, error) { tracker := logger.MakeLineColumnTracker(&source) fileDir := r.fs.Dir(source.KeyPath.Text) isExtends := len(visited) > 1 - result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, func(extends string, extendsRange logger.Range) *TSConfigJSON { + result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, r.fs, fileDir, configDir, func(extends string, extendsRange logger.Range) *TSConfigJSON { if visited == nil { // If this is nil, then we're in a "transform" API call. In that case we // deliberately skip processing "extends" fields. This is because the @@ -1295,7 +1295,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map // Check the "exports" map if packageJSON := r.parsePackageJSON(result.pkgDirPath); packageJSON != nil && packageJSON.exportsMap != nil { if absolute, ok, _ := r.esmResolveAlgorithm(result.pkgIdent, "."+result.pkgSubpath, packageJSON, result.pkgDirPath, source.KeyPath.Text); ok { - base, err := r.parseTSConfig(absolute.Primary.Text, visited) + base, err := r.parseTSConfig(absolute.Primary.Text, visited, configDir) if result, shouldReturn := maybeFinishOurSearch(base, err, absolute.Primary.Text); shouldReturn { return result } @@ -1355,7 +1355,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map // This is a very abbreviated version of our ESM resolution if status == pjStatusExact || status == pjStatusExactEndsWithStar { fileToCheck := r.fs.Join(pkgDir, resolvedPath) - base, err := r.parseTSConfig(fileToCheck, visited) + base, err := r.parseTSConfig(fileToCheck, visited, configDir) if result, shouldReturn := maybeFinishOurSearch(base, err, fileToCheck); shouldReturn { return result @@ -1369,7 +1369,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map filesToCheck := []string{r.fs.Join(join, "tsconfig.json"), join, join + ".json"} for _, fileToCheck := range filesToCheck { - base, err := r.parseTSConfig(fileToCheck, visited) + base, err := r.parseTSConfig(fileToCheck, visited, configDir) // Explicitly ignore matches if they are directories instead of files if err != nil && err != syscall.ENOENT { @@ -1417,7 +1417,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map if !r.fs.IsAbs(extendsFile) { extendsFile = r.fs.Join(fileDir, extendsFile) } - base, err := r.parseTSConfig(extendsFile, visited) + base, err := r.parseTSConfig(extendsFile, visited, configDir) // TypeScript's handling of "extends" has some specific edge cases. We // must only try adding ".json" if it's not already present, which is @@ -1429,7 +1429,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map extendsBase := r.fs.Base(extendsFile) if entry, _ := entries.Get(extendsBase); entry == nil || entry.Kind(r.fs) != fs.FileEntry { if entry, _ := entries.Get(extendsBase + ".json"); entry != nil && entry.Kind(r.fs) == fs.FileEntry { - base, err = r.parseTSConfig(extendsFile+".json", visited) + base, err = r.parseTSConfig(extendsFile+".json", visited, configDir) } } } @@ -1458,14 +1458,6 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map return nil, errParseErrorAlreadyLogged } - if result.BaseURL != nil && !r.fs.IsAbs(*result.BaseURL) { - *result.BaseURL = r.fs.Join(fileDir, *result.BaseURL) - } - - if result.Paths != nil && !r.fs.IsAbs(result.BaseURLForPaths) { - result.BaseURLForPaths = r.fs.Join(fileDir, result.BaseURLForPaths) - } - // Now that we have parsed the entire "tsconfig.json" file, filter out any // paths that are invalid due to being a package-style path without a base // URL specified. This must be done here instead of when we're parsing the @@ -1612,7 +1604,7 @@ func (r resolverQuery) dirInfoUncached(path string) *dirInfo { // many other tools anyway. So now these files are ignored. if tsConfigPath != "" && !info.isInsideNodeModules { var err error - info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool)) + info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool), r.fs.Dir(tsConfigPath)) if err != nil { if err == syscall.ENOENT { r.log.AddError(nil, logger.Range{}, fmt.Sprintf("Cannot find tsconfig file %q", @@ -2094,7 +2086,7 @@ func (r resolverQuery) matchTSConfigPaths(tsConfigJSON *TSConfigJSON, path strin } if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseURL\"", absBaseURL)) + r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseUrl\"", absBaseURL)) } // Check for exact matches first diff --git a/internal/resolver/tsconfig_json.go b/internal/resolver/tsconfig_json.go index ffeb75dbb93..edfc7755cee 100644 --- a/internal/resolver/tsconfig_json.go +++ b/internal/resolver/tsconfig_json.go @@ -6,6 +6,7 @@ import ( "github.com/evanw/esbuild/internal/cache" "github.com/evanw/esbuild/internal/config" + "github.com/evanw/esbuild/internal/fs" "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/js_ast" "github.com/evanw/esbuild/internal/js_lexer" @@ -95,6 +96,9 @@ func ParseTSConfigJSON( log logger.Log, source logger.Source, jsonCache *cache.JSONCache, + fs fs.FS, + fileDir string, + configDir string, extends func(string, logger.Range) *TSConfigJSON, ) *TSConfigJSON { // Unfortunately "tsconfig.json" isn't actually JSON. It's some other @@ -138,6 +142,10 @@ func ParseTSConfigJSON( // Parse "baseUrl" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "baseUrl"); ok { if value, ok := getString(valueJSON); ok { + value = getSubstitutedPathWithConfigDirTemplate(fs, value, configDir) + if !fs.IsAbs(value) { + value = fs.Join(fileDir, value) + } result.BaseURL = &value } } @@ -301,12 +309,7 @@ func ParseTSConfigJSON( // Parse "paths" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "paths"); ok { if paths, ok := valueJSON.Data.(*js_ast.EObject); ok { - hasBaseURL := result.BaseURL != nil - if hasBaseURL { - result.BaseURLForPaths = *result.BaseURL - } else { - result.BaseURLForPaths = "." - } + result.BaseURLForPaths = fileDir result.Paths = &TSConfigPaths{Source: source, Map: make(map[string][]TSConfigPath)} for _, prop := range paths.Properties { if key, ok := getString(prop.Key); ok { @@ -339,6 +342,7 @@ func ParseTSConfigJSON( for _, item := range array.Items { if str, ok := getString(item); ok { if isValidTSConfigPathPattern(str, log, &source, &tracker, item.Loc) { + str = getSubstitutedPathWithConfigDirTemplate(fs, str, configDir) result.Paths.Map[key] = append(result.Paths.Map[key], TSConfigPath{Text: str, Loc: item.Loc}) } } @@ -387,6 +391,14 @@ func ParseTSConfigJSON( return &result } +// See: https://github.com/microsoft/TypeScript/pull/58042 +func getSubstitutedPathWithConfigDirTemplate(fs fs.FS, value string, basePath string) string { + if strings.HasPrefix(value, "${configDir}") { + return fs.Join(basePath, "./"+value[12:]) + } + return value +} + func parseMemberExpressionForJSX(log logger.Log, source *logger.Source, tracker *logger.LineColumnTracker, loc logger.Loc, text string) []string { if text == "" { return nil