1968 lines
71 KiB
Go
1968 lines
71 KiB
Go
package resolver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/evanw/esbuild/internal/ast"
|
|
"github.com/evanw/esbuild/internal/cache"
|
|
"github.com/evanw/esbuild/internal/compat"
|
|
"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_printer"
|
|
"github.com/evanw/esbuild/internal/logger"
|
|
)
|
|
|
|
var defaultMainFields = map[config.Platform][]string{
|
|
// Note that this means if a package specifies "main", "module", and
|
|
// "browser" then "browser" will win out over "module". This is the
|
|
// same behavior as webpack: https://github.com/webpack/webpack/issues/4674.
|
|
//
|
|
// This is deliberate because the presence of the "browser" field is a
|
|
// good signal that the "module" field may have non-browser stuff in it,
|
|
// which will crash or fail to be bundled when targeting the browser.
|
|
config.PlatformBrowser: {"browser", "module", "main"},
|
|
|
|
// Note that this means if a package specifies "module" and "main", the ES6
|
|
// module will not be selected. This means tree shaking will not work when
|
|
// targeting node environments.
|
|
//
|
|
// This is unfortunately necessary for compatibility. Some packages
|
|
// incorrectly treat the "module" field as "code for the browser". It
|
|
// actually means "code for ES6 environments" which includes both node
|
|
// and the browser.
|
|
//
|
|
// For example, the package "@firebase/app" prints a warning on startup about
|
|
// the bundler incorrectly using code meant for the browser if the bundler
|
|
// selects the "module" field instead of the "main" field.
|
|
//
|
|
// If you want to enable tree shaking when targeting node, you will have to
|
|
// configure the main fields to be "module" and then "main". Keep in mind
|
|
// that some packages may break if you do this.
|
|
config.PlatformNode: {"main", "module"},
|
|
|
|
// The neutral platform is for people that don't want esbuild to try to
|
|
// pick good defaults for their platform. In that case, the list of main
|
|
// fields is empty by default. You must explicitly configure it yourself.
|
|
config.PlatformNeutral: {},
|
|
}
|
|
|
|
// These are the main fields to use when the "main fields" setting is configured
|
|
// to something unusual, such as something without the "main" field.
|
|
var mainFieldsForFailure = []string{"main", "module"}
|
|
|
|
// Path resolution is a mess. One tricky issue is the "module" override for the
|
|
// "main" field in "package.json" files. Bundlers generally prefer "module" over
|
|
// "main" but that breaks packages that export a function in "main" for use with
|
|
// "require()", since resolving to "module" means an object will be returned. We
|
|
// attempt to handle this automatically by having import statements resolve to
|
|
// "module" but switch that out later for "main" if "require()" is used too.
|
|
type PathPair struct {
|
|
// Either secondary will be empty, or primary will be "module" and secondary
|
|
// will be "main"
|
|
Primary logger.Path
|
|
Secondary logger.Path
|
|
}
|
|
|
|
func (pp *PathPair) iter() []*logger.Path {
|
|
result := []*logger.Path{&pp.Primary, &pp.Secondary}
|
|
if !pp.HasSecondary() {
|
|
result = result[:1]
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (pp *PathPair) HasSecondary() bool {
|
|
return pp.Secondary.Text != ""
|
|
}
|
|
|
|
type SideEffectsData struct {
|
|
Source *logger.Source
|
|
Range logger.Range
|
|
|
|
// If non-empty, this false value came from a plugin
|
|
PluginName string
|
|
|
|
// If true, "sideEffects" was an array. If false, "sideEffects" was false.
|
|
IsSideEffectsArrayInJSON bool
|
|
}
|
|
|
|
type ResolveResult struct {
|
|
PathPair PathPair
|
|
|
|
// If this was resolved by a plugin, the plugin gets to store its data here
|
|
PluginData interface{}
|
|
|
|
// If not empty, these should override the default values
|
|
JSXFactory []string // Default if empty: "React.createElement"
|
|
JSXFragment []string // Default if empty: "React.Fragment"
|
|
|
|
DifferentCase *fs.DifferentCase
|
|
|
|
// If present, any ES6 imports to this file can be considered to have no side
|
|
// effects. This means they should be removed if unused.
|
|
PrimarySideEffectsData *SideEffectsData
|
|
|
|
TSTarget *config.TSTarget
|
|
|
|
IsExternal bool
|
|
|
|
// If true, the class field transform should use Object.defineProperty().
|
|
UseDefineForClassFieldsTS config.MaybeBool
|
|
|
|
// This is the "importsNotUsedAsValues" and "preserveValueImports" fields from "package.json"
|
|
UnusedImportsTS config.UnusedImportsTS
|
|
|
|
// This is the "type" field from "package.json"
|
|
ModuleType js_ast.ModuleType
|
|
}
|
|
|
|
type DebugMeta struct {
|
|
notes []logger.MsgData
|
|
suggestionText string
|
|
suggestionMessage string
|
|
}
|
|
|
|
func (dm DebugMeta) LogErrorMsg(log logger.Log, source *logger.Source, r logger.Range, text string, notes []logger.MsgData) {
|
|
tracker := logger.MakeLineColumnTracker(source)
|
|
|
|
if source != nil && dm.suggestionMessage != "" {
|
|
data := tracker.MsgData(r, dm.suggestionMessage)
|
|
data.Location.Suggestion = dm.suggestionText
|
|
dm.notes = append(dm.notes, data)
|
|
}
|
|
|
|
msg := logger.Msg{
|
|
Kind: logger.Error,
|
|
Data: tracker.MsgData(r, text),
|
|
Notes: append(dm.notes, notes...),
|
|
}
|
|
|
|
log.AddMsg(msg)
|
|
}
|
|
|
|
type Resolver interface {
|
|
Resolve(sourceDir string, importPath string, kind ast.ImportKind) (result *ResolveResult, debug DebugMeta)
|
|
ResolveAbs(absPath string) *ResolveResult
|
|
PrettyPath(path logger.Path) string
|
|
|
|
// This tries to run "Resolve" on a package path as a relative path. If
|
|
// successful, the user just forgot a leading "./" in front of the path.
|
|
ProbeResolvePackageAsRelative(sourceDir string, importPath string, kind ast.ImportKind) *ResolveResult
|
|
}
|
|
|
|
type resolver struct {
|
|
fs fs.FS
|
|
log logger.Log
|
|
caches *cache.CacheSet
|
|
options config.Options
|
|
|
|
// These are sets that represent various conditions for the "exports" field
|
|
// in package.json.
|
|
esmConditionsDefault map[string]bool
|
|
esmConditionsImport map[string]bool
|
|
esmConditionsRequire map[string]bool
|
|
|
|
// A special filtered import order for CSS "@import" imports.
|
|
//
|
|
// The "resolve extensions" setting determines the order of implicit
|
|
// extensions to try when resolving imports with the extension omitted.
|
|
// Sometimes people create a JavaScript/TypeScript file and a CSS file with
|
|
// the same name when they create a component. At a high level, users expect
|
|
// implicit extensions to resolve to the JS file when being imported from JS
|
|
// and to resolve to the CSS file when being imported from CSS.
|
|
//
|
|
// Different bundlers handle this in different ways. Parcel handles this by
|
|
// having the resolver prefer the same extension as the importing file in
|
|
// front of the configured "resolve extensions" order. Webpack's "css-loader"
|
|
// plugin just explicitly configures a special "resolve extensions" order
|
|
// consisting of only ".css" for CSS files.
|
|
//
|
|
// It's unclear what behavior is best here. What we currently do is to create
|
|
// a special filtered version of the configured "resolve extensions" order
|
|
// for CSS files that filters out any extension that has been explicitly
|
|
// configured with a non-CSS loader. This still gives users control over the
|
|
// order but avoids the scenario where we match an import in a CSS file to a
|
|
// JavaScript-related file. It's probably not perfect with plugins in the
|
|
// picture but it's better than some alternatives and probably pretty good.
|
|
atImportExtensionOrder []string
|
|
|
|
// This mutex serves two purposes. First of all, it guards access to "dirCache"
|
|
// which is potentially mutated during path resolution. But this mutex is also
|
|
// necessary for performance. The "React admin" benchmark mysteriously runs
|
|
// twice as fast when this mutex is locked around the whole resolve operation
|
|
// instead of around individual accesses to "dirCache". For some reason,
|
|
// reducing parallelism in the resolver helps the rest of the bundler go
|
|
// faster. I'm not sure why this is but please don't change this unless you
|
|
// do a lot of testing with various benchmarks and there aren't any regressions.
|
|
mutex sync.Mutex
|
|
|
|
// This cache maps a directory path to information about that directory and
|
|
// all parent directories
|
|
dirCache map[string]*dirInfo
|
|
}
|
|
|
|
type resolverQuery struct {
|
|
*resolver
|
|
debugMeta *DebugMeta
|
|
debugLogs *debugLogs
|
|
kind ast.ImportKind
|
|
}
|
|
|
|
func NewResolver(fs fs.FS, log logger.Log, caches *cache.CacheSet, options config.Options) Resolver {
|
|
// Filter out non-CSS extensions for CSS "@import" imports
|
|
atImportExtensionOrder := make([]string, 0, len(options.ExtensionOrder))
|
|
for _, ext := range options.ExtensionOrder {
|
|
if loader, ok := options.ExtensionToLoader[ext]; ok && loader != config.LoaderCSS {
|
|
continue
|
|
}
|
|
atImportExtensionOrder = append(atImportExtensionOrder, ext)
|
|
}
|
|
|
|
// Generate the condition sets for interpreting the "exports" field
|
|
esmConditionsDefault := map[string]bool{"default": true}
|
|
esmConditionsImport := map[string]bool{"import": true}
|
|
esmConditionsRequire := map[string]bool{"require": true}
|
|
for _, condition := range options.Conditions {
|
|
esmConditionsDefault[condition] = true
|
|
}
|
|
switch options.Platform {
|
|
case config.PlatformBrowser:
|
|
esmConditionsDefault["browser"] = true
|
|
case config.PlatformNode:
|
|
esmConditionsDefault["node"] = true
|
|
}
|
|
for key := range esmConditionsDefault {
|
|
esmConditionsImport[key] = true
|
|
esmConditionsRequire[key] = true
|
|
}
|
|
|
|
return &resolver{
|
|
fs: fs,
|
|
log: log,
|
|
options: options,
|
|
caches: caches,
|
|
dirCache: make(map[string]*dirInfo),
|
|
atImportExtensionOrder: atImportExtensionOrder,
|
|
esmConditionsDefault: esmConditionsDefault,
|
|
esmConditionsImport: esmConditionsImport,
|
|
esmConditionsRequire: esmConditionsRequire,
|
|
}
|
|
}
|
|
|
|
func (rr *resolver) Resolve(sourceDir string, importPath string, kind ast.ImportKind) (*ResolveResult, DebugMeta) {
|
|
var debugMeta DebugMeta
|
|
r := resolverQuery{
|
|
resolver: rr,
|
|
debugMeta: &debugMeta,
|
|
kind: kind,
|
|
}
|
|
if r.log.Level <= logger.LevelDebug {
|
|
r.debugLogs = &debugLogs{what: fmt.Sprintf(
|
|
"Resolving import %q in directory %q of type %q",
|
|
importPath, sourceDir, kind.StringForMetafile())}
|
|
}
|
|
|
|
// Certain types of URLs default to being external for convenience
|
|
if r.isExternalPattern(importPath) ||
|
|
|
|
// "fill: url(#filter);"
|
|
(kind.IsFromCSS() && strings.HasPrefix(importPath, "#")) ||
|
|
|
|
// "background: url(http://example.com/images/image.png);"
|
|
strings.HasPrefix(importPath, "http://") ||
|
|
|
|
// "background: url(https://example.com/images/image.png);"
|
|
strings.HasPrefix(importPath, "https://") ||
|
|
|
|
// "background: url(//example.com/images/image.png);"
|
|
strings.HasPrefix(importPath, "//") ||
|
|
|
|
// "import fs from 'fs'"
|
|
(r.options.Platform == config.PlatformNode && BuiltInNodeModules[importPath]) {
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Marking this path as implicitly external")
|
|
}
|
|
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return &ResolveResult{
|
|
PathPair: PathPair{Primary: logger.Path{Text: importPath}},
|
|
IsExternal: true,
|
|
}, debugMeta
|
|
}
|
|
|
|
// "import fs from 'node:fs'"
|
|
// "require('node:fs')"
|
|
if r.options.Platform == config.PlatformNode && strings.HasPrefix(importPath, "node:") {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Marking this path as implicitly external due to the \"node:\" prefix")
|
|
}
|
|
|
|
// Check whether the path will end up as "import" or "require"
|
|
convertImportToRequire := !r.options.OutputFormat.KeepES6ImportExportSyntax()
|
|
isImport := !convertImportToRequire && (kind == ast.ImportStmt || kind == ast.ImportDynamic)
|
|
isRequire := kind == ast.ImportRequire || kind == ast.ImportRequireResolve ||
|
|
(convertImportToRequire && (kind == ast.ImportStmt || kind == ast.ImportDynamic))
|
|
|
|
// Check for support with "import"
|
|
if isImport && r.options.UnsupportedJSFeatures.Has(compat.NodeColonPrefixImport) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Removing the \"node:\" prefix because the target environment doesn't support it with \"import\" statements")
|
|
}
|
|
|
|
// Automatically strip the prefix if it's not supported
|
|
importPath = importPath[5:]
|
|
}
|
|
|
|
// Check for support with "require"
|
|
if isRequire && r.options.UnsupportedJSFeatures.Has(compat.NodeColonPrefixRequire) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Removing the \"node:\" prefix because the target environment doesn't support it with \"require\" calls")
|
|
}
|
|
|
|
// Automatically strip the prefix if it's not supported
|
|
importPath = importPath[5:]
|
|
}
|
|
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return &ResolveResult{
|
|
PathPair: PathPair{Primary: logger.Path{Text: importPath}},
|
|
IsExternal: true,
|
|
}, debugMeta
|
|
}
|
|
|
|
if parsed, ok := ParseDataURL(importPath); ok {
|
|
// "import 'data:text/javascript,console.log(123)';"
|
|
// "@import 'data:text/css,body{background:white}';"
|
|
if parsed.DecodeMIMEType() != MIMETypeUnsupported {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Putting this path in the \"dataurl\" namespace")
|
|
}
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return &ResolveResult{
|
|
PathPair: PathPair{Primary: logger.Path{Text: importPath, Namespace: "dataurl"}},
|
|
}, debugMeta
|
|
}
|
|
|
|
// "background: url(data:image/png;base64,iVBORw0KGgo=);"
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Marking this data URL as external")
|
|
}
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return &ResolveResult{
|
|
PathPair: PathPair{Primary: logger.Path{Text: importPath}},
|
|
IsExternal: true,
|
|
}, debugMeta
|
|
}
|
|
|
|
// Fail now if there is no directory to resolve in. This can happen for
|
|
// virtual modules (e.g. stdin) if a resolve directory is not specified.
|
|
if sourceDir == "" {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote("Cannot resolve this path without a directory")
|
|
}
|
|
r.flushDebugLogs(flushDueToFailure)
|
|
return nil, debugMeta
|
|
}
|
|
|
|
r.mutex.Lock()
|
|
defer r.mutex.Unlock()
|
|
|
|
result := r.resolveWithoutSymlinks(sourceDir, importPath)
|
|
if result == nil {
|
|
// If resolution failed, try again with the URL query and/or hash removed
|
|
suffix := strings.IndexAny(importPath, "?#")
|
|
if suffix < 1 {
|
|
r.flushDebugLogs(flushDueToFailure)
|
|
return nil, debugMeta
|
|
}
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Retrying resolution after removing the suffix %q", importPath[suffix:]))
|
|
}
|
|
if result2 := r.resolveWithoutSymlinks(sourceDir, importPath[:suffix]); result2 == nil {
|
|
r.flushDebugLogs(flushDueToFailure)
|
|
return nil, debugMeta
|
|
} else {
|
|
result = result2
|
|
result.PathPair.Primary.IgnoredSuffix = importPath[suffix:]
|
|
if result.PathPair.HasSecondary() {
|
|
result.PathPair.Secondary.IgnoredSuffix = importPath[suffix:]
|
|
}
|
|
}
|
|
}
|
|
|
|
// If successful, resolve symlinks using the directory info cache
|
|
r.finalizeResolve(result)
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return result, debugMeta
|
|
}
|
|
|
|
func (r resolverQuery) isExternalPattern(path string) bool {
|
|
for _, pattern := range r.options.ExternalModules.Patterns {
|
|
if len(path) >= len(pattern.Prefix)+len(pattern.Suffix) &&
|
|
strings.HasPrefix(path, pattern.Prefix) &&
|
|
strings.HasSuffix(path, pattern.Suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (rr *resolver) ResolveAbs(absPath string) *ResolveResult {
|
|
r := resolverQuery{resolver: rr}
|
|
if r.log.Level <= logger.LevelDebug {
|
|
r.debugLogs = &debugLogs{what: fmt.Sprintf("Getting metadata for absolute path %s", absPath)}
|
|
}
|
|
|
|
r.mutex.Lock()
|
|
defer r.mutex.Unlock()
|
|
|
|
// Just decorate the absolute path with information from parent directories
|
|
result := &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: absPath, Namespace: "file"}}}
|
|
r.finalizeResolve(result)
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return result
|
|
}
|
|
|
|
func (rr *resolver) ProbeResolvePackageAsRelative(sourceDir string, importPath string, kind ast.ImportKind) *ResolveResult {
|
|
r := resolverQuery{
|
|
resolver: rr,
|
|
kind: kind,
|
|
}
|
|
absPath := r.fs.Join(sourceDir, importPath)
|
|
|
|
r.mutex.Lock()
|
|
defer r.mutex.Unlock()
|
|
|
|
if pair, ok, diffCase := r.loadAsFileOrDirectory(absPath); ok {
|
|
result := &ResolveResult{PathPair: pair, DifferentCase: diffCase}
|
|
r.finalizeResolve(result)
|
|
r.flushDebugLogs(flushDueToSuccess)
|
|
return result
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type debugLogs struct {
|
|
what string
|
|
indent string
|
|
notes []logger.MsgData
|
|
}
|
|
|
|
func (d *debugLogs) addNote(text string) {
|
|
if d.indent != "" {
|
|
text = d.indent + text
|
|
}
|
|
d.notes = append(d.notes, logger.MsgData{Text: text})
|
|
}
|
|
|
|
func (d *debugLogs) increaseIndent() {
|
|
d.indent += " "
|
|
}
|
|
|
|
func (d *debugLogs) decreaseIndent() {
|
|
d.indent = d.indent[2:]
|
|
}
|
|
|
|
type flushMode uint8
|
|
|
|
const (
|
|
flushDueToFailure flushMode = iota
|
|
flushDueToSuccess
|
|
)
|
|
|
|
func (r resolverQuery) flushDebugLogs(mode flushMode) {
|
|
if r.debugLogs != nil {
|
|
if mode == flushDueToFailure {
|
|
r.log.AddWithNotes(logger.Debug, nil, logger.Range{}, r.debugLogs.what, r.debugLogs.notes)
|
|
} else if r.log.Level <= logger.LevelVerbose {
|
|
r.log.AddWithNotes(logger.Verbose, nil, logger.Range{}, r.debugLogs.what, r.debugLogs.notes)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r resolverQuery) finalizeResolve(result *ResolveResult) {
|
|
for _, path := range result.PathPair.iter() {
|
|
if path.Namespace == "file" {
|
|
if dirInfo := r.dirInfoCached(r.fs.Dir(path.Text)); dirInfo != nil {
|
|
base := r.fs.Base(path.Text)
|
|
|
|
// Look up this file in the "sideEffects" map in the nearest enclosing
|
|
// directory with a "package.json" file.
|
|
//
|
|
// Only do this for the primary path. Some packages have the primary
|
|
// path marked as having side effects and the secondary path marked
|
|
// as not having side effects. This is likely a bug in the package
|
|
// definition but we don't want to consider the primary path as not
|
|
// having side effects just because the secondary path is marked as
|
|
// not having side effects.
|
|
if pkgJSON := dirInfo.enclosingPackageJSON; pkgJSON != nil && *path == result.PathPair.Primary {
|
|
if pkgJSON.sideEffectsMap != nil {
|
|
hasSideEffects := false
|
|
if pkgJSON.sideEffectsMap[path.Text] {
|
|
// Fast path: map lookup
|
|
hasSideEffects = true
|
|
} else {
|
|
// Slow path: glob tests
|
|
for _, re := range pkgJSON.sideEffectsRegexps {
|
|
if re.MatchString(path.Text) {
|
|
hasSideEffects = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasSideEffects {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Marking this file as having no side effects due to %q",
|
|
pkgJSON.source.KeyPath.Text))
|
|
}
|
|
result.PrimarySideEffectsData = pkgJSON.sideEffectsData
|
|
}
|
|
}
|
|
|
|
// Also copy over the "type" field
|
|
result.ModuleType = pkgJSON.moduleType
|
|
}
|
|
|
|
// Copy various fields from the nearest enclosing "tsconfig.json" file if present
|
|
if path == &result.PathPair.Primary && dirInfo.enclosingTSConfigJSON != nil {
|
|
// Except don't do this if we're inside a "node_modules" directory. Package
|
|
// authors often publish their "tsconfig.json" files to npm because of
|
|
// npm's default-include publishing model and because these authors
|
|
// probably don't know about ".npmignore" files.
|
|
//
|
|
// People trying to use these packages with esbuild have historically
|
|
// complained that esbuild is respecting "tsconfig.json" in these cases.
|
|
// The assumption is that the package author published these files by
|
|
// accident.
|
|
//
|
|
// Ignoring "tsconfig.json" files inside "node_modules" directories breaks
|
|
// the use case of publishing TypeScript code and having it be transpiled
|
|
// for you, but that's the uncommon case and likely doesn't work with
|
|
// many other tools anyway. So now these files are ignored.
|
|
if helpers.IsInsideNodeModules(result.PathPair.Primary.Text) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Ignoring %q because %q is inside \"node_modules\"",
|
|
dirInfo.enclosingTSConfigJSON.AbsPath,
|
|
result.PathPair.Primary.Text))
|
|
}
|
|
} else {
|
|
result.JSXFactory = dirInfo.enclosingTSConfigJSON.JSXFactory
|
|
result.JSXFragment = dirInfo.enclosingTSConfigJSON.JSXFragmentFactory
|
|
result.UseDefineForClassFieldsTS = dirInfo.enclosingTSConfigJSON.UseDefineForClassFields
|
|
result.UnusedImportsTS = config.UnusedImportsFromTsconfigValues(
|
|
dirInfo.enclosingTSConfigJSON.PreserveImportsNotUsedAsValues,
|
|
dirInfo.enclosingTSConfigJSON.PreserveValueImports,
|
|
)
|
|
result.TSTarget = dirInfo.enclosingTSConfigJSON.TSTarget
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("This import is under the effect of %q",
|
|
dirInfo.enclosingTSConfigJSON.AbsPath))
|
|
if result.JSXFactory != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("\"jsxFactory\" is %q due to %q",
|
|
strings.Join(result.JSXFactory, "."),
|
|
dirInfo.enclosingTSConfigJSON.AbsPath))
|
|
}
|
|
if result.JSXFragment != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("\"jsxFragment\" is %q due to %q",
|
|
strings.Join(result.JSXFragment, "."),
|
|
dirInfo.enclosingTSConfigJSON.AbsPath))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !r.options.PreserveSymlinks {
|
|
if entry, _ := dirInfo.entries.Get(base); entry != nil {
|
|
if symlink := entry.Symlink(r.fs); symlink != "" {
|
|
// Is this entry itself a symlink?
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved symlink %q to %q", path.Text, symlink))
|
|
}
|
|
path.Text = symlink
|
|
} else if dirInfo.absRealPath != "" {
|
|
// Is there at least one parent directory with a symlink?
|
|
symlink := r.fs.Join(dirInfo.absRealPath, base)
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved symlink %q to %q", path.Text, symlink))
|
|
}
|
|
path.Text = symlink
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Primary path is %q in namespace %q", result.PathPair.Primary.Text, result.PathPair.Primary.Namespace))
|
|
if result.PathPair.HasSecondary() {
|
|
r.debugLogs.addNote(fmt.Sprintf("Secondary path is %q in namespace %q", result.PathPair.Secondary.Text, result.PathPair.Secondary.Namespace))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r resolverQuery) resolveWithoutSymlinks(sourceDir string, importPath string) *ResolveResult {
|
|
// This implements the module resolution algorithm from node.js, which is
|
|
// described here: https://nodejs.org/api/modules.html#modules_all_together
|
|
var result ResolveResult
|
|
|
|
// Return early if this is already an absolute path. In addition to asking
|
|
// the file system whether this is an absolute path, we also explicitly check
|
|
// whether it starts with a "/" and consider that an absolute path too. This
|
|
// is because relative paths can technically start with a "/" on Windows
|
|
// because it's not an absolute path on Windows. Then people might write code
|
|
// with imports that start with a "/" that works fine on Windows only to
|
|
// experience unexpected build failures later on other operating systems.
|
|
// Treating these paths as absolute paths on all platforms means Windows
|
|
// users will not be able to accidentally make use of these paths.
|
|
if strings.HasPrefix(importPath, "/") || r.fs.IsAbs(importPath) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The import %q is being treated as an absolute path", importPath))
|
|
}
|
|
|
|
// First, check path overrides from the nearest enclosing TypeScript "tsconfig.json" file
|
|
if dirInfo := r.dirInfoCached(sourceDir); dirInfo != nil && dirInfo.enclosingTSConfigJSON != nil && dirInfo.enclosingTSConfigJSON.Paths != nil {
|
|
if absolute, ok, diffCase := r.matchTSConfigPaths(dirInfo.enclosingTSConfigJSON, importPath); ok {
|
|
return &ResolveResult{PathPair: absolute, DifferentCase: diffCase}
|
|
}
|
|
}
|
|
|
|
if r.options.ExternalModules.AbsPaths != nil && r.options.ExternalModules.AbsPaths[importPath] {
|
|
// If the string literal in the source text is an absolute path and has
|
|
// been marked as an external module, mark it as *not* an absolute path.
|
|
// That way we preserve the literal text in the output and don't generate
|
|
// a relative path from the output directory to that path.
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The path %q was marked as external by the user", importPath))
|
|
}
|
|
return &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: importPath}}, IsExternal: true}
|
|
}
|
|
|
|
// Run node's resolution rules (e.g. adding ".js")
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(importPath); ok {
|
|
return &ResolveResult{PathPair: absolute, DifferentCase: diffCase}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Check both relative and package paths for CSS URL tokens, with relative
|
|
// paths taking precedence over package paths to match Webpack behavior.
|
|
isPackagePath := IsPackagePath(importPath)
|
|
checkRelative := !isPackagePath || r.kind == ast.ImportURL || r.kind == ast.ImportAt
|
|
checkPackage := isPackagePath
|
|
|
|
if checkRelative {
|
|
absPath := r.fs.Join(sourceDir, importPath)
|
|
|
|
// Check for external packages first
|
|
if r.options.ExternalModules.AbsPaths != nil && r.options.ExternalModules.AbsPaths[absPath] {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The path %q was marked as external by the user", absPath))
|
|
}
|
|
return &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: absPath, Namespace: "file"}}, IsExternal: true}
|
|
}
|
|
|
|
// Check the "browser" map
|
|
if importDirInfo := r.dirInfoCached(r.fs.Dir(absPath)); importDirInfo != nil {
|
|
if remapped, ok := r.checkBrowserMap(importDirInfo, absPath, absolutePathKind); ok {
|
|
if remapped == nil {
|
|
return &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: absPath, Namespace: "file", Flags: logger.PathDisabled}}}
|
|
}
|
|
if remappedResult, ok, diffCase := r.resolveWithoutRemapping(importDirInfo.enclosingBrowserScope, *remapped); ok {
|
|
result = ResolveResult{PathPair: remappedResult, DifferentCase: diffCase}
|
|
checkRelative = false
|
|
checkPackage = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if checkRelative {
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absPath); ok {
|
|
checkPackage = false
|
|
result = ResolveResult{PathPair: absolute, DifferentCase: diffCase}
|
|
} else if !checkPackage {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if checkPackage {
|
|
// Check for external packages first
|
|
if r.options.ExternalModules.NodeModules != nil {
|
|
query := importPath
|
|
for {
|
|
if r.options.ExternalModules.NodeModules[query] {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The path %q was marked as external by the user", query))
|
|
}
|
|
return &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: importPath}}, IsExternal: true}
|
|
}
|
|
|
|
// If the module "foo" has been marked as external, we also want to treat
|
|
// paths into that module such as "foo/bar" as external too.
|
|
slash := strings.LastIndexByte(query, '/')
|
|
if slash == -1 {
|
|
break
|
|
}
|
|
query = query[:slash]
|
|
}
|
|
}
|
|
|
|
sourceDirInfo := r.dirInfoCached(sourceDir)
|
|
if sourceDirInfo == nil {
|
|
// Bail if the directory is missing for some reason
|
|
return nil
|
|
}
|
|
|
|
// Support remapping one package path to another via the "browser" field
|
|
if remapped, ok := r.checkBrowserMap(sourceDirInfo, importPath, packagePathKind); ok {
|
|
if remapped == nil {
|
|
// "browser": {"module": false}
|
|
if absolute, ok, diffCase := r.loadNodeModules(importPath, sourceDirInfo, false /* forbidImports */); ok {
|
|
absolute.Primary = logger.Path{Text: absolute.Primary.Text, Namespace: "file", Flags: logger.PathDisabled}
|
|
if absolute.HasSecondary() {
|
|
absolute.Secondary = logger.Path{Text: absolute.Secondary.Text, Namespace: "file", Flags: logger.PathDisabled}
|
|
}
|
|
return &ResolveResult{PathPair: absolute, DifferentCase: diffCase}
|
|
} else {
|
|
return &ResolveResult{PathPair: PathPair{Primary: logger.Path{Text: importPath, Flags: logger.PathDisabled}}, DifferentCase: diffCase}
|
|
}
|
|
}
|
|
|
|
// "browser": {"module": "./some-file"}
|
|
// "browser": {"module": "another-module"}
|
|
importPath = *remapped
|
|
sourceDirInfo = sourceDirInfo.enclosingBrowserScope
|
|
}
|
|
|
|
if absolute, ok, diffCase := r.resolveWithoutRemapping(sourceDirInfo, importPath); ok {
|
|
result = ResolveResult{PathPair: absolute, DifferentCase: diffCase}
|
|
} else {
|
|
// Note: node's "self references" are not currently supported
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return &result
|
|
}
|
|
|
|
func (r resolverQuery) resolveWithoutRemapping(sourceDirInfo *dirInfo, importPath string) (PathPair, bool, *fs.DifferentCase) {
|
|
if IsPackagePath(importPath) {
|
|
return r.loadNodeModules(importPath, sourceDirInfo, false /* forbidImports */)
|
|
} else {
|
|
return r.loadAsFileOrDirectory(r.fs.Join(sourceDirInfo.absPath, importPath))
|
|
}
|
|
}
|
|
|
|
func (r *resolver) PrettyPath(path logger.Path) string {
|
|
if path.Namespace == "file" {
|
|
if rel, ok := r.fs.Rel(r.fs.Cwd(), path.Text); ok {
|
|
path.Text = rel
|
|
}
|
|
|
|
// These human-readable paths are used in error messages, comments in output
|
|
// files, source names in source maps, and paths in the metadata JSON file.
|
|
// These should be platform-independent so our output doesn't depend on which
|
|
// operating system it was run. Replace Windows backward slashes with standard
|
|
// forward slashes.
|
|
path.Text = strings.ReplaceAll(path.Text, "\\", "/")
|
|
} else if path.Namespace != "" {
|
|
path.Text = fmt.Sprintf("%s:%s", path.Namespace, path.Text)
|
|
}
|
|
|
|
if path.IsDisabled() {
|
|
path.Text = "(disabled):" + path.Text
|
|
}
|
|
|
|
return path.Text + path.IgnoredSuffix
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type dirInfo struct {
|
|
// These objects are immutable, so we can just point to the parent directory
|
|
// and avoid having to lock the cache again
|
|
parent *dirInfo
|
|
|
|
// A pointer to the enclosing dirInfo with a valid "browser" field in
|
|
// package.json. We need this to remap paths after they have been resolved.
|
|
enclosingBrowserScope *dirInfo
|
|
|
|
// All relevant information about this directory
|
|
absPath string
|
|
entries fs.DirEntries
|
|
isNodeModules bool // Is the base name "node_modules"?
|
|
hasNodeModules bool // Is there a "node_modules" subdirectory?
|
|
packageJSON *packageJSON // Is there a "package.json" file in this directory?
|
|
enclosingPackageJSON *packageJSON // Is there a "package.json" file in this directory or a parent directory?
|
|
enclosingTSConfigJSON *TSConfigJSON // Is there a "tsconfig.json" file in this directory or a parent directory?
|
|
absRealPath string // If non-empty, this is the real absolute path resolving any symlinks
|
|
}
|
|
|
|
func (r resolverQuery) dirInfoCached(path string) *dirInfo {
|
|
// First, check the cache
|
|
cached, ok := r.dirCache[path]
|
|
|
|
// Cache hit: stop now
|
|
if !ok {
|
|
// Cache miss: read the info
|
|
cached = r.dirInfoUncached(path)
|
|
|
|
// Update the cache unconditionally. Even if the read failed, we don't want to
|
|
// retry again later. The directory is inaccessible so trying again is wasted.
|
|
r.dirCache[path] = cached
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
if cached == nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to read directory %q", path))
|
|
} else {
|
|
count := len(cached.entries.SortedKeys())
|
|
entries := "entries"
|
|
if count == 1 {
|
|
entries = "entry"
|
|
}
|
|
r.debugLogs.addNote(fmt.Sprintf("Read %d %s for directory %q", count, entries, path))
|
|
}
|
|
}
|
|
|
|
return cached
|
|
}
|
|
|
|
var errParseErrorImportCycle = errors.New("(import cycle)")
|
|
var errParseErrorAlreadyLogged = errors.New("(error already logged)")
|
|
|
|
// This may return "parseErrorAlreadyLogged" in which case there was a syntax
|
|
// error, but it's already been reported. No further errors should be 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) {
|
|
// Don't infinite loop if a series of "extends" links forms a cycle
|
|
if visited[file] {
|
|
return nil, errParseErrorImportCycle
|
|
}
|
|
visited[file] = true
|
|
|
|
contents, err, originalError := r.caches.FSCache.ReadFile(r.fs, file)
|
|
if r.debugLogs != nil && originalError != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to read file %q: %s", file, originalError.Error()))
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The file %q exists", file))
|
|
}
|
|
|
|
keyPath := logger.Path{Text: file, Namespace: "file"}
|
|
source := logger.Source{
|
|
KeyPath: keyPath,
|
|
PrettyPath: r.PrettyPath(keyPath),
|
|
Contents: contents,
|
|
}
|
|
tracker := logger.MakeLineColumnTracker(&source)
|
|
fileDir := r.fs.Dir(file)
|
|
|
|
result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, func(extends string, extendsRange logger.Range) *TSConfigJSON {
|
|
if IsPackagePath(extends) {
|
|
// If this is a package path, try to resolve it to a "node_modules"
|
|
// folder. This doesn't use the normal node module resolution algorithm
|
|
// both because it's different (e.g. we don't want to match a directory)
|
|
// and because it would deadlock since we're currently in the middle of
|
|
// populating the directory info cache.
|
|
current := fileDir
|
|
for {
|
|
// Skip "node_modules" folders
|
|
if r.fs.Base(current) != "node_modules" {
|
|
join := r.fs.Join(current, "node_modules", extends)
|
|
filesToCheck := []string{r.fs.Join(join, "tsconfig.json"), join, join + ".json"}
|
|
for _, fileToCheck := range filesToCheck {
|
|
base, err := r.parseTSConfig(fileToCheck, visited)
|
|
if err == nil {
|
|
return base
|
|
} else if err == syscall.ENOENT {
|
|
continue
|
|
} else if err == errParseErrorImportCycle {
|
|
r.log.Add(logger.Warning, &tracker, extendsRange,
|
|
fmt.Sprintf("Base config file %q forms cycle", extends))
|
|
} else if err != errParseErrorAlreadyLogged {
|
|
r.log.Add(logger.Error, &tracker, extendsRange,
|
|
fmt.Sprintf("Cannot read file %q: %s",
|
|
r.PrettyPath(logger.Path{Text: fileToCheck, Namespace: "file"}), err.Error()))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Go to the parent directory, stopping at the file system root
|
|
next := r.fs.Dir(current)
|
|
if current == next {
|
|
break
|
|
}
|
|
current = next
|
|
}
|
|
} else {
|
|
// If this is a regular path, search relative to the enclosing directory
|
|
extendsFile := extends
|
|
if !r.fs.IsAbs(extends) {
|
|
extendsFile = r.fs.Join(fileDir, extends)
|
|
}
|
|
for _, fileToCheck := range []string{extendsFile, extendsFile + ".json"} {
|
|
base, err := r.parseTSConfig(fileToCheck, visited)
|
|
if err == nil {
|
|
return base
|
|
} else if err == syscall.ENOENT {
|
|
continue
|
|
} else if err == errParseErrorImportCycle {
|
|
r.log.Add(logger.Warning, &tracker, extendsRange,
|
|
fmt.Sprintf("Base config file %q forms cycle", extends))
|
|
} else if err != errParseErrorAlreadyLogged {
|
|
r.log.Add(logger.Error, &tracker, extendsRange,
|
|
fmt.Sprintf("Cannot read file %q: %s",
|
|
r.PrettyPath(logger.Path{Text: fileToCheck, Namespace: "file"}), err.Error()))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Suppress warnings about missing base config files inside "node_modules"
|
|
if !helpers.IsInsideNodeModules(file) {
|
|
r.log.Add(logger.Warning, &tracker, extendsRange,
|
|
fmt.Sprintf("Cannot find base config file %q", extends))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if result == nil {
|
|
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)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (r resolverQuery) dirInfoUncached(path string) *dirInfo {
|
|
// Get the info for the parent directory
|
|
var parentInfo *dirInfo
|
|
parentDir := r.fs.Dir(path)
|
|
if parentDir != path {
|
|
parentInfo = r.dirInfoCached(parentDir)
|
|
|
|
// Stop now if the parent directory doesn't exist
|
|
if parentInfo == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// List the directories
|
|
entries, err, originalError := r.fs.ReadDirectory(path)
|
|
if err == syscall.EACCES {
|
|
// Just pretend this directory is empty if we can't access it. This is the
|
|
// case on Unix for directories that only have the execute permission bit
|
|
// set. It means we will just pass through the empty directory and
|
|
// continue to check the directories above it, which is now node behaves.
|
|
entries = fs.MakeEmptyDirEntries(path)
|
|
err = nil
|
|
}
|
|
if r.debugLogs != nil && originalError != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to read directory %q: %s", path, originalError.Error()))
|
|
}
|
|
if err != nil {
|
|
// Ignore "ENOTDIR" here so that calling "ReadDirectory" on a file behaves
|
|
// as if there is nothing there at all instead of causing an error due to
|
|
// the directory actually being a file. This is a workaround for situations
|
|
// where people try to import from a path containing a file as a parent
|
|
// directory. The "pnpm" package manager generates a faulty "NODE_PATH"
|
|
// list which contains such paths and treating them as missing means we just
|
|
// ignore them during path resolution.
|
|
if err != syscall.ENOENT && err != syscall.ENOTDIR {
|
|
r.log.Add(logger.Error, nil, logger.Range{},
|
|
fmt.Sprintf("Cannot read directory %q: %s",
|
|
r.PrettyPath(logger.Path{Text: path, Namespace: "file"}), err.Error()))
|
|
}
|
|
return nil
|
|
}
|
|
info := &dirInfo{
|
|
absPath: path,
|
|
parent: parentInfo,
|
|
entries: entries,
|
|
}
|
|
|
|
// A "node_modules" directory isn't allowed to directly contain another "node_modules" directory
|
|
base := r.fs.Base(path)
|
|
if base == "node_modules" {
|
|
info.isNodeModules = true
|
|
} else if entry, _ := entries.Get("node_modules"); entry != nil {
|
|
info.hasNodeModules = entry.Kind(r.fs) == fs.DirEntry
|
|
}
|
|
|
|
// Propagate the browser scope into child directories
|
|
if parentInfo != nil {
|
|
info.enclosingPackageJSON = parentInfo.enclosingPackageJSON
|
|
info.enclosingBrowserScope = parentInfo.enclosingBrowserScope
|
|
info.enclosingTSConfigJSON = parentInfo.enclosingTSConfigJSON
|
|
|
|
// Make sure "absRealPath" is the real path of the directory (resolving any symlinks)
|
|
if !r.options.PreserveSymlinks {
|
|
if entry, _ := parentInfo.entries.Get(base); entry != nil {
|
|
if symlink := entry.Symlink(r.fs); symlink != "" {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved symlink %q to %q", path, symlink))
|
|
}
|
|
info.absRealPath = symlink
|
|
} else if parentInfo.absRealPath != "" {
|
|
symlink := r.fs.Join(parentInfo.absRealPath, base)
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved symlink %q to %q", path, symlink))
|
|
}
|
|
info.absRealPath = symlink
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record if this directory has a package.json file
|
|
if entry, _ := entries.Get("package.json"); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
info.packageJSON = r.parsePackageJSON(path)
|
|
|
|
// Propagate this "package.json" file into child directories
|
|
if info.packageJSON != nil {
|
|
info.enclosingPackageJSON = info.packageJSON
|
|
if info.packageJSON.browserMap != nil {
|
|
info.enclosingBrowserScope = info
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record if this directory has a tsconfig.json or jsconfig.json file
|
|
{
|
|
var tsConfigPath string
|
|
if forceTsConfig := r.options.TsConfigOverride; forceTsConfig == "" {
|
|
if entry, _ := entries.Get("tsconfig.json"); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
tsConfigPath = r.fs.Join(path, "tsconfig.json")
|
|
} else if entry, _ := entries.Get("jsconfig.json"); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
tsConfigPath = r.fs.Join(path, "jsconfig.json")
|
|
}
|
|
} else if parentInfo == nil {
|
|
// If there is a tsconfig.json override, mount it at the root directory
|
|
tsConfigPath = forceTsConfig
|
|
}
|
|
if tsConfigPath != "" {
|
|
var err error
|
|
info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool))
|
|
if err != nil {
|
|
if err == syscall.ENOENT {
|
|
r.log.Add(logger.Error, nil, logger.Range{}, fmt.Sprintf("Cannot find tsconfig file %q",
|
|
r.PrettyPath(logger.Path{Text: tsConfigPath, Namespace: "file"})))
|
|
} else if err != errParseErrorAlreadyLogged {
|
|
r.log.Add(logger.Debug, nil, logger.Range{},
|
|
fmt.Sprintf("Cannot read file %q: %s",
|
|
r.PrettyPath(logger.Path{Text: tsConfigPath, Namespace: "file"}), err.Error()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
var rewrittenFileExtensions = map[string][]string{
|
|
// Note that the official compiler code always tries ".ts" before
|
|
// ".tsx" even if the original extension was ".jsx".
|
|
".js": {".ts", ".tsx"},
|
|
".jsx": {".ts", ".tsx"},
|
|
".mjs": {".mts"},
|
|
".cjs": {".cts"},
|
|
}
|
|
|
|
func (r resolverQuery) loadAsFile(path string, extensionOrder []string) (string, bool, *fs.DifferentCase) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Attempting to load %q as a file", path))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
// Read the directory entries once to minimize locking
|
|
dirPath := r.fs.Dir(path)
|
|
entries, err, originalError := r.fs.ReadDirectory(dirPath)
|
|
if r.debugLogs != nil && originalError != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to read directory %q: %s", dirPath, originalError.Error()))
|
|
}
|
|
if err != nil {
|
|
if err != syscall.ENOENT {
|
|
r.log.Add(logger.Error, nil, logger.Range{},
|
|
fmt.Sprintf(" Cannot read directory %q: %s",
|
|
r.PrettyPath(logger.Path{Text: dirPath, Namespace: "file"}), err.Error()))
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
base := r.fs.Base(path)
|
|
|
|
// Try the plain path without any extensions
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Checking for file %q", base))
|
|
}
|
|
if entry, diffCase := entries.Get(base); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found file %q", base))
|
|
}
|
|
return path, true, diffCase
|
|
}
|
|
|
|
// Try the path with extensions
|
|
for _, ext := range extensionOrder {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Checking for file %q", base+ext))
|
|
}
|
|
if entry, diffCase := entries.Get(base + ext); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found file %q", base+ext))
|
|
}
|
|
return path + ext, true, diffCase
|
|
}
|
|
}
|
|
|
|
// TypeScript-specific behavior: if the extension is ".js" or ".jsx", try
|
|
// replacing it with ".ts" or ".tsx". At the time of writing this specific
|
|
// behavior comes from the function "loadModuleFromFile()" in the file
|
|
// "moduleNameResolver.ts" in the TypeScript compiler source code. It
|
|
// contains this comment:
|
|
//
|
|
// If that didn't work, try stripping a ".js" or ".jsx" extension and
|
|
// replacing it with a TypeScript one; e.g. "./foo.js" can be matched
|
|
// by "./foo.ts" or "./foo.d.ts"
|
|
//
|
|
// We don't care about ".d.ts" files because we can't do anything with
|
|
// those, so we ignore that part of the behavior.
|
|
//
|
|
// See the discussion here for more historical context:
|
|
// https://github.com/microsoft/TypeScript/issues/4595
|
|
for old, exts := range rewrittenFileExtensions {
|
|
if !strings.HasSuffix(base, old) {
|
|
continue
|
|
}
|
|
lastDot := strings.LastIndexByte(base, '.')
|
|
for _, ext := range exts {
|
|
if entry, diffCase := entries.Get(base[:lastDot] + ext); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Rewrote to %q", base[:lastDot]+ext))
|
|
}
|
|
return path[:len(path)-(len(base)-lastDot)] + ext, true, diffCase
|
|
}
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to rewrite to %q", base[:lastDot]+ext))
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to find file %q", base))
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func (r resolverQuery) loadAsIndex(dirInfo *dirInfo, path string, extensionOrder []string) (PathPair, bool, *fs.DifferentCase) {
|
|
// Try the "index" file with extensions
|
|
for _, ext := range extensionOrder {
|
|
base := "index" + ext
|
|
if entry, diffCase := dirInfo.entries.Get(base); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found file %q", r.fs.Join(path, base)))
|
|
}
|
|
return PathPair{Primary: logger.Path{Text: r.fs.Join(path, base), Namespace: "file"}}, true, diffCase
|
|
}
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Failed to find file %q", r.fs.Join(path, base)))
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
func (r resolverQuery) loadAsIndexWithBrowserRemapping(dirInfo *dirInfo, path string, extensionOrder []string) (PathPair, bool, *fs.DifferentCase) {
|
|
// Potentially remap using the "browser" field
|
|
absPath := r.fs.Join(path, "index")
|
|
if remapped, ok := r.checkBrowserMap(dirInfo, absPath, absolutePathKind); ok {
|
|
if remapped == nil {
|
|
return PathPair{Primary: logger.Path{Text: absPath, Namespace: "file", Flags: logger.PathDisabled}}, true, nil
|
|
}
|
|
remappedAbs := r.fs.Join(path, *remapped)
|
|
|
|
// Is this a file?
|
|
absolute, ok, diffCase := r.loadAsFile(remappedAbs, extensionOrder)
|
|
if ok {
|
|
return PathPair{Primary: logger.Path{Text: absolute, Namespace: "file"}}, true, diffCase
|
|
}
|
|
|
|
// Is it a directory with an index?
|
|
if fieldDirInfo := r.dirInfoCached(remappedAbs); fieldDirInfo != nil {
|
|
if absolute, ok, _ := r.loadAsIndex(fieldDirInfo, remappedAbs, extensionOrder); ok {
|
|
return absolute, true, nil
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
return r.loadAsIndex(dirInfo, path, extensionOrder)
|
|
}
|
|
|
|
func getProperty(json js_ast.Expr, name string) (js_ast.Expr, logger.Loc, bool) {
|
|
if obj, ok := json.Data.(*js_ast.EObject); ok {
|
|
for _, prop := range obj.Properties {
|
|
if key, ok := prop.Key.Data.(*js_ast.EString); ok && key.Value != nil &&
|
|
len(key.Value) == len(name) && js_lexer.UTF16ToString(key.Value) == name {
|
|
return prop.ValueOrNil, prop.Key.Loc, true
|
|
}
|
|
}
|
|
}
|
|
return js_ast.Expr{}, logger.Loc{}, false
|
|
}
|
|
|
|
func getString(json js_ast.Expr) (string, bool) {
|
|
if value, ok := json.Data.(*js_ast.EString); ok {
|
|
return js_lexer.UTF16ToString(value.Value), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func getBool(json js_ast.Expr) (bool, bool) {
|
|
if value, ok := json.Data.(*js_ast.EBoolean); ok {
|
|
return value.Value, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func (r resolverQuery) loadAsFileOrDirectory(path string) (PathPair, bool, *fs.DifferentCase) {
|
|
// Use a special import order for CSS "@import" imports
|
|
extensionOrder := r.options.ExtensionOrder
|
|
if r.kind == ast.ImportAt || r.kind == ast.ImportAtConditional {
|
|
extensionOrder = r.atImportExtensionOrder
|
|
}
|
|
|
|
// Is this a file?
|
|
absolute, ok, diffCase := r.loadAsFile(path, extensionOrder)
|
|
if ok {
|
|
return PathPair{Primary: logger.Path{Text: absolute, Namespace: "file"}}, true, diffCase
|
|
}
|
|
|
|
// Is this a directory?
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Attempting to load %q as a directory", path))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
dirInfo := r.dirInfoCached(path)
|
|
if dirInfo == nil {
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
// Try using the main field(s) from "package.json"
|
|
if absolute, ok, diffCase := r.loadAsMainField(dirInfo, path, extensionOrder); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
|
|
// Look for an "index" file with known extensions
|
|
if absolute, ok, diffCase := r.loadAsIndexWithBrowserRemapping(dirInfo, path, extensionOrder); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
func (r resolverQuery) loadAsMainField(dirInfo *dirInfo, path string, extensionOrder []string) (PathPair, bool, *fs.DifferentCase) {
|
|
if dirInfo.packageJSON == nil {
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
mainFieldValues := dirInfo.packageJSON.mainFields
|
|
mainFieldKeys := r.options.MainFields
|
|
autoMain := false
|
|
|
|
// If the user has not explicitly specified a "main" field order,
|
|
// use a default one determined by the current platform target
|
|
if mainFieldKeys == nil {
|
|
mainFieldKeys = defaultMainFields[r.options.Platform]
|
|
autoMain = true
|
|
}
|
|
|
|
loadMainField := func(fieldRelPath string, field string) (PathPair, bool, *fs.DifferentCase) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found main field %q with path %q", field, fieldRelPath))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
// Potentially remap using the "browser" field
|
|
fieldAbsPath := r.fs.Join(path, fieldRelPath)
|
|
if remapped, ok := r.checkBrowserMap(dirInfo, fieldAbsPath, absolutePathKind); ok {
|
|
if remapped == nil {
|
|
return PathPair{Primary: logger.Path{Text: fieldAbsPath, Namespace: "file", Flags: logger.PathDisabled}}, true, nil
|
|
}
|
|
fieldAbsPath = r.fs.Join(path, *remapped)
|
|
}
|
|
|
|
// Is this a file?
|
|
absolute, ok, diffCase := r.loadAsFile(fieldAbsPath, extensionOrder)
|
|
if ok {
|
|
return PathPair{Primary: logger.Path{Text: absolute, Namespace: "file"}}, true, diffCase
|
|
}
|
|
|
|
// Is it a directory with an index?
|
|
if fieldDirInfo := r.dirInfoCached(fieldAbsPath); fieldDirInfo != nil {
|
|
if absolute, ok, _ := r.loadAsIndexWithBrowserRemapping(fieldDirInfo, fieldAbsPath, extensionOrder); ok {
|
|
return absolute, true, nil
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Searching for main fields in %q", dirInfo.packageJSON.source.KeyPath.Text))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
foundSomething := false
|
|
|
|
for _, key := range mainFieldKeys {
|
|
value, ok := mainFieldValues[key]
|
|
if !ok {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Did not find main field %q", key))
|
|
}
|
|
continue
|
|
}
|
|
foundSomething = true
|
|
|
|
absolute, ok, diffCase := loadMainField(value.relPath, key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// If the user did not manually configure a "main" field order, then
|
|
// use a special per-module automatic algorithm to decide whether to
|
|
// use "module" or "main" based on whether the package is imported
|
|
// using "import" or "require".
|
|
if autoMain && key == "module" {
|
|
var absoluteMain PathPair
|
|
var okMain bool
|
|
var diffCaseMain *fs.DifferentCase
|
|
|
|
if main, ok := mainFieldValues["main"]; ok {
|
|
if absolute, ok, diffCase := loadMainField(main.relPath, "main"); ok {
|
|
absoluteMain = absolute
|
|
okMain = true
|
|
diffCaseMain = diffCase
|
|
}
|
|
} else {
|
|
// Some packages have a "module" field without a "main" field but
|
|
// still have an implicit "index.js" file. In that case, treat that
|
|
// as the value for "main".
|
|
if absolute, ok, diffCase := r.loadAsIndexWithBrowserRemapping(dirInfo, path, extensionOrder); ok {
|
|
absoluteMain = absolute
|
|
okMain = true
|
|
diffCaseMain = diffCase
|
|
}
|
|
}
|
|
|
|
if okMain {
|
|
// If both the "main" and "module" fields exist, use "main" if the
|
|
// path is for "require" and "module" if the path is for "import".
|
|
// If we're using "module", return enough information to be able to
|
|
// fall back to "main" later if something ended up using "require()"
|
|
// with this same path. The goal of this code is to avoid having
|
|
// both the "module" file and the "main" file in the bundle at the
|
|
// same time.
|
|
if r.kind != ast.ImportRequire {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved to %q using the \"module\" field in %q",
|
|
absolute.Primary.Text, dirInfo.packageJSON.source.KeyPath.Text))
|
|
r.debugLogs.addNote(fmt.Sprintf("The fallback path in case of \"require\" is %q",
|
|
absoluteMain.Primary.Text))
|
|
}
|
|
return PathPair{
|
|
// This is the whole point of the path pair
|
|
Primary: absolute.Primary,
|
|
Secondary: absoluteMain.Primary,
|
|
}, true, diffCase
|
|
} else {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved to %q because of \"require\"", absoluteMain.Primary.Text))
|
|
}
|
|
return absoluteMain, true, diffCaseMain
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved to %q using the %q field in %q",
|
|
absolute.Primary.Text, key, dirInfo.packageJSON.source.KeyPath.Text))
|
|
}
|
|
return absolute, true, diffCase
|
|
}
|
|
|
|
// Let the user know if "main" exists but was skipped due to mis-configuration
|
|
if !foundSomething {
|
|
for _, field := range mainFieldsForFailure {
|
|
if main, ok := mainFieldValues[field]; ok {
|
|
tracker := logger.MakeLineColumnTracker(&dirInfo.packageJSON.source)
|
|
keyRange := dirInfo.packageJSON.source.RangeOfString(main.keyLoc)
|
|
if len(mainFieldKeys) == 0 && r.options.Platform == config.PlatformNeutral {
|
|
r.debugMeta.notes = append(r.debugMeta.notes, tracker.MsgData(keyRange,
|
|
fmt.Sprintf("The %q field here was ignored. Main fields must be configured explicitly when using the \"neutral\" platform.",
|
|
field)))
|
|
} else {
|
|
quoted := make([]string, len(mainFieldKeys))
|
|
for i, key := range mainFieldKeys {
|
|
quoted[i] = fmt.Sprintf("%q", key)
|
|
}
|
|
r.debugMeta.notes = append(r.debugMeta.notes, tracker.MsgData(keyRange,
|
|
fmt.Sprintf("The %q field here was ignored because the list of main fields to use is currently set to [%s].",
|
|
field, strings.Join(quoted, ", "))))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
// This closely follows the behavior of "tryLoadModuleUsingPaths()" in the
|
|
// official TypeScript compiler
|
|
func (r resolverQuery) matchTSConfigPaths(tsConfigJSON *TSConfigJSON, path string) (PathPair, bool, *fs.DifferentCase) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Matching %q against \"paths\" in %q", path, tsConfigJSON.AbsPath))
|
|
}
|
|
|
|
absBaseURL := tsConfigJSON.BaseURLForPaths
|
|
|
|
// The explicit base URL should take precedence over the implicit base URL
|
|
// if present. This matters when a tsconfig.json file overrides "baseUrl"
|
|
// from another extended tsconfig.json file but doesn't override "paths".
|
|
if tsConfigJSON.BaseURL != nil {
|
|
absBaseURL = *tsConfigJSON.BaseURL
|
|
}
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseURL\"", absBaseURL))
|
|
}
|
|
|
|
// Check for exact matches first
|
|
for key, originalPaths := range tsConfigJSON.Paths {
|
|
if key == path {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found an exact match for %q in \"paths\"", key))
|
|
}
|
|
for _, originalPath := range originalPaths {
|
|
// Load the original path relative to the "baseUrl" from tsconfig.json
|
|
absoluteOriginalPath := originalPath
|
|
if !r.fs.IsAbs(originalPath) {
|
|
absoluteOriginalPath = r.fs.Join(absBaseURL, originalPath)
|
|
}
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absoluteOriginalPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
return PathPair{}, false, nil
|
|
}
|
|
}
|
|
|
|
type match struct {
|
|
prefix string
|
|
suffix string
|
|
originalPaths []string
|
|
}
|
|
|
|
// Check for pattern matches next
|
|
longestMatchPrefixLength := -1
|
|
longestMatchSuffixLength := -1
|
|
var longestMatch match
|
|
for key, originalPaths := range tsConfigJSON.Paths {
|
|
if starIndex := strings.IndexByte(key, '*'); starIndex != -1 {
|
|
prefix, suffix := key[:starIndex], key[starIndex+1:]
|
|
|
|
// Find the match with the longest prefix. If two matches have the same
|
|
// prefix length, pick the one with the longest suffix. This second edge
|
|
// case isn't handled by the TypeScript compiler, but we handle it
|
|
// because we want the output to always be deterministic and Go map
|
|
// iteration order is deliberately non-deterministic.
|
|
if strings.HasPrefix(path, prefix) && strings.HasSuffix(path, suffix) && (len(prefix) > longestMatchPrefixLength ||
|
|
(len(prefix) == longestMatchPrefixLength && len(suffix) > longestMatchSuffixLength)) {
|
|
longestMatchPrefixLength = len(prefix)
|
|
longestMatchSuffixLength = len(suffix)
|
|
longestMatch = match{
|
|
prefix: prefix,
|
|
suffix: suffix,
|
|
originalPaths: originalPaths,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there is at least one match, only consider the one with the longest
|
|
// prefix. This matches the behavior of the TypeScript compiler.
|
|
if longestMatchPrefixLength != -1 {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Found a fuzzy match for %q in \"paths\"", longestMatch.prefix+"*"+longestMatch.suffix))
|
|
}
|
|
|
|
for _, originalPath := range longestMatch.originalPaths {
|
|
// Swap out the "*" in the original path for whatever the "*" matched
|
|
matchedText := path[len(longestMatch.prefix) : len(path)-len(longestMatch.suffix)]
|
|
originalPath = strings.Replace(originalPath, "*", matchedText, 1)
|
|
|
|
// Load the original path relative to the "baseUrl" from tsconfig.json
|
|
absoluteOriginalPath := originalPath
|
|
if !r.fs.IsAbs(originalPath) {
|
|
absoluteOriginalPath = r.fs.Join(absBaseURL, originalPath)
|
|
}
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absoluteOriginalPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo, forbidImports bool) (PathPair, bool, *fs.DifferentCase) {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Searching for %q in \"node_modules\" directories starting from %q", importPath, dirInfo.absPath))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
// First, check path overrides from the nearest enclosing TypeScript "tsconfig.json" file
|
|
if dirInfo.enclosingTSConfigJSON != nil {
|
|
// Try path substitutions first
|
|
if dirInfo.enclosingTSConfigJSON.Paths != nil {
|
|
if absolute, ok, diffCase := r.matchTSConfigPaths(dirInfo.enclosingTSConfigJSON, importPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
|
|
// Try looking up the path relative to the base URL
|
|
if dirInfo.enclosingTSConfigJSON.BaseURL != nil {
|
|
basePath := r.fs.Join(*dirInfo.enclosingTSConfigJSON.BaseURL, importPath)
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(basePath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find the parent directory with the "package.json" file
|
|
dirInfoPackageJSON := dirInfo
|
|
for dirInfoPackageJSON != nil && dirInfoPackageJSON.packageJSON == nil {
|
|
dirInfoPackageJSON = dirInfoPackageJSON.parent
|
|
}
|
|
|
|
// Then check for the package in any enclosing "node_modules" directories
|
|
if dirInfoPackageJSON != nil && strings.HasPrefix(importPath, "#") && !forbidImports && dirInfoPackageJSON.packageJSON.importsMap != nil {
|
|
packageJSON := dirInfoPackageJSON.packageJSON
|
|
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Looking for %q in \"imports\" map in %q", importPath, packageJSON.source.KeyPath.Text))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
// Filter out invalid module specifiers now where we have more information for
|
|
// a better error message instead of later when we're inside the algorithm
|
|
if importPath == "#" || strings.HasPrefix(importPath, "#/") {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The path %q must not equal \"#\" and must not start with \"#/\".", importPath))
|
|
}
|
|
tracker := logger.MakeLineColumnTracker(&packageJSON.source)
|
|
r.debugMeta.notes = append(r.debugMeta.notes, tracker.MsgData(packageJSON.importsMap.root.firstToken,
|
|
fmt.Sprintf("This \"imports\" map was ignored because the module specifier %q is invalid:", importPath)))
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
// The condition set is determined by the kind of import
|
|
conditions := r.esmConditionsDefault
|
|
switch r.kind {
|
|
case ast.ImportStmt, ast.ImportDynamic:
|
|
conditions = r.esmConditionsImport
|
|
case ast.ImportRequire, ast.ImportRequireResolve:
|
|
conditions = r.esmConditionsRequire
|
|
}
|
|
|
|
resolvedPath, status, debug := r.esmPackageImportsResolve(importPath, packageJSON.importsMap.root, conditions)
|
|
resolvedPath, status, debug = r.esmHandlePostConditions(resolvedPath, status, debug)
|
|
|
|
if status == pjStatusPackageResolve {
|
|
// The import path was remapped via "imports" to another import path
|
|
// that now needs to be resolved too. Set "forbidImports" to true
|
|
// so we don't try to resolve "imports" again and end up in a loop.
|
|
absolute, ok, diffCase := r.loadNodeModules(resolvedPath, dirInfoPackageJSON, true /* forbidImports */)
|
|
if !ok {
|
|
tracker := logger.MakeLineColumnTracker(&packageJSON.source)
|
|
r.debugMeta.notes = append(
|
|
[]logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("The remapped path %q could not be resolved:", resolvedPath))},
|
|
r.debugMeta.notes...)
|
|
}
|
|
return absolute, ok, diffCase
|
|
}
|
|
|
|
return r.finalizeImportsExportsResult(
|
|
dirInfoPackageJSON.absPath, conditions, *packageJSON.importsMap, packageJSON,
|
|
resolvedPath, status, debug,
|
|
"", "", "",
|
|
)
|
|
}
|
|
|
|
esmPackageName, esmPackageSubpath, esmOK := esmParsePackageName(importPath)
|
|
if r.debugLogs != nil && esmOK {
|
|
r.debugLogs.addNote(fmt.Sprintf("Parsed package name %q and package subpath %q", esmPackageName, esmPackageSubpath))
|
|
}
|
|
|
|
// Then check for the package in any enclosing "node_modules" directories
|
|
for {
|
|
// Skip directories that are themselves called "node_modules", since we
|
|
// don't ever want to search for "node_modules/node_modules"
|
|
if dirInfo.hasNodeModules {
|
|
absPath := r.fs.Join(dirInfo.absPath, "node_modules", importPath)
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Checking for a package in the directory %q", absPath))
|
|
}
|
|
|
|
// Check the package's package.json file
|
|
if esmOK {
|
|
absPkgPath := r.fs.Join(dirInfo.absPath, "node_modules", esmPackageName)
|
|
if pkgDirInfo := r.dirInfoCached(absPkgPath); pkgDirInfo != nil {
|
|
// Check the "exports" map
|
|
if packageJSON := pkgDirInfo.packageJSON; packageJSON != nil && packageJSON.exportsMap != nil {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Looking for %q in \"exports\" map in %q", esmPackageSubpath, packageJSON.source.KeyPath.Text))
|
|
r.debugLogs.increaseIndent()
|
|
defer r.debugLogs.decreaseIndent()
|
|
}
|
|
|
|
// The condition set is determined by the kind of import
|
|
conditions := r.esmConditionsDefault
|
|
switch r.kind {
|
|
case ast.ImportStmt, ast.ImportDynamic:
|
|
conditions = r.esmConditionsImport
|
|
case ast.ImportRequire, ast.ImportRequireResolve:
|
|
conditions = r.esmConditionsRequire
|
|
}
|
|
|
|
// Resolve against the path "/", then join it with the absolute
|
|
// directory path. This is done because ESM package resolution uses
|
|
// URLs while our path resolution uses file system paths. We don't
|
|
// want problems due to Windows paths, which are very unlike URL
|
|
// paths. We also want to avoid any "%" characters in the absolute
|
|
// directory path accidentally being interpreted as URL escapes.
|
|
resolvedPath, status, debug := r.esmPackageExportsResolve("/", esmPackageSubpath, packageJSON.exportsMap.root, conditions)
|
|
resolvedPath, status, debug = r.esmHandlePostConditions(resolvedPath, status, debug)
|
|
|
|
return r.finalizeImportsExportsResult(
|
|
absPkgPath, conditions, *packageJSON.exportsMap, packageJSON,
|
|
resolvedPath, status, debug,
|
|
esmPackageName, esmPackageSubpath, absPath,
|
|
)
|
|
}
|
|
|
|
// Check the "browser" map
|
|
if remapped, ok := r.checkBrowserMap(pkgDirInfo, absPath, absolutePathKind); ok {
|
|
if remapped == nil {
|
|
return PathPair{Primary: logger.Path{Text: absPath, Namespace: "file", Flags: logger.PathDisabled}}, true, nil
|
|
}
|
|
if remappedResult, ok, diffCase := r.resolveWithoutRemapping(pkgDirInfo.enclosingBrowserScope, *remapped); ok {
|
|
return remappedResult, true, diffCase
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
|
|
// Go to the parent directory, stopping at the file system root
|
|
dirInfo = dirInfo.parent
|
|
if dirInfo == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Then check the global "NODE_PATH" environment variable.
|
|
//
|
|
// Note: This is a deviation from node's published module resolution
|
|
// algorithm. The published algorithm says "NODE_PATH" must take precedence
|
|
// over "node_modules" paths, but it appears that the published algorithm is
|
|
// incorrect. We follow node's actual behavior instead of following the
|
|
// published algorithm. See also: https://github.com/nodejs/node/issues/38128.
|
|
for _, absDir := range r.options.AbsNodePaths {
|
|
absPath := r.fs.Join(absDir, importPath)
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
func (r resolverQuery) finalizeImportsExportsResult(
|
|
absDirPath string,
|
|
conditions map[string]bool,
|
|
importExportMap pjMap,
|
|
packageJSON *packageJSON,
|
|
|
|
// Resolution results
|
|
resolvedPath string,
|
|
status pjStatus,
|
|
debug pjDebug,
|
|
|
|
// Only for exports
|
|
esmPackageName string,
|
|
esmPackageSubpath string,
|
|
absImportPath string,
|
|
) (PathPair, bool, *fs.DifferentCase) {
|
|
if (status == pjStatusExact || status == pjStatusInexact) && strings.HasPrefix(resolvedPath, "/") {
|
|
absResolvedPath := r.fs.Join(absDirPath, resolvedPath[1:])
|
|
|
|
switch status {
|
|
case pjStatusExact:
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is exact", absResolvedPath))
|
|
}
|
|
resolvedDirInfo := r.dirInfoCached(r.fs.Dir(absResolvedPath))
|
|
if resolvedDirInfo == nil {
|
|
status = pjStatusModuleNotFound
|
|
} else if entry, diffCase := resolvedDirInfo.entries.Get(r.fs.Base(absResolvedPath)); entry == nil {
|
|
status = pjStatusModuleNotFound
|
|
} else if kind := entry.Kind(r.fs); kind == fs.DirEntry {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The path %q is a directory, which is not allowed", absResolvedPath))
|
|
}
|
|
status = pjStatusUnsupportedDirectoryImport
|
|
} else if kind != fs.FileEntry {
|
|
status = pjStatusModuleNotFound
|
|
} else {
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("Resolved to %q", absResolvedPath))
|
|
}
|
|
return PathPair{Primary: logger.Path{Text: absResolvedPath, Namespace: "file"}}, true, diffCase
|
|
}
|
|
|
|
case pjStatusInexact:
|
|
// If this was resolved against an expansion key ending in a "/"
|
|
// instead of a "*", we need to try CommonJS-style implicit
|
|
// extension and/or directory detection.
|
|
if r.debugLogs != nil {
|
|
r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is inexact", absResolvedPath))
|
|
}
|
|
if absolute, ok, diffCase := r.loadAsFileOrDirectory(absResolvedPath); ok {
|
|
return absolute, true, diffCase
|
|
}
|
|
status = pjStatusModuleNotFound
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(resolvedPath, "/") {
|
|
resolvedPath = "." + resolvedPath
|
|
}
|
|
|
|
// Provide additional details about the failure to help with debugging
|
|
tracker := logger.MakeLineColumnTracker(&packageJSON.source)
|
|
switch status {
|
|
case pjStatusInvalidModuleSpecifier:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("The module specifier %q is invalid:", resolvedPath))}
|
|
|
|
case pjStatusInvalidPackageConfiguration:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
"The package configuration has an invalid value here:")}
|
|
|
|
case pjStatusInvalidPackageTarget:
|
|
why := fmt.Sprintf("The package target %q is invalid:", resolvedPath)
|
|
if resolvedPath == "" {
|
|
// "PACKAGE_TARGET_RESOLVE" is specified to throw an "Invalid
|
|
// Package Target" error for what is actually an invalid package
|
|
// configuration error
|
|
why = "The package configuration has an invalid value here:"
|
|
}
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token, why)}
|
|
|
|
case pjStatusPackagePathNotExported:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("The path %q is not exported by package %q:", esmPackageSubpath, esmPackageName))}
|
|
|
|
// If this fails, try to resolve it using the old algorithm
|
|
if absolute, ok, _ := r.loadAsFileOrDirectory(absImportPath); ok && absolute.Primary.Namespace == "file" {
|
|
if relPath, ok := r.fs.Rel(absDirPath, absolute.Primary.Text); ok {
|
|
query := "." + path.Join("/", strings.ReplaceAll(relPath, "\\", "/"))
|
|
|
|
// If that succeeds, try to do a reverse lookup using the
|
|
// "exports" map for the currently-active set of conditions
|
|
if ok, subpath, token := r.esmPackageExportsReverseResolve(
|
|
query, importExportMap.root, conditions); ok {
|
|
r.debugMeta.notes = append(r.debugMeta.notes, tracker.MsgData(token,
|
|
fmt.Sprintf("The file %q is exported at path %q:", query, subpath)))
|
|
|
|
// Provide an inline suggestion message with the correct import path
|
|
actualImportPath := path.Join(esmPackageName, subpath)
|
|
r.debugMeta.suggestionText = string(js_printer.QuoteForJSON(actualImportPath, false))
|
|
r.debugMeta.suggestionMessage = fmt.Sprintf("Import from %q to get the file %q:",
|
|
actualImportPath, r.PrettyPath(absolute.Primary))
|
|
}
|
|
}
|
|
}
|
|
|
|
case pjStatusPackageImportNotDefined:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("The package import %q is not defined in this \"imports\" map:", resolvedPath))}
|
|
|
|
case pjStatusModuleNotFound:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("The module %q was not found on the file system:", resolvedPath))}
|
|
|
|
case pjStatusUnsupportedDirectoryImport:
|
|
r.debugMeta.notes = []logger.MsgData{tracker.MsgData(debug.token,
|
|
fmt.Sprintf("Importing the directory %q is not supported:", resolvedPath))}
|
|
|
|
case pjStatusUndefinedNoConditionsMatch:
|
|
prettyPrintConditions := func(conditions []string) string {
|
|
quoted := make([]string, len(conditions))
|
|
for i, condition := range conditions {
|
|
quoted[i] = fmt.Sprintf("%q", condition)
|
|
}
|
|
return strings.Join(quoted, ", ")
|
|
}
|
|
keys := make([]string, 0, len(conditions))
|
|
for key := range conditions {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
r.debugMeta.notes = []logger.MsgData{
|
|
tracker.MsgData(importExportMap.root.firstToken,
|
|
fmt.Sprintf("The path %q is not currently exported by package %q:",
|
|
esmPackageSubpath, esmPackageName)),
|
|
tracker.MsgData(debug.token,
|
|
fmt.Sprintf("None of the conditions provided (%s) match any of the currently active conditions (%s):",
|
|
prettyPrintConditions(debug.unmatchedConditions),
|
|
prettyPrintConditions(keys),
|
|
))}
|
|
for _, key := range debug.unmatchedConditions {
|
|
if key == "import" && (r.kind == ast.ImportRequire || r.kind == ast.ImportRequireResolve) {
|
|
r.debugMeta.suggestionMessage = "Consider using an \"import\" statement to import this file:"
|
|
} else if key == "require" && (r.kind == ast.ImportStmt || r.kind == ast.ImportDynamic) {
|
|
r.debugMeta.suggestionMessage = "Consider using a \"require()\" call to import this file:"
|
|
}
|
|
}
|
|
}
|
|
|
|
return PathPair{}, false, nil
|
|
}
|
|
|
|
// Package paths are loaded from a "node_modules" directory. Non-package paths
|
|
// are relative or absolute paths.
|
|
func IsPackagePath(path string) bool {
|
|
return !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "./") &&
|
|
!strings.HasPrefix(path, "../") && path != "." && path != ".."
|
|
}
|
|
|
|
// This list can be obtained with the following command:
|
|
//
|
|
// node --experimental-wasi-unstable-preview1 -p "[...require('module').builtinModules].join('\n')"
|
|
//
|
|
// Be sure to use the *LATEST* version of node when updating this list!
|
|
var BuiltInNodeModules = map[string]bool{
|
|
"_http_agent": true,
|
|
"_http_client": true,
|
|
"_http_common": true,
|
|
"_http_incoming": true,
|
|
"_http_outgoing": true,
|
|
"_http_server": true,
|
|
"_stream_duplex": true,
|
|
"_stream_passthrough": true,
|
|
"_stream_readable": true,
|
|
"_stream_transform": true,
|
|
"_stream_wrap": true,
|
|
"_stream_writable": true,
|
|
"_tls_common": true,
|
|
"_tls_wrap": true,
|
|
"assert": true,
|
|
"assert/strict": true,
|
|
"async_hooks": true,
|
|
"buffer": true,
|
|
"child_process": true,
|
|
"cluster": true,
|
|
"console": true,
|
|
"constants": true,
|
|
"crypto": true,
|
|
"dgram": true,
|
|
"diagnostics_channel": true,
|
|
"dns": true,
|
|
"dns/promises": true,
|
|
"domain": true,
|
|
"events": true,
|
|
"fs": true,
|
|
"fs/promises": true,
|
|
"http": true,
|
|
"http2": true,
|
|
"https": true,
|
|
"inspector": true,
|
|
"module": true,
|
|
"net": true,
|
|
"os": true,
|
|
"path": true,
|
|
"path/posix": true,
|
|
"path/win32": true,
|
|
"perf_hooks": true,
|
|
"process": true,
|
|
"punycode": true,
|
|
"querystring": true,
|
|
"readline": true,
|
|
"repl": true,
|
|
"stream": true,
|
|
"stream/consumers": true,
|
|
"stream/promises": true,
|
|
"stream/web": true,
|
|
"string_decoder": true,
|
|
"sys": true,
|
|
"timers": true,
|
|
"timers/promises": true,
|
|
"tls": true,
|
|
"trace_events": true,
|
|
"tty": true,
|
|
"url": true,
|
|
"util": true,
|
|
"util/types": true,
|
|
"v8": true,
|
|
"vm": true,
|
|
"wasi": true,
|
|
"worker_threads": true,
|
|
"zlib": true,
|
|
}
|