From 55f6415a46189d4f4b49265a309f407bbc79ec29 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 26 Aug 2022 11:33:50 -0700 Subject: [PATCH] implement glob-style path resolution --- CHANGELOG.md | 26 ++ internal/ast/ast.go | 13 +- internal/bundler/bundler.go | 256 ++++++++++-- internal/bundler/bundler_default_test.go | 12 +- internal/bundler/bundler_glob_test.go | 138 +++++++ internal/bundler/linker.go | 3 +- .../bundler/snapshots/snapshots_default.txt | 75 +++- internal/bundler/snapshots/snapshots_glob.txt | 201 ++++++++++ .../bundler/snapshots/snapshots_splitting.txt | 26 +- internal/cache/cache.go | 15 + internal/graph/input.go | 2 + internal/helpers/glob.go | 54 +++ internal/js_parser/js_parser.go | 369 ++++++++++++++++-- internal/logger/msg_ids.go | 7 +- internal/resolver/resolver.go | 368 ++++++++++++----- internal/runtime/runtime.go | 7 + pkg/api/api_impl.go | 9 +- scripts/js-api-tests.js | 22 +- scripts/plugin-tests.js | 4 +- 19 files changed, 1384 insertions(+), 223 deletions(-) create mode 100644 internal/bundler/bundler_glob_test.go create mode 100644 internal/bundler/snapshots/snapshots_glob.txt create mode 100644 internal/helpers/glob.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee3ee83501..8b4b253e12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,32 @@ } ``` +* Handle import paths containing wildcards + + This release introduces wildcards in import paths in two places: + + * **Entry points** + + You can now pass a string containing glob-style wildcards such as `./src/*.ts` as an entry point and esbuild will search the file system for files that match the pattern. This can be used to easily pass esbuild all files with a certain extension on the command line in a cross-platform way. Previously you had to rely on the shell to perform glob expansion, but that is obviously shell-dependent and didn't work at all on Windows. Note that to use this feature on the command line you will have to quote the pattern so it's passed verbatim to esbuild without any expansion by the shell. Here's an example: + + ```sh + esbuild --minify "./src/*.ts" --outdir=out + ``` + + Specifically the `*` character will match any character except for the `/` character, and the `/**/` character sequence will match a path separator followed by zero or more path elements. Other wildcard operators found in glob patterns such as `?` and `[...]` are not supported. + + * **Run-time import paths** + + Import paths that are evaluated at run-time can now be bundled in certain limited situations. The import path expression must be a form of string concatenation and must start with either `./` or `../`. Each non-string expression in the string concatenation chain becomes a wildcard. The `*` wildcard is chosen unless the previous character is a `/`, in which case the `/**/*` character sequence is used. + + ```js + // These two forms are equivalent + const json1 = await import('./data/' + kind + '.json') + const json2 = await import(`./data/${kind}.json`) + ``` + + This feature works with `require(...)`, `import(...)`, and `new URL(..., import.meta.url)` because these can all accept run-time expressions. It does not work with `import` and `export` statements because these cannot accept run-time expressions. + * Move all binary executable packages to the `@esbuild/` scope Binary package executables for esbuild are published as individual packages separate from the main `esbuild` package so you only have to download the relevant one for the current platform when you install esbuild. This release moves all of these packages under the `@esbuild/` scope to avoid collisions with 3rd-party packages. It also changes them to a consistent naming scheme that uses the `os` and `cpu` names from node. diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 9019f37158b..531b786fded 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -128,9 +128,10 @@ func (flags ImportRecordFlags) Has(flag ImportRecordFlags) bool { } type ImportRecord struct { - Assertions *[]AssertEntry - Path logger.Path - Range logger.Range + Assertions *[]AssertEntry + GlobPattern *GlobPattern + Path logger.Path + Range logger.Range // If the "HandlesImportErrors" flag is present, then this is the location // of the error handler. This is used for error reporting. @@ -165,6 +166,12 @@ func FindAssertion(assertions []AssertEntry, name string) *AssertEntry { return nil } +type GlobPattern struct { + Parts []helpers.GlobPart + ExportAlias string + Kind ImportKind +} + // This stores a 32-bit index where the zero value is an invalid index. This is // a better alternative to storing the index as a pointer since that has the // same properties but takes up more space and costs an extra pointer traversal. diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index ec51873d2b8..dca1a207379 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -92,10 +92,18 @@ type parseArgs struct { } type parseResult struct { - resolveResults []*resolver.ResolveResult - file scannerFile - tlaCheck tlaCheck - ok bool + resolveResults []*resolver.ResolveResult + globResolveResults map[uint32]globResolveResult + file scannerFile + tlaCheck tlaCheck + ok bool +} + +type globResolveResult struct { + resolveResults map[string]resolver.ResolveResult + absPath string + prettyPath string + exportAlias string } type tlaCheck struct { @@ -386,6 +394,36 @@ func parseFile(args parseArgs) { continue } + // Special-case glob pattern imports + if record.GlobPattern != nil { + prettyPath := helpers.GlobPatternToString(record.GlobPattern.Parts) + switch record.GlobPattern.Kind { + case ast.ImportRequire: + prettyPath = fmt.Sprintf("require(%q)", prettyPath) + case ast.ImportDynamic: + prettyPath = fmt.Sprintf("import(%q)", prettyPath) + case ast.ImportNewURL: + prettyPath = fmt.Sprintf("new URL(%q, import.meta.url)", prettyPath) + } + if results, msg := args.res.ResolveGlob(absResolveDir, record.GlobPattern.Parts, record.GlobPattern.Kind, prettyPath); results != nil { + if msg != nil { + args.log.AddID(msg.ID, msg.Kind, &tracker, record.Range, msg.Data.Text) + } + if result.globResolveResults == nil { + result.globResolveResults = make(map[uint32]globResolveResult) + } + result.globResolveResults[uint32(importRecordIndex)] = globResolveResult{ + resolveResults: results, + absPath: args.fs.Join(absResolveDir, "(glob)"), + prettyPath: fmt.Sprintf("%s in %s", prettyPath, result.file.inputFile.Source.PrettyPath), + exportAlias: record.GlobPattern.ExportAlias, + } + } else { + args.log.AddError(&tracker, record.Range, fmt.Sprintf("Could not resolve %s", prettyPath)) + } + continue + } + // Ignore records that the parser has discarded. This is used to remove // type-only imports in TypeScript files. if record.Flags.Has(ast.IsUnused) { @@ -1110,7 +1148,10 @@ func ScanBundle( file: scannerFile{ inputFile: graph.InputFile{ Source: source, - Repr: &graph.JSRepr{AST: ast}, + Repr: &graph.JSRepr{ + AST: ast, + }, + OmitFromSourceMapsAndMetafile: true, }, }, ok: ok, @@ -1183,7 +1224,6 @@ func (s *scanner) maybeParseFile( prettyPath string, importSource *logger.Source, importPathRange logger.Range, - pluginData interface{}, kind inputKind, inject chan config.InjectedFile, ) uint32 { @@ -1287,7 +1327,7 @@ func (s *scanner) maybeParseFile( importSource: importSource, sideEffects: sideEffects, importPathRange: importPathRange, - pluginData: pluginData, + pluginData: resolveResult.PluginData, options: optionsClone, results: s.resultChannel, inject: inject, @@ -1318,6 +1358,26 @@ func (s *scanner) allocateSourceIndex(path logger.Path, kind cache.SourceIndexKi return sourceIndex } +func (s *scanner) allocateGlobSourceIndex(parentSourceIndex uint32, globIndex uint32) uint32 { + // Allocate a source index using the shared source index cache so that + // subsequent builds reuse the same source index and therefore use the + // cached parse results for increased speed. + sourceIndex := s.caches.SourceIndexCache.GetGlob(parentSourceIndex, globIndex) + + // Grow the results array to fit this source index + if newLen := int(sourceIndex) + 1; len(s.results) < newLen { + // Reallocate to a bigger array + if cap(s.results) < newLen { + s.results = append(make([]parseResult, 0, 2*newLen), s.results...) + } + + // Grow in place + s.results = s.results[:newLen] + } + + return sourceIndex +} + func (s *scanner) preprocessInjectedFiles() { s.timer.Begin("Preprocess injected files") defer s.timer.End("Preprocess injected files") @@ -1390,7 +1450,7 @@ func (s *scanner) preprocessInjectedFiles() { } channel := make(chan config.InjectedFile) - s.maybeParseFile(*resolveResult, prettyPath, nil, logger.Range{}, nil, inputKindNormal, channel) + s.maybeParseFile(*resolveResult, prettyPath, nil, logger.Range{}, inputKindNormal, channel) // Wait for the results in parallel. The results slice is large enough so // it is not reallocated during the computations. @@ -1428,7 +1488,7 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { } } resolveResult := resolver.ResolveResult{PathPair: resolver.PathPair{Primary: stdinPath}} - sourceIndex := s.maybeParseFile(resolveResult, s.res.PrettyPath(stdinPath), nil, logger.Range{}, nil, inputKindStdin, nil) + sourceIndex := s.maybeParseFile(resolveResult, s.res.PrettyPath(stdinPath), nil, logger.Range{}, inputKindStdin, nil) entryMetas = append(entryMetas, graph.EntryPoint{ OutputPath: "stdin", SourceIndex: sourceIndex, @@ -1440,6 +1500,9 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { for i := range entryPoints { entryPoint := &entryPoints[i] absPath := entryPoint.InputPath + if strings.ContainsRune(absPath, '*') { + continue // Ignore glob patterns + } if !s.fs.IsAbs(absPath) { absPath = s.fs.Join(entryPointAbsResolveDir, absPath) } @@ -1473,7 +1536,11 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { // Add any remaining entry points. Run resolver plugins on these entry points // so plugins can alter where they resolve to. These are run in parallel in // case any of these plugins block. - entryPointResolveResults := make([]*resolver.ResolveResult, len(entryPoints)) + type entryPointInfo struct { + results []resolver.ResolveResult + isGlob bool + } + entryPointInfos := make([]entryPointInfo, len(entryPoints)) entryPointWaitGroup := sync.WaitGroup{} entryPointWaitGroup.Add(len(entryPoints)) for i, entryPoint := range entryPoints { @@ -1483,6 +1550,32 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { importer.Namespace = "file" } + // Special-case glob patterns here + if strings.ContainsRune(entryPoint.InputPath, '*') { + if pattern := helpers.ParseGlobPattern(entryPoint.InputPath); len(pattern) > 1 { + prettyPattern := fmt.Sprintf("%q", entryPoint.InputPath) + if results, msg := s.res.ResolveGlob(entryPointAbsResolveDir, pattern, ast.ImportEntryPoint, prettyPattern); results != nil { + keys := make([]string, 0, len(results)) + for key := range results { + keys = append(keys, key) + } + sort.Strings(keys) + info := entryPointInfo{isGlob: true} + for _, key := range keys { + info.results = append(info.results, results[key]) + } + entryPointInfos[i] = info + if msg != nil { + s.log.AddID(msg.ID, msg.Kind, nil, logger.Range{}, msg.Data.Text) + } + } else { + s.log.AddError(nil, logger.Range{}, fmt.Sprintf("Could not resolve %q", entryPoint.InputPath)) + } + entryPointWaitGroup.Done() + return + } + } + // Run the resolver and log an error if the path couldn't be resolved resolveResult, didLogError, debug := RunOnResolvePlugins( s.options.Plugins, @@ -1502,18 +1595,12 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { if resolveResult.IsExternal { s.log.AddError(nil, logger.Range{}, fmt.Sprintf("The entry point %q cannot be marked as external", entryPoint.InputPath)) } else { - entryPointResolveResults[i] = resolveResult + entryPointInfos[i] = entryPointInfo{results: []resolver.ResolveResult{*resolveResult}} } } else if !didLogError { var notes []logger.MsgData if !s.fs.IsAbs(entryPoint.InputPath) { - if strings.ContainsRune(entryPoint.InputPath, '*') { - notes = append(notes, logger.MsgData{ - Text: "It looks like you are trying to use glob syntax (i.e. \"*\") with esbuild. " + - "This syntax is typically handled by your shell, and isn't handled by esbuild itself. " + - "You must expand glob syntax first before passing your paths to esbuild.", - }) - } else if query := s.res.ProbeResolvePackageAsRelative(entryPointAbsResolveDir, entryPoint.InputPath, ast.ImportEntryPoint); query != nil { + if query := s.res.ProbeResolvePackageAsRelative(entryPointAbsResolveDir, entryPoint.InputPath, ast.ImportEntryPoint); query != nil { notes = append(notes, logger.MsgData{ Text: fmt.Sprintf("Use the relative path %q to reference the file %q. "+ "Without the leading \"./\", the path %q is being interpreted as a package path instead.", @@ -1529,16 +1616,24 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { entryPointWaitGroup.Wait() // Parse all entry points that were resolved successfully - for i, resolveResult := range entryPointResolveResults { - if resolveResult != nil { + for i, info := range entryPointInfos { + if info.results == nil { + continue + } + + for _, resolveResult := range info.results { prettyPath := s.res.PrettyPath(resolveResult.PathPair.Primary) - sourceIndex := s.maybeParseFile(*resolveResult, prettyPath, nil, logger.Range{}, resolveResult.PluginData, inputKindEntryPoint, nil) + sourceIndex := s.maybeParseFile(resolveResult, prettyPath, nil, logger.Range{}, inputKindEntryPoint, nil) outputPath := entryPoints[i].OutputPath outputPathWasAutoGenerated := false // If the output path is missing, automatically generate one from the input path if outputPath == "" { - outputPath = entryPoints[i].InputPath + if info.isGlob { + outputPath = prettyPath + } else { + outputPath = entryPoints[i].InputPath + } windowsVolumeLabel := "" // The ":" character is invalid in file paths on Windows except when @@ -1695,6 +1790,12 @@ func (s *scanner) scanAllDependencies() { // Skip this import record if the previous resolver call failed resolveResult := result.resolveResults[importRecordIndex] if resolveResult == nil { + if globResults := result.globResolveResults[uint32(importRecordIndex)]; globResults.resolveResults != nil { + sourceIndex := s.allocateGlobSourceIndex(result.file.inputFile.Source.Index, uint32(importRecordIndex)) + record.SourceIndex = ast.MakeIndex32(sourceIndex) + s.results[sourceIndex] = s.generateResultForGlobResolve(sourceIndex, globResults.absPath, + &result.file.inputFile.Source, record.Range, record.GlobPattern.Kind, globResults, record.Assertions) + } continue } @@ -1702,7 +1803,7 @@ func (s *scanner) scanAllDependencies() { if !resolveResult.IsExternal { // Handle a path within the bundle sourceIndex := s.maybeParseFile(*resolveResult, s.res.PrettyPath(path), - &result.file.inputFile.Source, record.Range, resolveResult.PluginData, inputKindNormal, nil) + &result.file.inputFile.Source, record.Range, inputKindNormal, nil) record.SourceIndex = ast.MakeIndex32(sourceIndex) } else { // Allow this import statement to be removed if something marked it as "sideEffects: false" @@ -1734,6 +1835,113 @@ func (s *scanner) scanAllDependencies() { } } +func (s *scanner) generateResultForGlobResolve( + sourceIndex uint32, + fakeSourcePath string, + importSource *logger.Source, + importRange logger.Range, + kind ast.ImportKind, + result globResolveResult, + assertions *[]ast.AssertEntry, +) parseResult { + keys := make([]string, 0, len(result.resolveResults)) + for key := range result.resolveResults { + keys = append(keys, key) + } + sort.Strings(keys) + + object := js_ast.EObject{Properties: make([]js_ast.Property, 0, len(result.resolveResults))} + importRecords := make([]ast.ImportRecord, 0, len(result.resolveResults)) + resolveResults := make([]*resolver.ResolveResult, 0, len(result.resolveResults)) + + for _, key := range keys { + resolveResult := result.resolveResults[key] + var value js_ast.Expr + + importRecordIndex := uint32(len(importRecords)) + var sourceIndex ast.Index32 + + if !resolveResult.IsExternal { + sourceIndex = ast.MakeIndex32(s.maybeParseFile( + resolveResult, + s.res.PrettyPath(resolveResult.PathPair.Primary), + importSource, + importRange, + inputKindNormal, + nil, + )) + } + + path := resolveResult.PathPair.Primary + + // If the path to the external module is relative to the source + // file, rewrite the path to be relative to the working directory + if path.Namespace == "file" { + if relPath, ok := s.fs.Rel(s.options.AbsOutputDir, path.Text); ok { + // Prevent issues with path separators being different on Windows + relPath = strings.ReplaceAll(relPath, "\\", "/") + if resolver.IsPackagePath(relPath) { + relPath = "./" + relPath + } + path.Text = relPath + } + } + + resolveResults = append(resolveResults, &resolveResult) + importRecords = append(importRecords, ast.ImportRecord{ + Path: path, + SourceIndex: sourceIndex, + Assertions: assertions, + Kind: kind, + }) + + switch kind { + case ast.ImportDynamic: + value.Data = &js_ast.EImportString{ImportRecordIndex: importRecordIndex} + case ast.ImportRequire: + value.Data = &js_ast.ERequireString{ImportRecordIndex: importRecordIndex} + case ast.ImportNewURL: + value.Data = &js_ast.ENewURLImportMeta{ImportRecordIndex: importRecordIndex} + default: + panic("Internal error") + } + + object.Properties = append(object.Properties, js_ast.Property{ + Key: js_ast.Expr{Data: &js_ast.EString{Value: helpers.StringToUTF16(key)}}, + ValueOrNil: js_ast.Expr{Data: &js_ast.EArrow{ + Body: js_ast.FnBody{Block: js_ast.SBlock{Stmts: []js_ast.Stmt{{Data: &js_ast.SReturn{ValueOrNil: value}}}}}, + PreferExpr: true, + }}, + }) + } + + source := logger.Source{ + KeyPath: logger.Path{Text: fakeSourcePath, Namespace: "file"}, + PrettyPath: result.prettyPath, + Index: sourceIndex, + } + ast := js_parser.GlobResolveAST(s.log, source, importRecords, &object, result.exportAlias) + + // Fill out "nil" for any additional imports (i.e. from the runtime) + for len(resolveResults) < len(ast.ImportRecords) { + resolveResults = append(resolveResults, nil) + } + + return parseResult{ + resolveResults: resolveResults, + file: scannerFile{ + inputFile: graph.InputFile{ + Source: source, + Repr: &graph.JSRepr{ + AST: ast, + }, + OmitFromSourceMapsAndMetafile: true, + }, + }, + ok: true, + } +} + func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scannerFile { s.timer.Begin("Process scanned files") defer s.timer.End("Process scanned files") @@ -2447,7 +2655,7 @@ func (b *Bundle) generateMetadataJSON(results []graph.OutputFile, allReachableFi // Write inputs isFirst := true for _, sourceIndex := range allReachableFiles { - if sourceIndex == runtime.SourceIndex { + if b.files[sourceIndex].inputFile.OmitFromSourceMapsAndMetafile { continue } if file := &b.files[sourceIndex]; len(file.jsonMetadataChunk) > 0 { diff --git a/internal/bundler/bundler_default_test.go b/internal/bundler/bundler_default_test.go index 51c9d60a971..1113d470a20 100644 --- a/internal/bundler/bundler_default_test.go +++ b/internal/bundler/bundler_default_test.go @@ -5724,15 +5724,15 @@ func TestEntryNamesNoSlashAfterDir(t *testing.T) { func TestEntryNamesNonPortableCharacter(t *testing.T) { default_suite.expectBundled(t, bundled{ files: map[string]string{ - "/entry1-*.ts": `console.log(1)`, - "/entry2-*.ts": `console.log(2)`, + "/entry1-:.ts": `console.log(1)`, + "/entry2-:.ts": `console.log(2)`, }, entryPathsAdvanced: []EntryPoint{ - // The "*" should turn into "_" for cross-platform Windows portability - {InputPath: "/entry1-*.ts"}, + // The ":" should turn into "_" for cross-platform Windows portability + {InputPath: "/entry1-:.ts"}, - // The "*" should be preserved since the user _really_ wants it - {InputPath: "/entry2-*.ts", OutputPath: "entry2-*"}, + // The ":" should be preserved since the user _really_ wants it + {InputPath: "/entry2-:.ts", OutputPath: "entry2-:"}, }, options: config.Options{ Mode: config.ModePassThrough, diff --git a/internal/bundler/bundler_glob_test.go b/internal/bundler/bundler_glob_test.go new file mode 100644 index 00000000000..efe3e07dc9e --- /dev/null +++ b/internal/bundler/bundler_glob_test.go @@ -0,0 +1,138 @@ +package bundler + +import ( + "testing" + + "github.com/evanw/esbuild/internal/config" +) + +var glob_suite = suite{ + name: "glob", +} + +func TestGlobBasicNoSplitting(t *testing.T) { + glob_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + const ab = Math.random() < 0.5 ? 'a.js' : 'b.js' + console.log({ + concat: { + require: require('./src/' + ab), + import: import('./src/' + ab), + newURL: new URL('./src/' + ab, import.meta.url), + }, + template: { + require: require(` + "`./src/${ab}`" + `), + import: import(` + "`./src/${ab}`" + `), + newURL: new URL(` + "`./src/${ab}`" + `, import.meta.url), + }, + }) + `, + "/src/a.js": `module.exports = 'a'`, + "/src/b.js": `module.exports = 'b'`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + }, + expectedScanLog: `entry.js: WARNING: The "new URL(..., import.meta.url)" syntax won't be bundled without code splitting enabled +entry.js: WARNING: The "new URL(..., import.meta.url)" syntax won't be bundled without code splitting enabled +`, + }) +} + +func TestGlobBasicSplitting(t *testing.T) { + glob_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + const ab = Math.random() < 0.5 ? 'a.js' : 'b.js' + console.log({ + concat: { + require: require('./src/' + ab), + import: import('./src/' + ab), + newURL: new URL('./src/' + ab, import.meta.url), + }, + template: { + require: require(` + "`./src/${ab}`" + `), + import: import(` + "`./src/${ab}`" + `), + newURL: new URL(` + "`./src/${ab}`" + `, import.meta.url), + }, + }) + `, + "/src/a.js": `module.exports = 'a'`, + "/src/b.js": `module.exports = 'b'`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + CodeSplitting: true, + }, + }) +} + +func TestGlobDirDoesNotExist(t *testing.T) { + glob_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + const ab = Math.random() < 0.5 ? 'a.js' : 'b.js' + console.log({ + concat: { + require: require('./src/' + ab), + import: import('./src/' + ab), + newURL: new URL('./src/' + ab, import.meta.url), + }, + template: { + require: require(` + "`./src/${ab}`" + `), + import: import(` + "`./src/${ab}`" + `), + newURL: new URL(` + "`./src/${ab}`" + `, import.meta.url), + }, + }) + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + CodeSplitting: true, + }, + expectedScanLog: `entry.js: ERROR: Could not resolve require("./src/**/*") +entry.js: ERROR: Could not resolve import("./src/**/*") +entry.js: ERROR: Could not resolve new URL("./src/**/*", import.meta.url) +`, + }) +} + +func TestGlobNoMatches(t *testing.T) { + glob_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + const ab = Math.random() < 0.5 ? 'a.js' : 'b.js' + console.log({ + concat: { + require: require('./src/' + ab + '.json'), + import: import('./src/' + ab + '.json'), + newURL: new URL('./src/' + ab + '.json', import.meta.url), + }, + template: { + require: require(` + "`./src/${ab}.json`" + `), + import: import(` + "`./src/${ab}.json`" + `), + newURL: new URL(` + "`./src/${ab}.json`" + `, import.meta.url), + }, + }) + `, + "/src/dummy.js": ``, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + CodeSplitting: true, + }, + expectedScanLog: `entry.js: WARNING: The glob pattern require("./src/**/*.json") did not match any files +entry.js: WARNING: The glob pattern import("./src/**/*.json") did not match any files +entry.js: WARNING: The glob pattern new URL("./src/**/*.json", import.meta.url) did not match any files +`, + }) +} diff --git a/internal/bundler/linker.go b/internal/bundler/linker.go index e666e2d6768..9c30a3b10f0 100644 --- a/internal/bundler/linker.go +++ b/internal/bundler/linker.go @@ -5003,7 +5003,6 @@ func (c *linkerContext) generateChunkJS(chunks []chunkInfo, chunkIndex int, chun metaByteCount = make(map[string]int, len(compileResults)) } for _, compileResult := range compileResults { - isRuntime := compileResult.sourceIndex == runtime.SourceIndex for text := range compileResult.ExtractedLegalComments { if !legalCommentSet[text] { legalCommentSet[text] = true @@ -5036,7 +5035,7 @@ func (c *linkerContext) generateChunkJS(chunks []chunkInfo, chunkIndex int, chun } // Don't include the runtime in source maps - if isRuntime { + if c.graph.Files[compileResult.sourceIndex].InputFile.OmitFromSourceMapsAndMetafile { prevOffset.AdvanceString(string(compileResult.JS)) j.AddBytes(compileResult.JS) } else { diff --git a/internal/bundler/snapshots/snapshots_default.txt b/internal/bundler/snapshots/snapshots_default.txt index d846bb94fe6..be91e5eabf5 100644 --- a/internal/bundler/snapshots/snapshots_default.txt +++ b/internal/bundler/snapshots/snapshots_default.txt @@ -641,7 +641,7 @@ TestEntryNamesNonPortableCharacter ---------- /out/entry1-_.js ---------- console.log(1); ----------- /out/entry2-*.js ---------- +---------- /out/entry2-:.js ---------- console.log(2); ================================================================================ @@ -2945,27 +2945,62 @@ class Bar { ================================================================================ TestRequireAndDynamicImportInvalidTemplate ---------- /out.js ---------- +// require("./**/*") in entry.js +var _exports = {}; +__export(_exports, { + globRequire: () => globRequire +}); +var globRequire; +var init_ = __esm({ + 'require("./**/*") in entry.js'() { + globRequire = __glob({ + "./entry.js": () => require_entry() + }); + } +}); + +// import("./**/*") in entry.js +var _exports2 = {}; +__export(_exports2, { + globImport: () => globImport +}); +var globImport; +var init_2 = __esm({ + 'import("./**/*") in entry.js'() { + globImport = __glob({ + "./entry.js": () => Promise.resolve().then(() => require_entry()) + }); + } +}); + // entry.js -__require(tag`./b`); -__require(`./${b}`); -try { - __require(tag`./b`); - __require(`./${b}`); -} catch { -} -(async () => { - import(tag`./b`); - import(`./${b}`); - await import(tag`./b`); - await import(`./${b}`); - try { - import(tag`./b`); - import(`./${b}`); - await import(tag`./b`); - await import(`./${b}`); - } catch { +var require_entry = __commonJS({ + "entry.js"() { + init_(); + init_2(); + __require(tag`./b`); + globRequire(`./${b}`); + try { + __require(tag`./b`); + globRequire(`./${b}`); + } catch { + } + (async () => { + import(tag`./b`); + globImport(`./${b}`); + await import(tag`./b`); + await globImport(`./${b}`); + try { + import(tag`./b`); + globImport(`./${b}`); + await import(tag`./b`); + await globImport(`./${b}`); + } catch { + } + })(); } -})(); +}); +export default require_entry(); ================================================================================ TestRequireBadArgumentCount diff --git a/internal/bundler/snapshots/snapshots_glob.txt b/internal/bundler/snapshots/snapshots_glob.txt new file mode 100644 index 00000000000..3e7de0ac9d2 --- /dev/null +++ b/internal/bundler/snapshots/snapshots_glob.txt @@ -0,0 +1,201 @@ +TestGlobBasicNoSplitting +---------- /out.js ---------- +// src/a.js +var require_a = __commonJS({ + "src/a.js"(exports, module) { + module.exports = "a"; + } +}); + +// src/b.js +var require_b = __commonJS({ + "src/b.js"(exports, module) { + module.exports = "b"; + } +}); + +// require("./src/**/*") in entry.js +var _exports = {}; +__export(_exports, { + globRequire_src: () => globRequire_src +}); +var globRequire_src = __glob({ + "./src/a.js": () => require_a(), + "./src/b.js": () => require_b() +}); + +// import("./src/**/*") in entry.js +var _exports2 = {}; +__export(_exports2, { + globImport_src: () => globImport_src +}); +var globImport_src = __glob({ + "./src/a.js": () => Promise.resolve().then(() => __toESM(require_a())), + "./src/b.js": () => Promise.resolve().then(() => __toESM(require_b())) +}); + +// entry.js +var ab = Math.random() < 0.5 ? "a.js" : "b.js"; +console.log({ + concat: { + require: globRequire_src("./src/" + ab), + import: globImport_src("./src/" + ab), + newURL: new URL("./src/" + ab, import.meta.url) + }, + template: { + require: globRequire_src(`./src/${ab}`), + import: globImport_src(`./src/${ab}`), + newURL: new URL(`./src/${ab}`, import.meta.url) + } +}); + +================================================================================ +TestGlobBasicSplitting +---------- /out/entry.js ---------- +import { + require_a +} from "./chunk-HEKATOEQ.js"; +import { + require_b +} from "./chunk-SMNQOI55.js"; +import { + __export, + __glob +} from "./chunk-7R4CFAA2.js"; + +// require("./src/**/*") in entry.js +var _exports = {}; +__export(_exports, { + globRequire_src: () => globRequire_src +}); +var globRequire_src = __glob({ + "./src/a.js": () => require_a(), + "./src/b.js": () => require_b() +}); + +// import("./src/**/*") in entry.js +var _exports2 = {}; +__export(_exports2, { + globImport_src: () => globImport_src +}); +var globImport_src = __glob({ + "./src/a.js": () => import("./a-NN32PJTF.js"), + "./src/b.js": () => import("./b-62CWNBIU.js") +}); + +// new URL("./src/**/*", import.meta.url) in entry.js +var _exports3 = {}; +__export(_exports3, { + globNewURL_src: () => globNewURL_src +}); +var globNewURL_src = __glob({ + "./src/a.js": () => new URL("./a-NN32PJTF.js", import.meta.url), + "./src/b.js": () => new URL("./b-62CWNBIU.js", import.meta.url) +}); + +// entry.js +var ab = Math.random() < 0.5 ? "a.js" : "b.js"; +console.log({ + concat: { + require: globRequire_src("./src/" + ab), + import: globImport_src("./src/" + ab), + newURL: globNewURL_src("./src/" + ab) + }, + template: { + require: globRequire_src(`./src/${ab}`), + import: globImport_src(`./src/${ab}`), + newURL: globNewURL_src(`./src/${ab}`) + } +}); + +---------- /out/a-NN32PJTF.js ---------- +import { + require_a +} from "./chunk-HEKATOEQ.js"; +import "./chunk-7R4CFAA2.js"; +export default require_a(); + +---------- /out/chunk-HEKATOEQ.js ---------- +import { + __commonJS +} from "./chunk-7R4CFAA2.js"; + +// src/a.js +var require_a = __commonJS({ + "src/a.js"(exports, module) { + module.exports = "a"; + } +}); + +export { + require_a +}; + +---------- /out/b-62CWNBIU.js ---------- +import { + require_b +} from "./chunk-SMNQOI55.js"; +import "./chunk-7R4CFAA2.js"; +export default require_b(); + +---------- /out/chunk-SMNQOI55.js ---------- +import { + __commonJS +} from "./chunk-7R4CFAA2.js"; + +// src/b.js +var require_b = __commonJS({ + "src/b.js"(exports, module) { + module.exports = "b"; + } +}); + +export { + require_b +}; + +---------- /out/chunk-7R4CFAA2.js ---------- +export { + __glob, + __commonJS, + __export +}; + +================================================================================ +TestGlobNoMatches +---------- /out/entry.js ---------- +// require("./src/**/*.json") in entry.js +var _exports = {}; +__export(_exports, { + globRequire_src_json: () => globRequire_src_json +}); +var globRequire_src_json = __glob({}); + +// import("./src/**/*.json") in entry.js +var _exports2 = {}; +__export(_exports2, { + globImport_src_json: () => globImport_src_json +}); +var globImport_src_json = __glob({}); + +// new URL("./src/**/*.json", import.meta.url) in entry.js +var _exports3 = {}; +__export(_exports3, { + globNewURL_src_json: () => globNewURL_src_json +}); +var globNewURL_src_json = __glob({}); + +// entry.js +var ab = Math.random() < 0.5 ? "a.js" : "b.js"; +console.log({ + concat: { + require: globRequire_src_json("./src/" + ab + ".json"), + import: globImport_src_json("./src/" + ab + ".json"), + newURL: globNewURL_src_json("./src/" + ab + ".json") + }, + template: { + require: globRequire_src_json(`./src/${ab}.json`), + import: globImport_src_json(`./src/${ab}.json`), + newURL: globNewURL_src_json(`./src/${ab}.json`) + } +}); diff --git a/internal/bundler/snapshots/snapshots_splitting.txt b/internal/bundler/snapshots/snapshots_splitting.txt index ff32ebd7291..140b92c786e 100644 --- a/internal/bundler/snapshots/snapshots_splitting.txt +++ b/internal/bundler/snapshots/snapshots_splitting.txt @@ -215,19 +215,19 @@ TestSplittingDynamicAndNotDynamicCommonJSIntoES6 import { __toESM, require_foo -} from "./chunk-6FVSQLGP.js"; +} from "./chunk-H2HESYLH.js"; // entry.js var import_foo = __toESM(require_foo()); -import("./foo-NCFYBVPB.js").then(({ default: { bar: b } }) => console.log(import_foo.bar, b)); +import("./foo-OK6Y35CI.js").then(({ default: { bar: b } }) => console.log(import_foo.bar, b)); ----------- /out/foo-NCFYBVPB.js ---------- +---------- /out/foo-OK6Y35CI.js ---------- import { require_foo -} from "./chunk-6FVSQLGP.js"; +} from "./chunk-H2HESYLH.js"; export default require_foo(); ----------- /out/chunk-6FVSQLGP.js ---------- +---------- /out/chunk-H2HESYLH.js ---------- // foo.js var require_foo = __commonJS({ "foo.js"(exports) { @@ -270,9 +270,9 @@ export { TestSplittingDynamicCommonJSIntoES6 ---------- /out/entry.js ---------- // entry.js -import("./foo-MGFNEEYE.js").then(({ default: { bar } }) => console.log(bar)); +import("./foo-PPQD77K4.js").then(({ default: { bar } }) => console.log(bar)); ----------- /out/foo-MGFNEEYE.js ---------- +---------- /out/foo-PPQD77K4.js ---------- // foo.js var require_foo = __commonJS({ "foo.js"(exports) { @@ -327,7 +327,7 @@ TestSplittingHybridESMAndCJSIssue617 import { foo, init_a -} from "./chunk-WNGDXRWX.js"; +} from "./chunk-NE324UYZ.js"; init_a(); export { foo @@ -338,7 +338,7 @@ import { __toCommonJS, a_exports, init_a -} from "./chunk-WNGDXRWX.js"; +} from "./chunk-NE324UYZ.js"; // b.js var bar = (init_a(), __toCommonJS(a_exports)); @@ -346,7 +346,7 @@ export { bar }; ----------- /out/chunk-WNGDXRWX.js ---------- +---------- /out/chunk-NE324UYZ.js ---------- // a.js var a_exports = {}; __export(a_exports, { @@ -497,7 +497,7 @@ TestSplittingSharedCommonJSIntoES6 ---------- /out/a.js ---------- import { require_shared -} from "./chunk-GI5SL2RW.js"; +} from "./chunk-KTJ3L72M.js"; // a.js var { foo } = require_shared(); @@ -506,13 +506,13 @@ console.log(foo); ---------- /out/b.js ---------- import { require_shared -} from "./chunk-GI5SL2RW.js"; +} from "./chunk-KTJ3L72M.js"; // b.js var { foo } = require_shared(); console.log(foo); ----------- /out/chunk-GI5SL2RW.js ---------- +---------- /out/chunk-KTJ3L72M.js ---------- // shared.js var require_shared = __commonJS({ "shared.js"(exports) { diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 7eacea39ab1..8b1dd8c401e 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -41,6 +41,7 @@ type CacheSet struct { func MakeCacheSet() *CacheSet { return &CacheSet{ SourceIndexCache: SourceIndexCache{ + globEntries: make(map[uint64]uint32), entries: make(map[sourceIndexKey]uint32), nextSourceIndex: runtime.SourceIndex + 1, }, @@ -60,6 +61,7 @@ func MakeCacheSet() *CacheSet { } type SourceIndexCache struct { + globEntries map[uint64]uint32 entries map[sourceIndexKey]uint32 mutex sync.Mutex nextSourceIndex uint32 @@ -98,3 +100,16 @@ func (c *SourceIndexCache) Get(path logger.Path, kind SourceIndexKind) uint32 { c.entries[key] = sourceIndex return sourceIndex } + +func (c *SourceIndexCache) GetGlob(parentSourceIndex uint32, globIndex uint32) uint32 { + key := (uint64(parentSourceIndex) << 32) | uint64(globIndex) + c.mutex.Lock() + defer c.mutex.Unlock() + if sourceIndex, ok := c.globEntries[key]; ok { + return sourceIndex + } + sourceIndex := c.nextSourceIndex + c.nextSourceIndex++ + c.globEntries[key] = sourceIndex + return sourceIndex +} diff --git a/internal/graph/input.go b/internal/graph/input.go index 2a54fc987b9..b4fb1b6be1e 100644 --- a/internal/graph/input.go +++ b/internal/graph/input.go @@ -30,6 +30,8 @@ type InputFile struct { SideEffects SideEffects Source logger.Source Loader config.Loader + + OmitFromSourceMapsAndMetafile bool } type OutputFile struct { diff --git a/internal/helpers/glob.go b/internal/helpers/glob.go new file mode 100644 index 00000000000..1a0accabb58 --- /dev/null +++ b/internal/helpers/glob.go @@ -0,0 +1,54 @@ +package helpers + +import "strings" + +type GlobWildcard uint8 + +const ( + GlobNone GlobWildcard = iota + GlobAllExceptSlash + GlobAllIncludingSlash +) + +type GlobPart struct { + Prefix string + Wildcard GlobWildcard +} + +// The returned array will always be at least one element. If there are no +// wildcards then it will be exactly one element, and if there are wildcards +// then it will be more than one element. +func ParseGlobPattern(text string) (pattern []GlobPart) { + for { + star := strings.IndexByte(text, '*') + if star < 0 { + pattern = append(pattern, GlobPart{Prefix: text}) + break + } + count := 1 + for star+count < len(text) && text[star+count] == '*' { + count++ + } + wildcard := GlobAllExceptSlash + if count > 1 && (star == 0 || text[star-1] == '/') && (star+count == len(text) || text[star+count] == '/') { + wildcard = GlobAllIncludingSlash // A "globstar" path segment + } + pattern = append(pattern, GlobPart{Prefix: text[:star], Wildcard: wildcard}) + text = text[star+count:] + } + return +} + +func GlobPatternToString(pattern []GlobPart) string { + sb := strings.Builder{} + for _, part := range pattern { + sb.WriteString(part.Prefix) + switch part.Wildcard { + case GlobAllExceptSlash: + sb.WriteByte('*') + case GlobAllIncludingSlash: + sb.WriteString("**") + } + } + return sb.String() +} diff --git a/internal/js_parser/js_parser.go b/internal/js_parser/js_parser.go index 5f76d57104d..f5077f1fbff 100644 --- a/internal/js_parser/js_parser.go +++ b/internal/js_parser/js_parser.go @@ -54,6 +54,7 @@ type parser struct { importSymbolPropertyUses map[js_ast.Ref]map[string]js_ast.SymbolUse symbolCallUses map[js_ast.Ref]js_ast.SymbolCallUse declaredSymbols []js_ast.DeclaredSymbol + globPatternImports []globPatternImport runtimeImports map[string]js_ast.LocRef duplicateCaseChecker duplicateCaseChecker unrepresentableIdentifiers map[string]bool @@ -345,6 +346,15 @@ type parser struct { isControlFlowDead bool } +type globPatternImport struct { + assertions *[]ast.AssertEntry + parts []helpers.GlobPart + name string + approximateRange logger.Range + ref js_ast.Ref + kind ast.ImportKind +} + type namespaceImportItems struct { entries map[string]js_ast.LocRef importRecordIndex uint32 @@ -5805,9 +5815,9 @@ func (p *parser) parseLabelName() *js_ast.LocRef { return &name } -func (p *parser) parsePath() (logger.Loc, string, *[]ast.AssertEntry, ast.ImportRecordFlags) { +func (p *parser) parsePath() (logger.Range, string, *[]ast.AssertEntry, ast.ImportRecordFlags) { var flags ast.ImportRecordFlags - pathLoc := p.lexer.Loc() + pathRange := p.lexer.Range() pathText := helpers.UTF16ToString(p.lexer.StringLiteral()) if p.lexer.Token == js_lexer.TNoSubstitutionTemplateLiteral { p.lexer.Next() @@ -5876,7 +5886,7 @@ func (p *parser) parsePath() (logger.Loc, string, *[]ast.AssertEntry, ast.Import assertions = &entries } - return pathLoc, pathText, assertions, flags + return pathRange, pathText, assertions, flags } // This assumes the "function" token has already been parsed @@ -6262,7 +6272,7 @@ func (p *parser) parseStmt(opts parseStmtOpts) js_ast.Stmt { p.lexer.Next() var namespaceRef js_ast.Ref var alias *js_ast.ExportStarAlias - var pathLoc logger.Loc + var pathRange logger.Range var pathText string var assertions *[]ast.AssertEntry var flags ast.ImportRecordFlags @@ -6275,15 +6285,15 @@ func (p *parser) parseStmt(opts parseStmtOpts) js_ast.Stmt { alias = &js_ast.ExportStarAlias{Loc: p.lexer.Loc(), OriginalName: name.String} p.lexer.Next() p.lexer.ExpectContextualKeyword("from") - pathLoc, pathText, assertions, flags = p.parsePath() + pathRange, pathText, assertions, flags = p.parsePath() } else { // "export * from 'path'" p.lexer.ExpectContextualKeyword("from") - pathLoc, pathText, assertions, flags = p.parsePath() + pathRange, pathText, assertions, flags = p.parsePath() name := js_ast.GenerateNonUniqueNameFromPath(pathText) + "_star" namespaceRef = p.storeNameInRef(js_lexer.MaybeSubstring{String: name}) } - importRecordIndex := p.addImportRecord(ast.ImportStmt, pathLoc, pathText, assertions, flags) + importRecordIndex := p.addImportRecord(ast.ImportStmt, pathRange, pathText, assertions, flags) // Export-star statements anywhere in the file disable top-level const // local prefix because import cycles can be used to trigger TDZ @@ -7206,11 +7216,11 @@ func extractDeclsForBinding(binding js_ast.Binding, decls []js_ast.Decl) []js_as return decls } -func (p *parser) addImportRecord(kind ast.ImportKind, pathLoc logger.Loc, text string, assertions *[]ast.AssertEntry, flags ast.ImportRecordFlags) uint32 { +func (p *parser) addImportRecord(kind ast.ImportKind, pathRange logger.Range, text string, assertions *[]ast.AssertEntry, flags ast.ImportRecordFlags) uint32 { index := uint32(len(p.importRecords)) p.importRecords = append(p.importRecords, ast.ImportRecord{ Kind: kind, - Range: p.source.RangeOfString(pathLoc), + Range: pathRange, Path: logger.Path{Text: text}, Assertions: assertions, Flags: flags, @@ -14016,7 +14026,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO return js_ast.Expr{Loc: arg.Loc, Data: js_ast.ENullShared} } - importRecordIndex := p.addImportRecord(ast.ImportDynamic, arg.Loc, helpers.UTF16ToString(str.Value), assertions, flags) + importRecordIndex := p.addImportRecord(ast.ImportDynamic, p.source.RangeOfString(arg.Loc), helpers.UTF16ToString(str.Value), assertions, flags) p.importRecordsForCurrentPart = append(p.importRecordsForCurrentPart, importRecordIndex) if isAwaitTarget && p.fnOrArrowDataVisit.tryBodyCount != 0 { record := &p.importRecords[importRecordIndex] @@ -14033,6 +14043,13 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO }} } + // Handle glob patterns + if p.options.mode == config.ModeBundle { + if value := p.handleGlobPattern(arg, ast.ImportDynamic, "globImport", assertions); value.Data != nil { + return value + } + } + // Use a debug log so people can see this if they want to r := js_lexer.RangeOfIdentifier(p.source, expr.Loc) p.log.AddID(logger.MsgID_JS_UnsupportedDynamicImport, logger.Debug, &p.tracker, r, @@ -14295,7 +14312,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO return js_ast.Expr{Loc: arg.Loc, Data: js_ast.ENullShared} } - importRecordIndex := p.addImportRecord(ast.ImportRequireResolve, e.Args[0].Loc, helpers.UTF16ToString(str.Value), nil, 0) + importRecordIndex := p.addImportRecord(ast.ImportRequireResolve, p.source.RangeOfString(e.Args[0].Loc), helpers.UTF16ToString(str.Value), nil, 0) p.importRecordsForCurrentPart = append(p.importRecordsForCurrentPart, importRecordIndex) if p.fnOrArrowDataVisit.tryBodyCount != 0 { record := &p.importRecords[importRecordIndex] @@ -14407,7 +14424,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO return js_ast.Expr{Loc: expr.Loc, Data: js_ast.ENullShared} } - importRecordIndex := p.addImportRecord(ast.ImportRequire, arg.Loc, helpers.UTF16ToString(str.Value), nil, 0) + importRecordIndex := p.addImportRecord(ast.ImportRequire, p.source.RangeOfString(arg.Loc), helpers.UTF16ToString(str.Value), nil, 0) p.importRecordsForCurrentPart = append(p.importRecordsForCurrentPart, importRecordIndex) if p.fnOrArrowDataVisit.tryBodyCount != 0 { record := &p.importRecords[importRecordIndex] @@ -14421,6 +14438,11 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO }} } + // Handle glob patterns + if value := p.handleGlobPattern(arg, ast.ImportRequire, "globRequire", nil); value.Data != nil { + return value + } + // Use a debug log so people can see this if they want to r := js_lexer.RangeOfIdentifier(p.source, e.Target.Loc) p.log.AddID(logger.MsgID_JS_UnsupportedRequireCall, logger.Debug, &p.tracker, r, @@ -14491,6 +14513,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO if _, ok := dot.Target.Data.(*js_ast.EImportMeta); ok { // Support "new URL(a ? './b' : './c', import.meta.url)" return p.maybeTransposeIfExprChain(e.Args[0], func(arg js_ast.Expr) js_ast.Expr { + // The argument must be a string if str, ok := arg.Data.(*js_ast.EString); ok { if path := helpers.UTF16ToString(str.Value); strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") { if !p.options.codeSplitting { @@ -14498,7 +14521,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO logger.Range{Loc: expr.Loc, Len: e.CloseParenLoc.Start + 1 - expr.Loc.Start}, "The \"new URL(..., import.meta.url)\" syntax won't be bundled without code splitting enabled") } else { - importRecordIndex := p.addImportRecord(ast.ImportNewURL, arg.Loc, path, nil, 0) + importRecordIndex := p.addImportRecord(ast.ImportNewURL, p.source.RangeOfString(arg.Loc), path, nil, 0) p.importRecordsForCurrentPart = append(p.importRecordsForCurrentPart, importRecordIndex) if p.fnOrArrowDataVisit.tryBodyCount != 0 { record := &p.importRecords[importRecordIndex] @@ -14513,6 +14536,26 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO } } + // Handle glob patterns + prefix := "globNewURL" + if !p.options.codeSplitting { + // Don't actually generate an import if code splitting is + // disabled. In that case we are just using this call to + // see if the pattern would even be handled at all if code + // splitting were to be enabled. Only in that case do we + // generate a warning. + prefix = "" + } + if value := p.handleGlobPattern(arg, ast.ImportNewURL, prefix, nil); value.Data != nil { + if !p.options.codeSplitting { + p.log.AddID(logger.MsgID_Bundler_NewURLImportMeta, logger.Warning, &p.tracker, + logger.Range{Loc: expr.Loc, Len: e.CloseParenLoc.Start + 1 - expr.Loc.Start}, + "The \"new URL(..., import.meta.url)\" syntax won't be bundled without code splitting enabled") + } else { + return value + } + } + importMetaURL := *dot return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.ENew{ Target: js_ast.Expr{Loc: e.Target.Loc, Data: &js_ast.EIdentifier{Ref: id.Ref}}, @@ -14613,6 +14656,218 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO return expr, exprOut{} } +func (p *parser) handleGlobPattern(expr js_ast.Expr, kind ast.ImportKind, prefix string, assertions *[]ast.AssertEntry) js_ast.Expr { + pattern, approximateRange := p.globPatternFromExpr(expr) + if pattern == nil { + return js_ast.Expr{} + } + + var last helpers.GlobPart + var parts []helpers.GlobPart + + for _, part := range pattern { + if part.isWildcard { + if last.Wildcard == helpers.GlobNone { + if !strings.HasSuffix(last.Prefix, "/") { + // "`a${b}c`" => "a*c" + last.Wildcard = helpers.GlobAllExceptSlash + } else { + // "`a/${b}c`" => "a/**/*c" + last.Wildcard = helpers.GlobAllIncludingSlash + parts = append(parts, last) + last = helpers.GlobPart{Prefix: "/", Wildcard: helpers.GlobAllExceptSlash} + } + } + } else if part.text != "" { + if last.Wildcard != helpers.GlobNone { + parts = append(parts, last) + last = helpers.GlobPart{} + } + last.Prefix += part.text + } + } + + parts = append(parts, last) + + // Don't handle this if it's a string constant + if len(parts) == 1 && parts[0].Wildcard == helpers.GlobNone { + return js_ast.Expr{} + } + + // We currently only support relative globs + if prefix := parts[0].Prefix; !strings.HasPrefix(prefix, "./") && !strings.HasPrefix(prefix, "../") { + return js_ast.Expr{} + } + + ref := js_ast.InvalidRef + + // Don't generate duplicate glob imports +outer: + for _, globPattern := range p.globPatternImports { + // Check the kind + if globPattern.kind != kind { + continue + } + + // Check the parts + if len(globPattern.parts) != len(parts) { + continue + } + for i := range parts { + if globPattern.parts[i] != parts[i] { + continue outer + } + } + + // Check the import assertions + if assertions == nil { + if globPattern.assertions != nil { + continue + } + } else { + if globPattern.assertions == nil { + continue + } + a := *assertions + b := *globPattern.assertions + if len(a) != len(b) { + continue + } + for i := range a { + ai := a[i] + bi := b[i] + if !helpers.UTF16EqualsUTF16(ai.Key, bi.Key) || !helpers.UTF16EqualsUTF16(ai.Value, bi.Value) { + continue outer + } + } + } + + // If we get here, then these are the same glob pattern + ref = globPattern.ref + break + } + + // If there's no duplicate glob import, then generate a new glob import + if ref == js_ast.InvalidRef && prefix != "" { + sb := strings.Builder{} + sb.WriteString(prefix) + + for _, part := range parts { + gap := true + for _, c := range part.Prefix { + if !js_lexer.IsIdentifierContinue(c) { + gap = true + } else { + if gap { + sb.WriteByte('_') + gap = false + } + sb.WriteRune(c) + } + } + } + + name := sb.String() + ref = p.newSymbol(js_ast.SymbolOther, name) + p.moduleScope.Generated = append(p.moduleScope.Generated, ref) + + p.globPatternImports = append(p.globPatternImports, globPatternImport{ + assertions: assertions, + parts: parts, + name: name, + approximateRange: approximateRange, + ref: ref, + kind: kind, + }) + } + + return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.ECall{ + Target: js_ast.Expr{Loc: expr.Loc, Data: &js_ast.EIdentifier{Ref: ref}}, + Args: []js_ast.Expr{expr}, + }} +} + +type globPart struct { + text string + isWildcard bool +} + +func (p *parser) globPatternFromExpr(expr js_ast.Expr) ([]globPart, logger.Range) { + switch e := expr.Data.(type) { + case *js_ast.EString: + return []globPart{{text: helpers.UTF16ToString(e.Value)}}, p.source.RangeOfString(expr.Loc) + + case *js_ast.ETemplate: + if e.TagOrNil.Data != nil { + break + } + + pattern := make([]globPart, 0, 1+2*len(e.Parts)) + pattern = append(pattern, globPart{text: helpers.UTF16ToString(e.HeadCooked)}) + + for _, part := range e.Parts { + if partPattern, _ := p.globPatternFromExpr(part.Value); partPattern != nil { + pattern = append(pattern, partPattern...) + } else { + pattern = append(pattern, globPart{isWildcard: true}) + } + pattern = append(pattern, globPart{text: helpers.UTF16ToString(part.TailCooked)}) + } + + if len(e.Parts) == 0 { + return pattern, p.source.RangeOfString(expr.Loc) + } + + text := p.source.Contents + templateRange := logger.Range{Loc: e.HeadLoc} + + for i := e.Parts[len(e.Parts)-1].TailLoc.Start; i < int32(len(text)); i++ { + c := text[i] + if c == '`' { + templateRange.Len = i + 1 - templateRange.Loc.Start + break + } else if c == '\\' { + i += 1 + } + } + + return pattern, templateRange + + case *js_ast.EBinary: + if e.Op != js_ast.BinOpAdd { + break + } + + pattern, leftRange := p.globPatternFromExpr(e.Left) + if pattern == nil { + break + } + + if rightPattern, rightRange := p.globPatternFromExpr(e.Right); rightPattern != nil { + pattern = append(pattern, rightPattern...) + leftRange.Len = rightRange.End() - leftRange.Loc.Start + return pattern, leftRange + } + + pattern = append(pattern, globPart{isWildcard: true}) + + // Try to extend the left range by the right operand in some common cases + switch right := e.Right.Data.(type) { + case *js_ast.EIdentifier: + leftRange.Len = js_lexer.RangeOfIdentifier(p.source, e.Right.Loc).End() - leftRange.Loc.Start + + case *js_ast.ECall: + if right.CloseParenLoc.Start > 0 { + leftRange.Len = right.CloseParenLoc.Start + 1 - leftRange.Loc.Start + } + } + + return pattern, leftRange + } + + return nil, logger.Range{} +} + func (p *parser) convertSymbolUseToCall(ref js_ast.Ref, isSingleNonSpreadArgCall bool) { // Remove the normal symbol use use := p.symbolUses[ref] @@ -16048,7 +16303,7 @@ func Parse(log logger.Log, source logger.Source, options Options) (result js_ast } } } - before = p.generateImportStmt(file.Source.KeyPath.Text, exportsNoConflict, &file.Source.Index, before, symbols) + before, _ = p.generateImportStmt(file.Source.KeyPath.Text, logger.Range{}, exportsNoConflict, &file.Source.Index, before, symbols) } // Bind symbols in a second pass over the AST. I started off doing this in a @@ -16159,6 +16414,50 @@ func LazyExportAST(log logger.Log, source logger.Source, options Options, expr j return ast } +func GlobResolveAST(log logger.Log, source logger.Source, importRecords []ast.ImportRecord, object *js_ast.EObject, name string) js_ast.AST { + // Don't create a new lexer using js_lexer.NewLexer() here since that will + // actually attempt to parse the first token, which might cause a syntax + // error. + p := newParser(log, source, js_lexer.Lexer{}, &Options{}) + p.prepareForVisitPass() + + // Add an empty part for the namespace export that we can fill in later + nsExportPart := js_ast.Part{ + SymbolUses: make(map[js_ast.Ref]js_ast.SymbolUse), + CanBeRemovedIfUnused: true, + } + + if len(p.importRecords) != 0 { + panic("Internal error") + } + p.importRecords = importRecords + + importRecordIndices := make([]uint32, 0, len(importRecords)) + for importRecordIndex := range importRecords { + importRecordIndices = append(importRecordIndices, uint32(importRecordIndex)) + } + + p.symbolUses = make(map[js_ast.Ref]js_ast.SymbolUse) + ref := p.newSymbol(js_ast.SymbolOther, name) + p.moduleScope.Generated = append(p.moduleScope.Generated, ref) + + part := js_ast.Part{ + Stmts: []js_ast.Stmt{{Data: &js_ast.SLocal{ + IsExport: true, + Decls: []js_ast.Decl{{ + Binding: js_ast.Binding{Data: &js_ast.BIdentifier{Ref: ref}}, + ValueOrNil: p.callRuntime(logger.Loc{}, "__glob", []js_ast.Expr{{Data: object}}), + }}, + }}}, + ImportRecordIndices: importRecordIndices, + SymbolUses: p.symbolUses, + } + p.symbolUses = nil + + p.esmExportKeyword.Len = 1 + return p.toAST(nil, []js_ast.Part{nsExportPart, part}, nil, "", "") +} + func ParseDefineExprOrJSON(text string) (config.DefineExpr, js_ast.E) { if text == "" { return config.DefineExpr{}, nil @@ -16467,25 +16766,27 @@ func (p *parser) computeCharacterFrequency() *js_ast.CharFreq { func (p *parser) generateImportStmt( path string, + pathRange logger.Range, imports []string, sourceIndex *uint32, parts []js_ast.Part, symbols map[string]js_ast.LocRef, -) []js_ast.Part { - var loc logger.Loc - isFirst := true - for _, it := range symbols { - if isFirst || it.Loc.Start < loc.Start { - loc = it.Loc +) ([]js_ast.Part, uint32) { + if pathRange.Len == 0 { + isFirst := true + for _, it := range symbols { + if isFirst || it.Loc.Start < pathRange.Loc.Start { + pathRange.Loc = it.Loc + } + isFirst = false } - isFirst = false } namespaceRef := p.newSymbol(js_ast.SymbolOther, "import_"+js_ast.GenerateNonUniqueNameFromPath(path)) p.moduleScope.Generated = append(p.moduleScope.Generated, namespaceRef) declaredSymbols := make([]js_ast.DeclaredSymbol, len(imports)) clauseItems := make([]js_ast.ClauseItem, len(imports)) - importRecordIndex := p.addImportRecord(ast.ImportStmt, loc, path, nil, 0) + importRecordIndex := p.addImportRecord(ast.ImportStmt, pathRange, path, nil, 0) if sourceIndex != nil { p.importRecords[importRecordIndex].SourceIndex = ast.MakeIndex32(*sourceIndex) } @@ -16513,13 +16814,13 @@ func (p *parser) generateImportStmt( return append(parts, js_ast.Part{ DeclaredSymbols: declaredSymbols, ImportRecordIndices: []uint32{importRecordIndex}, - Stmts: []js_ast.Stmt{{Loc: loc, Data: &js_ast.SImport{ + Stmts: []js_ast.Stmt{{Loc: pathRange.Loc, Data: &js_ast.SImport{ NamespaceRef: namespaceRef, Items: &clauseItems, ImportRecordIndex: importRecordIndex, IsSingleLine: true, }}}, - }) + }), importRecordIndex } // Sort the keys for determinism @@ -16537,7 +16838,7 @@ func (p *parser) toAST(before, parts, after []js_ast.Part, hashbang string, dire if len(p.runtimeImports) > 0 && !p.options.omitRuntimeForTests { keys := sortedKeysOfMapStringLocRef(p.runtimeImports) sourceIndex := runtime.SourceIndex - before = p.generateImportStmt("", keys, &sourceIndex, before, p.runtimeImports) + before, _ = p.generateImportStmt("", logger.Range{}, keys, &sourceIndex, before, p.runtimeImports) } // Insert an import statement for any jsx runtime imports we generated @@ -16552,14 +16853,28 @@ func (p *parser) toAST(before, parts, after []js_ast.Part, hashbang string, dire path = path + "/jsx-runtime" } - before = p.generateImportStmt(path, keys, nil, before, p.jsxRuntimeImports) + before, _ = p.generateImportStmt(path, logger.Range{}, keys, nil, before, p.jsxRuntimeImports) } // Insert an import statement for any legacy jsx imports we generated (i.e., createElement) if len(p.jsxLegacyImports) > 0 && !p.options.omitJSXRuntimeForTests { keys := sortedKeysOfMapStringLocRef(p.jsxLegacyImports) path := p.options.jsx.ImportSource - before = p.generateImportStmt(path, keys, nil, before, p.jsxLegacyImports) + before, _ = p.generateImportStmt(path, logger.Range{}, keys, nil, before, p.jsxLegacyImports) + } + + // Insert imports for each glob pattern + for _, glob := range p.globPatternImports { + symbols := map[string]js_ast.LocRef{glob.name: {Loc: glob.approximateRange.Loc, Ref: glob.ref}} + var importRecordIndex uint32 + before, importRecordIndex = p.generateImportStmt(helpers.GlobPatternToString(glob.parts), glob.approximateRange, []string{glob.name}, nil, before, symbols) + record := &p.importRecords[importRecordIndex] + record.Assertions = glob.assertions + record.GlobPattern = &ast.GlobPattern{ + Parts: glob.parts, + ExportAlias: glob.name, + Kind: glob.kind, + } } // Generated imports are inserted before other code instead of appending them diff --git a/internal/logger/msg_ids.go b/internal/logger/msg_ids.go index 2c73294cb20..f4fa0a91921 100644 --- a/internal/logger/msg_ids.go +++ b/internal/logger/msg_ids.go @@ -52,11 +52,12 @@ const ( // Bundler MsgID_Bundler_AmbiguousReexport MsgID_Bundler_DifferentPathCase + MsgID_Bundler_EmptyGlob MsgID_Bundler_IgnoredBareImport MsgID_Bundler_IgnoredDynamicImport MsgID_Bundler_ImportIsUndefined - MsgID_Bundler_RequireResolveNotExternal MsgID_Bundler_NewURLImportMeta + MsgID_Bundler_RequireResolveNotExternal // Source maps MsgID_SourceMap_InvalidSourceMappings @@ -165,6 +166,8 @@ func StringToMsgIDs(str string, logLevel LogLevel, overrides map[MsgID]LogLevel) overrides[MsgID_Bundler_AmbiguousReexport] = logLevel case "different-path-case": overrides[MsgID_Bundler_DifferentPathCase] = logLevel + case "empty-glob": + overrides[MsgID_Bundler_EmptyGlob] = logLevel case "ignored-bare-import": overrides[MsgID_Bundler_IgnoredBareImport] = logLevel case "ignored-dynamic-import": @@ -281,6 +284,8 @@ func MsgIDToString(id MsgID) string { return "ambiguous-reexport" case MsgID_Bundler_DifferentPathCase: return "different-path-case" + case MsgID_Bundler_EmptyGlob: + return "empty-glob" case MsgID_Bundler_IgnoredBareImport: return "ignored-bare-import" case MsgID_Bundler_IgnoredDynamicImport: diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 4598ba69575..c6f6e0ad72b 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path" + "regexp" "sort" "strings" "sync" @@ -170,6 +171,7 @@ func (dm DebugMeta) LogErrorMsg(log logger.Log, source *logger.Source, r logger. type Resolver interface { Resolve(sourceDir string, importPath string, kind ast.ImportKind) (result *ResolveResult, debug DebugMeta) + ResolveGlob(sourceDir string, importPath []helpers.GlobPart, kind ast.ImportKind, prettyPattern string) (results map[string]ResolveResult, warning *logger.Msg) ResolveAbs(absPath string) *ResolveResult PrettyPath(path logger.Path) string @@ -422,6 +424,15 @@ func (rr *resolver) Resolve(sourceDir string, importPath string, kind ast.Import return nil, debugMeta } + // Glob imports only work in a multi-path context + if strings.ContainsRune(importPath, '*') { + if r.debugLogs != nil { + r.debugLogs.addNote("Cannot resolve a path containing a wildcard character in a single-path context") + } + r.flushDebugLogs(flushDueToFailure) + return nil, debugMeta + } + r.mutex.Lock() defer r.mutex.Unlock() sourceDirInfo := r.loadModuleSuffixesForSourceDir(sourceDir) @@ -479,6 +490,156 @@ func (rr *resolver) Resolve(sourceDir string, importPath string, kind ast.Import return result, debugMeta } +// This returns nil on failure and non-nil on success. Note that this may +// return an empty array to indicate a successful search that returned zero +// results. +func (rr *resolver) ResolveGlob(sourceDir string, importPathPattern []helpers.GlobPart, kind ast.ImportKind, prettyPattern string) (map[string]ResolveResult, *logger.Msg) { + var debugMeta DebugMeta + r := resolverQuery{ + resolver: rr, + debugMeta: &debugMeta, + kind: kind, + } + + if r.log.Level <= logger.LevelDebug { + r.debugLogs = &debugLogs{what: fmt.Sprintf( + "Resolving glob import %s in directory %q of type %q", + prettyPattern, sourceDir, kind.StringForMetafile())} + } + + if len(importPathPattern) == 0 { + if r.debugLogs != nil { + r.debugLogs.addNote("Ignoring empty glob pattern") + } + r.flushDebugLogs(flushDueToFailure) + return nil, nil + } + firstPrefix := importPathPattern[0].Prefix + + // Glob patterns only work for relative URLs + if !strings.HasPrefix(firstPrefix, "./") && !strings.HasPrefix(firstPrefix, "../") { + if kind == ast.ImportEntryPoint { + // Be permissive about forgetting "./" for entry points since it's common to omit "./" on the command line + firstPrefix = "./" + firstPrefix + } else { + // Don't allow omitting "./" for other imports since node doesn't let you do this either + if r.debugLogs != nil { + r.debugLogs.addNote("Ignoring glob import that doesn't start with \"./\" or \"../\"") + } + r.flushDebugLogs(flushDueToFailure) + return nil, nil + } + } + + // Handle leading directories in the pattern (including "../") + dirPrefix := 0 + for { + slash := strings.IndexByte(firstPrefix[dirPrefix:], '/') + if slash == -1 { + break + } + if star := strings.IndexByte(firstPrefix[dirPrefix:], '*'); star != -1 && slash > star { + break + } + dirPrefix += slash + 1 + } + sourceDir = r.fs.Join(sourceDir, firstPrefix[:dirPrefix]) + + r.mutex.Lock() + defer r.mutex.Unlock() + + // Look up the directory to start from + sourceDirInfo := r.dirInfoCached(sourceDir) + if sourceDirInfo == nil { + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("Failed to find the directory %q", sourceDir)) + } + r.flushDebugLogs(flushDueToFailure) + return nil, nil + } + + // Turn the glob pattern into a regular expression + canMatchOnSlash := false + wasGlobStar := false + sb := strings.Builder{} + sb.WriteByte('^') + for i, part := range importPathPattern { + prefix := part.Prefix + if i == 0 { + prefix = firstPrefix + } + if wasGlobStar && len(prefix) > 0 && prefix[0] == '/' { + prefix = prefix[1:] // Move over the "/" after a globstar + } + sb.WriteString(regexp.QuoteMeta(prefix)) + if part.Wildcard == helpers.GlobAllIncludingSlash { + // It's a globstar, so match zero or more path segments + sb.WriteString("(?:[^/]*(?:/|$))*") + canMatchOnSlash = true + wasGlobStar = true + } else { + // It's not a globstar, so only match one path segment + sb.WriteString("[^/]*") + wasGlobStar = false + } + } + sb.WriteByte('$') + re := regexp.MustCompile(sb.String()) + + // Initialize "results" to a non-nil value to indicate that the glob is valid + results := make(map[string]ResolveResult) + + var visit func(dirInfo *dirInfo, dir string) + visit = func(dirInfo *dirInfo, dir string) { + for _, key := range dirInfo.entries.SortedKeys() { + entry, _ := dirInfo.entries.Get(key) + switch entry.Kind(r.fs) { + case fs.DirEntry: + // To avoid infinite loops, don't follow any symlinks + if canMatchOnSlash && entry.Symlink(r.fs) == "" { + if childDirInfo := r.dirInfoCached(r.fs.Join(dirInfo.absPath, key)); childDirInfo != nil { + visit(childDirInfo, fmt.Sprintf("%s%s/", dir, key)) + } + } + + case fs.FileEntry: + if relPath := dir + key; re.MatchString(relPath) { + var result ResolveResult + + if r.isExternal(r.options.ExternalSettings.PreResolve, relPath, kind) { + result.PathPair = PathPair{Primary: logger.Path{Text: relPath}} + result.IsExternal = true + + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The path %q was marked as external by the user", result.PathPair.Primary.Text)) + } + } else { + absPath := r.fs.Join(dirInfo.absPath, key) + result.PathPair = PathPair{Primary: logger.Path{Text: absPath, Namespace: "file"}} + } + + r.finalizeResolve(&result) + results[relPath] = result + } + } + } + } + + visit(sourceDirInfo, firstPrefix[:dirPrefix]) + + var warning *logger.Msg + if len(results) == 0 { + warning = &logger.Msg{ + ID: logger.MsgID_Bundler_EmptyGlob, + Kind: logger.Warning, + Data: logger.MsgData{Text: fmt.Sprintf("The glob pattern %s did not match any files", prettyPattern)}, + } + } + + r.flushDebugLogs(flushDueToSuccess) + return results, warning +} + func (r *resolverQuery) loadModuleSuffixesForSourceDir(sourceDir string) *dirInfo { // Load TypeScript's "moduleSuffixes" setting from the "tsconfig.json" file // enclosing the source directory if present. Otherwise default to a single @@ -603,124 +764,123 @@ func (r resolverQuery) finalizeResolve(result *ResolveResult) { r.debugLogs.addNote(fmt.Sprintf("The path %q was marked as external by the user", result.PathPair.Primary.Text)) } result.IsExternal = true - return - } - - 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 - pathLookup := strings.ReplaceAll(path.Text, "\\", "/") // Avoid problems with Windows-style slashes - if pkgJSON.sideEffectsMap[pathLookup] { - // Fast path: map lookup - hasSideEffects = true - } else { - // Slow path: glob tests - for _, re := range pkgJSON.sideEffectsRegexps { - if re.MatchString(pathLookup) { - hasSideEffects = true - break + } else { + 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 + pathLookup := strings.ReplaceAll(path.Text, "\\", "/") // Avoid problems with Windows-style slashes + if pkgJSON.sideEffectsMap[pathLookup] { + // Fast path: map lookup + hasSideEffects = true + } else { + // Slow path: glob tests + for _, re := range pkgJSON.sideEffectsRegexps { + if re.MatchString(pathLookup) { + 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)) + 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 } - result.PrimarySideEffectsData = pkgJSON.sideEffectsData } - } - // Also copy over the "type" field - result.ModuleTypeData = pkgJSON.moduleTypeData - } + // Also copy over the "type" field + result.ModuleTypeData = pkgJSON.moduleTypeData + } - // 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.JSX = dirInfo.enclosingTSConfigJSON.JSX - result.JSXImportSource = dirInfo.enclosingTSConfigJSON.JSXImportSource - result.UseDefineForClassFieldsTS = dirInfo.enclosingTSConfigJSON.UseDefineForClassFields - result.UnusedImportFlagsTS = config.UnusedImportFlagsFromTsconfigValues( - dirInfo.enclosingTSConfigJSON.PreserveImportsNotUsedAsValues, - dirInfo.enclosingTSConfigJSON.PreserveValueImports, - ) - result.TSTarget = dirInfo.enclosingTSConfigJSON.TSTarget - if tsAlwaysStrict := dirInfo.enclosingTSConfigJSON.TSAlwaysStrict; tsAlwaysStrict != nil { - result.TSAlwaysStrict = tsAlwaysStrict + // 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 { - // If "alwaysStrict" is absent, it defaults to "strict" instead - result.TSAlwaysStrict = dirInfo.enclosingTSConfigJSON.TSStrict - } - - 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)) + result.JSXFactory = dirInfo.enclosingTSConfigJSON.JSXFactory + result.JSXFragment = dirInfo.enclosingTSConfigJSON.JSXFragmentFactory + result.JSX = dirInfo.enclosingTSConfigJSON.JSX + result.JSXImportSource = dirInfo.enclosingTSConfigJSON.JSXImportSource + result.UseDefineForClassFieldsTS = dirInfo.enclosingTSConfigJSON.UseDefineForClassFields + result.UnusedImportFlagsTS = config.UnusedImportFlagsFromTsconfigValues( + dirInfo.enclosingTSConfigJSON.PreserveImportsNotUsedAsValues, + dirInfo.enclosingTSConfigJSON.PreserveValueImports, + ) + result.TSTarget = dirInfo.enclosingTSConfigJSON.TSTarget + if tsAlwaysStrict := dirInfo.enclosingTSConfigJSON.TSAlwaysStrict; tsAlwaysStrict != nil { + result.TSAlwaysStrict = tsAlwaysStrict + } else { + // If "alwaysStrict" is absent, it defaults to "strict" instead + result.TSAlwaysStrict = dirInfo.enclosingTSConfigJSON.TSStrict } - if result.JSXFragment != nil { - r.debugLogs.addNote(fmt.Sprintf("\"jsxFragment\" is %q due to %q", - strings.Join(result.JSXFragment, "."), + + 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)) + 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 } - path.Text = symlink } } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ed891ef0061..35f9c6c8739 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -128,6 +128,13 @@ func Source(unsupportedJSFeatures compat.JSFeature) logger.Source { throw new Error('Dynamic require of "' + x + '" is not supported') }) + // This is used for glob imports + export var __glob = map => path => { + var fn = map[path] + if (fn) return fn() + throw new Error('Module not found in bundle: ' + path) + } + // For object rest patterns export var __restKey = key => typeof key === 'symbol' ? key : key + '' export var __objRest = (source, exclude) => { diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index f016e6a0d26..f76cd59b6cb 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -962,11 +962,18 @@ func rebuildImpl( options.AbsNodePaths[i] = validatePath(log, realFS, path, "node path") } entryPoints := make([]bundler.EntryPoint, 0, len(buildOpts.EntryPoints)+len(buildOpts.EntryPointsAdvanced)) + hasEntryPointWithWildcard := false for _, ep := range buildOpts.EntryPoints { entryPoints = append(entryPoints, bundler.EntryPoint{InputPath: ep}) + if strings.ContainsRune(ep, '*') { + hasEntryPointWithWildcard = true + } } for _, ep := range buildOpts.EntryPointsAdvanced { entryPoints = append(entryPoints, bundler.EntryPoint{InputPath: ep.InputPath, OutputPath: ep.OutputPath}) + if strings.ContainsRune(ep.InputPath, '*') { + hasEntryPointWithWildcard = true + } } entryPointCount := len(entryPoints) if buildOpts.Stdin != nil { @@ -979,7 +986,7 @@ func rebuildImpl( } } - if options.AbsOutputDir == "" && entryPointCount > 1 { + if options.AbsOutputDir == "" && (entryPointCount > 1 || hasEntryPointWithWildcard) { log.AddError(nil, logger.Range{}, "Must use \"outdir\" when there are multiple input files") } else if options.AbsOutputDir == "" && options.CodeSplitting { diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 38220869c2a..9be5e9acc08 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -44,24 +44,6 @@ let buildTests = { } }, - async errorIfGlob({ esbuild }) { - try { - await esbuild.build({ - entryPoints: ['./src/*.js'], - logLevel: 'silent', - write: false, - }) - throw new Error('Expected build failure'); - } catch (e) { - if (!e.errors || !e.errors[0] || e.errors[0].text !== 'Could not resolve "./src/*.js"' || - e.errors[0].notes[0].text !== 'It looks like you are trying to use glob syntax (i.e. "*") with esbuild. ' + - 'This syntax is typically handled by your shell, and isn\'t handled by esbuild itself. ' + - 'You must expand glob syntax first before passing your paths to esbuild.') { - throw e; - } - } - }, - async mangleCacheBuild({ esbuild }) { var result = await esbuild.build({ stdin: { @@ -792,8 +774,8 @@ export { const names2 = result2.outputFiles.map(x => path.basename(x.path)).sort() // Check that the public path is included in chunk hashes but not asset hashes - assert.deepStrictEqual(names1, ['data-BYATPJRB.bin', 'in-OGEHLZ72.js']) - assert.deepStrictEqual(names2, ['data-BYATPJRB.bin', 'in-IF4VVJK4.js']) + assert.deepStrictEqual(names1, ['data-BYATPJRB.bin', 'in-IN5VRZMW.js']) + assert.deepStrictEqual(names2, ['data-BYATPJRB.bin', 'in-7HV645WS.js']) }, async fileLoaderPublicPath({ esbuild, testDir }) { diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index 0b7215e75e1..d78a841e093 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -929,7 +929,7 @@ let pluginTests = { async virtualEntryPoints({ esbuild, testDir }) { const result = await esbuild.build({ - entryPoints: ['1', '2', 'a<>:"|?*b', 'a/b/c.d.e'], + entryPoints: ['1', '2', 'a<>:"|?b', 'a/b/c.d.e'], bundle: true, write: false, outdir: testDir, @@ -953,7 +953,7 @@ let pluginTests = { assert.strictEqual(result.outputFiles[3].path, path.join(testDir, 'a/b/c.d.js')) assert.strictEqual(result.outputFiles[0].text, `// virtual-ns:input 1\nconsole.log("input 1");\n`) assert.strictEqual(result.outputFiles[1].text, `// virtual-ns:input 2\nconsole.log("input 2");\n`) - assert.strictEqual(result.outputFiles[2].text, `// virtual-ns:input a<>:"|?*b\nconsole.log('input a<>:"|?*b');\n`) + assert.strictEqual(result.outputFiles[2].text, `// virtual-ns:input a<>:"|?b\nconsole.log('input a<>:"|?b');\n`) assert.strictEqual(result.outputFiles[3].text, `// virtual-ns:input a/b/c.d.e\nconsole.log("input a/b/c.d.e");\n`) },