package resolver import ( "fmt" "strings" "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" "github.com/evanw/esbuild/internal/js_parser" "github.com/evanw/esbuild/internal/logger" ) type TSConfigJSON struct { AbsPath string // The absolute path of "compilerOptions.baseUrl" BaseURL *string // This is used if "Paths" is non-nil. It's equal to "BaseURL" except if // "BaseURL" is missing, in which case it is as if "BaseURL" was ".". This // is to implement the "paths without baseUrl" feature from TypeScript 4.1. // More info: https://github.com/microsoft/TypeScript/issues/31869 BaseURLForPaths string // The verbatim values of "compilerOptions.paths". The keys are patterns to // match and the values are arrays of fallback paths to search. Each key and // each fallback path can optionally have a single "*" wildcard character. // If both the key and the value have a wildcard, the substring matched by // the wildcard is substituted into the fallback path. The keys represent // module-style path names and the fallback paths are relative to the // "baseUrl" value in the "tsconfig.json" file. Paths *TSConfigPaths tsTargetKey tsTargetKey TSStrict *config.TSAlwaysStrict TSAlwaysStrict *config.TSAlwaysStrict JSXSettings config.TSConfigJSX Settings config.TSConfig } func (derived *TSConfigJSON) applyExtendedConfig(base TSConfigJSON) { if base.tsTargetKey.Range.Len > 0 { derived.tsTargetKey = base.tsTargetKey } if base.TSStrict != nil { derived.TSStrict = base.TSStrict } if base.TSAlwaysStrict != nil { derived.TSAlwaysStrict = base.TSAlwaysStrict } if base.BaseURL != nil { derived.BaseURL = base.BaseURL } if base.Paths != nil { derived.Paths = base.Paths derived.BaseURLForPaths = base.BaseURLForPaths } derived.JSXSettings.ApplyExtendedConfig(base.JSXSettings) derived.Settings.ApplyExtendedConfig(base.Settings) } func (config *TSConfigJSON) TSAlwaysStrictOrStrict() *config.TSAlwaysStrict { if config.TSAlwaysStrict != nil { return config.TSAlwaysStrict } // If "alwaysStrict" is absent, it defaults to "strict" instead return config.TSStrict } // This information is only used for error messages type tsTargetKey struct { LowerValue string Source logger.Source Range logger.Range } type TSConfigPath struct { Text string Loc logger.Loc } type TSConfigPaths struct { Map map[string][]TSConfigPath // This may be different from the original "tsconfig.json" source if the // "paths" value is from another file via an "extends" clause. Source logger.Source } 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 // format that appears to be defined by the implementation details of the // TypeScript compiler. // // Attempt to parse it anyway by modifying the JSON parser, but just for // these particular files. This is likely not a completely accurate // emulation of what the TypeScript compiler does (e.g. string escape // behavior may also be different). json, ok := jsonCache.Parse(log, source, js_parser.JSONOptions{Flavor: js_lexer.TSConfigJSON}) if !ok { return nil } var result TSConfigJSON result.AbsPath = source.KeyPath.Text tracker := logger.MakeLineColumnTracker(&source) // Parse "extends" if extends != nil { if valueJSON, _, ok := getProperty(json, "extends"); ok { if value, ok := getString(valueJSON); ok { if base := extends(value, source.RangeOfString(valueJSON.Loc)); base != nil { result.applyExtendedConfig(*base) } } else if array, ok := valueJSON.Data.(*js_ast.EArray); ok { for _, item := range array.Items { if str, ok := getString(item); ok { if base := extends(str, source.RangeOfString(item.Loc)); base != nil { result.applyExtendedConfig(*base) } } } } } } // Parse "compilerOptions" if compilerOptionsJSON, _, ok := getProperty(json, "compilerOptions"); ok { // 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 } } // Parse "jsx" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "jsx"); ok { if value, ok := getString(valueJSON); ok { switch strings.ToLower(value) { case "preserve": result.JSXSettings.JSX = config.TSJSXPreserve case "react-native": result.JSXSettings.JSX = config.TSJSXReactNative case "react": result.JSXSettings.JSX = config.TSJSXReact case "react-jsx": result.JSXSettings.JSX = config.TSJSXReactJSX case "react-jsxdev": result.JSXSettings.JSX = config.TSJSXReactJSXDev } } } // Parse "jsxFactory" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "jsxFactory"); ok { if value, ok := getString(valueJSON); ok { result.JSXSettings.JSXFactory = parseMemberExpressionForJSX(log, &source, &tracker, valueJSON.Loc, value) } } // Parse "jsxFragmentFactory" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "jsxFragmentFactory"); ok { if value, ok := getString(valueJSON); ok { result.JSXSettings.JSXFragmentFactory = parseMemberExpressionForJSX(log, &source, &tracker, valueJSON.Loc, value) } } // Parse "jsxImportSource" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "jsxImportSource"); ok { if value, ok := getString(valueJSON); ok { result.JSXSettings.JSXImportSource = &value } } // Parse "experimentalDecorators" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "experimentalDecorators"); ok { if value, ok := getBool(valueJSON); ok { if value { result.Settings.ExperimentalDecorators = config.True } else { result.Settings.ExperimentalDecorators = config.False } } } // Parse "useDefineForClassFields" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "useDefineForClassFields"); ok { if value, ok := getBool(valueJSON); ok { if value { result.Settings.UseDefineForClassFields = config.True } else { result.Settings.UseDefineForClassFields = config.False } } } // Parse "target" if valueJSON, keyLoc, ok := getProperty(compilerOptionsJSON, "target"); ok { if value, ok := getString(valueJSON); ok { lowerValue := strings.ToLower(value) ok := true // See https://www.typescriptlang.org/tsconfig#target switch lowerValue { case "es3", "es5", "es6", "es2015", "es2016", "es2017", "es2018", "es2019", "es2020", "es2021": result.Settings.Target = config.TSTargetBelowES2022 case "es2022", "es2023", "es2024", "es2025", "esnext": result.Settings.Target = config.TSTargetAtOrAboveES2022 default: ok = false if !helpers.IsInsideNodeModules(source.KeyPath.Text) { log.AddID(logger.MsgID_TSConfigJSON_InvalidTarget, logger.Warning, &tracker, source.RangeOfString(valueJSON.Loc), fmt.Sprintf("Unrecognized target environment %q", value)) } } if ok { result.tsTargetKey = tsTargetKey{ Source: source, Range: source.RangeOfString(keyLoc), LowerValue: lowerValue, } } } } // Parse "strict" if valueJSON, keyLoc, ok := getProperty(compilerOptionsJSON, "strict"); ok { if value, ok := getBool(valueJSON); ok { valueRange := js_lexer.RangeOfIdentifier(source, valueJSON.Loc) result.TSStrict = &config.TSAlwaysStrict{ Name: "strict", Value: value, Source: source, Range: logger.Range{Loc: keyLoc, Len: valueRange.End() - keyLoc.Start}, } } } // Parse "alwaysStrict" if valueJSON, keyLoc, ok := getProperty(compilerOptionsJSON, "alwaysStrict"); ok { if value, ok := getBool(valueJSON); ok { valueRange := js_lexer.RangeOfIdentifier(source, valueJSON.Loc) result.TSAlwaysStrict = &config.TSAlwaysStrict{ Name: "alwaysStrict", Value: value, Source: source, Range: logger.Range{Loc: keyLoc, Len: valueRange.End() - keyLoc.Start}, } } } // Parse "importsNotUsedAsValues" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "importsNotUsedAsValues"); ok { if value, ok := getString(valueJSON); ok { switch value { case "remove": result.Settings.ImportsNotUsedAsValues = config.TSImportsNotUsedAsValues_Remove case "preserve": result.Settings.ImportsNotUsedAsValues = config.TSImportsNotUsedAsValues_Preserve case "error": result.Settings.ImportsNotUsedAsValues = config.TSImportsNotUsedAsValues_Error default: log.AddID(logger.MsgID_TSConfigJSON_InvalidImportsNotUsedAsValues, logger.Warning, &tracker, source.RangeOfString(valueJSON.Loc), fmt.Sprintf("Invalid value %q for \"importsNotUsedAsValues\"", value)) } } } // Parse "preserveValueImports" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "preserveValueImports"); ok { if value, ok := getBool(valueJSON); ok { if value { result.Settings.PreserveValueImports = config.True } else { result.Settings.PreserveValueImports = config.False } } } // Parse "verbatimModuleSyntax" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "verbatimModuleSyntax"); ok { if value, ok := getBool(valueJSON); ok { if value { result.Settings.VerbatimModuleSyntax = config.True } else { result.Settings.VerbatimModuleSyntax = config.False } } } // Parse "paths" if valueJSON, _, ok := getProperty(compilerOptionsJSON, "paths"); ok { if paths, ok := valueJSON.Data.(*js_ast.EObject); ok { 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 { if !isValidTSConfigPathPattern(key, log, &source, &tracker, prop.Key.Loc) { continue } // The "paths" field is an object which maps a pattern to an // array of remapping patterns to try, in priority order. See // the documentation for examples of how this is used: // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping. // // One particular example: // // { // "compilerOptions": { // "baseUrl": "projectRoot", // "paths": { // "*": [ // "*", // "generated/*" // ] // } // } // } // // Matching "folder1/file2" should first check "projectRoot/folder1/file2" // and then, if that didn't work, also check "projectRoot/generated/folder1/file2". if array, ok := prop.ValueOrNil.Data.(*js_ast.EArray); ok { 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}) } } } } else { log.AddID(logger.MsgID_TSConfigJSON_InvalidPaths, logger.Warning, &tracker, source.RangeOfString(prop.ValueOrNil.Loc), fmt.Sprintf( "Substitutions for pattern %q should be an array", key)) } } } } } } // Warn about compiler options not wrapped in "compilerOptions". // For example: https://github.com/evanw/esbuild/issues/3301 if obj, ok := json.Data.(*js_ast.EObject); ok { loop: for _, prop := range obj.Properties { if key, ok := prop.Key.Data.(*js_ast.EString); ok && key.Value != nil { key := helpers.UTF16ToString(key.Value) switch key { case "alwaysStrict", "baseUrl", "experimentalDecorators", "importsNotUsedAsValues", "jsx", "jsxFactory", "jsxFragmentFactory", "jsxImportSource", "paths", "preserveValueImports", "strict", "target", "useDefineForClassFields", "verbatimModuleSyntax": log.AddIDWithNotes(logger.MsgID_TSConfigJSON_InvalidTopLevelOption, logger.Warning, &tracker, source.RangeOfString(prop.Key.Loc), fmt.Sprintf("Expected the %q option to be nested inside a \"compilerOptions\" object", key), []logger.MsgData{}) break loop } } } } 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 } parts := strings.Split(text, ".") for _, part := range parts { if !js_ast.IsIdentifier(part) { warnRange := source.RangeOfString(loc) log.AddID(logger.MsgID_TSConfigJSON_InvalidJSX, logger.Warning, tracker, warnRange, fmt.Sprintf("Invalid JSX member expression: %q", text)) return nil } } return parts } func isValidTSConfigPathPattern(text string, log logger.Log, source *logger.Source, tracker *logger.LineColumnTracker, loc logger.Loc) bool { foundAsterisk := false for i := 0; i < len(text); i++ { if text[i] == '*' { if foundAsterisk { r := source.RangeOfString(loc) log.AddID(logger.MsgID_TSConfigJSON_InvalidPaths, logger.Warning, tracker, r, fmt.Sprintf( "Invalid pattern %q, must have at most one \"*\" character", text)) return false } foundAsterisk = true } } return true } func isSlash(c byte) bool { return c == '/' || c == '\\' } func isValidTSConfigPathNoBaseURLPattern(text string, log logger.Log, source *logger.Source, tracker **logger.LineColumnTracker, loc logger.Loc) bool { var c0 byte var c1 byte var c2 byte n := len(text) if n > 0 { c0 = text[0] if n > 1 { c1 = text[1] if n > 2 { c2 = text[2] } } } // Relative "." or ".." if c0 == '.' && (n == 1 || (n == 2 && c1 == '.')) { return true } // Relative "./" or "../" or ".\\" or "..\\" if c0 == '.' && (isSlash(c1) || (c1 == '.' && isSlash(c2))) { return true } // Absolute POSIX "/" or UNC "\\" if isSlash(c0) { return true } // Absolute DOS "c:/" or "c:\\" if ((c0 >= 'a' && c0 <= 'z') || (c0 >= 'A' && c0 <= 'Z')) && c1 == ':' && isSlash(c2) { return true } r := source.RangeOfString(loc) if *tracker == nil { t := logger.MakeLineColumnTracker(source) *tracker = &t } log.AddID(logger.MsgID_TSConfigJSON_InvalidPaths, logger.Warning, *tracker, r, fmt.Sprintf( "Non-relative path %q is not allowed when \"baseUrl\" is not set (did you forget a leading \"./\"?)", text)) return false }