Files
esbuild/pkg/api/api_impl.go
2020-07-24 23:00:42 -07:00

648 lines
19 KiB
Go

package api
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/bundler"
"github.com/evanw/esbuild/internal/compat"
"github.com/evanw/esbuild/internal/config"
"github.com/evanw/esbuild/internal/fs"
"github.com/evanw/esbuild/internal/lexer"
"github.com/evanw/esbuild/internal/logging"
"github.com/evanw/esbuild/internal/parser"
"github.com/evanw/esbuild/internal/resolver"
)
func validatePlatform(value Platform) config.Platform {
switch value {
case PlatformBrowser:
return config.PlatformBrowser
case PlatformNode:
return config.PlatformNode
default:
panic("Invalid platform")
}
}
func validateFormat(value Format) config.Format {
switch value {
case FormatDefault:
return config.FormatPreserve
case FormatIIFE:
return config.FormatIIFE
case FormatCommonJS:
return config.FormatCommonJS
case FormatESModule:
return config.FormatESModule
default:
panic("Invalid format")
}
}
func validateSourceMap(value SourceMap) config.SourceMap {
switch value {
case SourceMapNone:
return config.SourceMapNone
case SourceMapLinked:
return config.SourceMapLinkedWithComment
case SourceMapInline:
return config.SourceMapInline
case SourceMapExternal:
return config.SourceMapExternalWithoutComment
default:
panic("Invalid source map")
}
}
func validateColor(value StderrColor) logging.StderrColor {
switch value {
case ColorIfTerminal:
return logging.ColorIfTerminal
case ColorNever:
return logging.ColorNever
case ColorAlways:
return logging.ColorAlways
default:
panic("Invalid color")
}
}
func validateLogLevel(value LogLevel) logging.LogLevel {
switch value {
case LogLevelInfo:
return logging.LevelInfo
case LogLevelWarning:
return logging.LevelWarning
case LogLevelError:
return logging.LevelError
default:
panic("Invalid log level")
}
}
func validateStrict(value StrictOptions) config.StrictOptions {
return config.StrictOptions{
NullishCoalescing: value.NullishCoalescing,
ClassFields: value.ClassFields,
}
}
func validateLoader(value Loader) config.Loader {
switch value {
case LoaderJS:
return config.LoaderJS
case LoaderJSX:
return config.LoaderJSX
case LoaderTS:
return config.LoaderTS
case LoaderTSX:
return config.LoaderTSX
case LoaderJSON:
return config.LoaderJSON
case LoaderText:
return config.LoaderText
case LoaderBase64:
return config.LoaderBase64
case LoaderDataURL:
return config.LoaderDataURL
case LoaderFile:
return config.LoaderFile
case LoaderBinary:
return config.LoaderBinary
default:
panic("Invalid loader")
}
}
func validateEngine(value EngineName) compat.Engine {
switch value {
case EngineChrome:
return compat.Chrome
case EngineEdge:
return compat.Edge
case EngineFirefox:
return compat.Firefox
case EngineIOS:
return compat.IOS
case EngineNode:
return compat.Node
case EngineSafari:
return compat.Safari
default:
panic("Invalid loader")
}
}
var versionRegex = regexp.MustCompile(`^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?$`)
func validateFeatures(log logging.Log, target Target, engines []Engine) compat.Feature {
constraints := make(map[compat.Engine][]int)
switch target {
case ES5:
constraints[compat.ES] = []int{5}
case ES2015:
constraints[compat.ES] = []int{2015}
case ES2016:
constraints[compat.ES] = []int{2016}
case ES2017:
constraints[compat.ES] = []int{2017}
case ES2018:
constraints[compat.ES] = []int{2018}
case ES2019:
constraints[compat.ES] = []int{2019}
case ES2020:
constraints[compat.ES] = []int{2020}
case ESNext:
default:
panic("Invalid target")
}
for _, engine := range engines {
if match := versionRegex.FindStringSubmatch(engine.Version); match != nil {
if major, err := strconv.Atoi(match[1]); err == nil {
version := []int{major}
if minor, err := strconv.Atoi(match[2]); err == nil {
version = append(version, minor)
}
if patch, err := strconv.Atoi(match[3]); err == nil {
version = append(version, patch)
}
switch engine.Name {
case EngineChrome:
constraints[compat.Chrome] = version
case EngineEdge:
constraints[compat.Edge] = version
case EngineFirefox:
constraints[compat.Firefox] = version
case EngineIOS:
constraints[compat.IOS] = version
case EngineNode:
constraints[compat.Node] = version
case EngineSafari:
constraints[compat.Safari] = version
default:
panic("Invalid engine name")
}
continue
}
}
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid version: %q", engine.Version))
}
return compat.UnsupportedFeatures(constraints)
}
func validateExternals(log logging.Log, fs fs.FS, paths []string) config.ExternalModules {
result := config.ExternalModules{
NodeModules: make(map[string]bool),
AbsPaths: make(map[string]bool),
}
for _, path := range paths {
if resolver.IsPackagePath(path) {
result.NodeModules[path] = true
} else if absPath := validatePath(log, fs, path); absPath != "" {
result.AbsPaths[absPath] = true
}
}
return result
}
func isValidExtension(ext string) bool {
return len(ext) >= 2 && ext[0] == '.' && ext[len(ext)-1] != '.'
}
func validateResolveExtensions(log logging.Log, order []string) []string {
if order == nil {
return []string{".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js", ".json"}
}
for _, ext := range order {
if !isValidExtension(ext) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid file extension: %q", ext))
}
}
return order
}
func validateLoaders(log logging.Log, loaders map[string]Loader) map[string]config.Loader {
result := bundler.DefaultExtensionToLoaderMap()
if loaders != nil {
for ext, loader := range loaders {
if !isValidExtension(ext) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid file extension: %q", ext))
}
result[ext] = validateLoader(loader)
}
}
return result
}
func validateJSX(log logging.Log, text string, name string) []string {
if text == "" {
return nil
}
parts := strings.Split(text, ".")
for _, part := range parts {
if !lexer.IsIdentifier(part) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid JSX %s: %q", name, text))
return nil
}
}
return parts
}
func validateDefines(log logging.Log, defines map[string]string, pureFns []string) *config.ProcessedDefines {
if len(defines) == 0 && len(pureFns) == 0 {
return nil
}
rawDefines := make(map[string]config.DefineData)
for key, value := range defines {
// The key must be a dot-separated identifier list
for _, part := range strings.Split(key, ".") {
if !lexer.IsIdentifier(part) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid define key: %q", key))
continue
}
}
// Allow substituting for an identifier
if lexer.IsIdentifier(value) {
if _, ok := lexer.Keywords()[value]; !ok {
name := value // The closure must close over a variable inside the loop
rawDefines[key] = config.DefineData{
DefineFunc: func(findSymbol config.FindSymbol) ast.E {
return &ast.EIdentifier{Ref: findSymbol(name)}
},
}
continue
}
}
// Parse the value as JSON
source := logging.Source{Contents: value}
expr, ok := parser.ParseJSON(logging.NewDeferLog(), source, parser.ParseJSONOptions{})
if !ok {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid define value: %q", value))
continue
}
// Only allow atoms for now
var fn config.DefineFunc
switch e := expr.Data.(type) {
case *ast.ENull:
fn = func(config.FindSymbol) ast.E { return &ast.ENull{} }
case *ast.EBoolean:
fn = func(config.FindSymbol) ast.E { return &ast.EBoolean{Value: e.Value} }
case *ast.EString:
fn = func(config.FindSymbol) ast.E { return &ast.EString{Value: e.Value} }
case *ast.ENumber:
fn = func(config.FindSymbol) ast.E { return &ast.ENumber{Value: e.Value} }
default:
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid define value: %q", value))
continue
}
rawDefines[key] = config.DefineData{DefineFunc: fn}
}
for _, key := range pureFns {
// The key must be a dot-separated identifier list
for _, part := range strings.Split(key, ".") {
if !lexer.IsIdentifier(part) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid pure function: %q", key))
continue
}
}
// Merge with any previously-specified defines
define := rawDefines[key]
define.CallCanBeUnwrappedIfUnused = true
rawDefines[key] = define
}
// Processing defines is expensive. Process them once here so the same object
// can be shared between all parsers we create using these arguments.
processed := config.ProcessDefines(rawDefines)
return &processed
}
func validatePath(log logging.Log, fs fs.FS, relPath string) string {
if relPath == "" {
return ""
}
absPath, ok := fs.Abs(relPath)
if !ok {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid path: %s", relPath))
}
return absPath
}
func validateOutputExtensions(log logging.Log, outExtensions map[string]string) map[string]string {
result := make(map[string]string)
for key, value := range outExtensions {
if key != ".js" {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid output extension: %q (valid: .js)", key))
}
if !isValidExtension(value) {
log.AddError(nil, ast.Loc{}, fmt.Sprintf("Invalid output extension: %q", value))
}
result[key] = value
}
return result
}
func messagesOfKind(kind logging.MsgKind, msgs []logging.Msg) []Message {
var filtered []Message
for _, msg := range msgs {
if msg.Kind == kind {
var location *Location
if msg.Location != nil {
loc := msg.Location
location = &Location{
File: loc.File,
Line: loc.Line,
Column: loc.Column,
Length: loc.Length,
LineText: loc.LineText,
}
}
filtered = append(filtered, Message{
Text: msg.Text,
Location: location,
})
}
}
return filtered
}
////////////////////////////////////////////////////////////////////////////////
// Build API
func buildImpl(buildOpts BuildOptions) BuildResult {
var log logging.Log
if buildOpts.LogLevel == LogLevelSilent {
log = logging.NewDeferLog()
} else {
log = logging.NewStderrLog(logging.StderrOptions{
IncludeSource: true,
ErrorLimit: buildOpts.ErrorLimit,
Color: validateColor(buildOpts.Color),
LogLevel: validateLogLevel(buildOpts.LogLevel),
})
}
// Convert and validate the buildOpts
realFS := fs.RealFS()
options := config.Options{
UnsupportedFeatures: validateFeatures(log, buildOpts.Target, buildOpts.Engines),
Strict: validateStrict(buildOpts.Strict),
JSX: config.JSXOptions{
Factory: validateJSX(log, buildOpts.JSXFactory, "factory"),
Fragment: validateJSX(log, buildOpts.JSXFragment, "fragment"),
},
Defines: validateDefines(log, buildOpts.Defines, buildOpts.PureFunctions),
Platform: validatePlatform(buildOpts.Platform),
SourceMap: validateSourceMap(buildOpts.Sourcemap),
MangleSyntax: buildOpts.MinifySyntax,
RemoveWhitespace: buildOpts.MinifyWhitespace,
MinifyIdentifiers: buildOpts.MinifyIdentifiers,
ModuleName: buildOpts.GlobalName,
IsBundling: buildOpts.Bundle,
CodeSplitting: buildOpts.Splitting,
OutputFormat: validateFormat(buildOpts.Format),
AbsOutputFile: validatePath(log, realFS, buildOpts.Outfile),
AbsOutputDir: validatePath(log, realFS, buildOpts.Outdir),
AbsMetadataFile: validatePath(log, realFS, buildOpts.Metafile),
OutputExtensions: validateOutputExtensions(log, buildOpts.OutExtensions),
ExtensionToLoader: validateLoaders(log, buildOpts.Loaders),
ExtensionOrder: validateResolveExtensions(log, buildOpts.ResolveExtensions),
ExternalModules: validateExternals(log, realFS, buildOpts.Externals),
TsConfigOverride: validatePath(log, realFS, buildOpts.Tsconfig),
}
entryPaths := make([]string, len(buildOpts.EntryPoints))
for i, entryPoint := range buildOpts.EntryPoints {
entryPaths[i] = validatePath(log, realFS, entryPoint)
}
entryPathCount := len(buildOpts.EntryPoints)
if buildOpts.Stdin != nil {
entryPathCount++
options.Stdin = &config.StdinInfo{
Loader: validateLoader(buildOpts.Stdin.Loader),
Contents: buildOpts.Stdin.Contents,
SourceFile: buildOpts.Stdin.Sourcefile,
AbsResolveDir: validatePath(log, realFS, buildOpts.Stdin.ResolveDir),
}
}
if options.AbsOutputDir == "" && entryPathCount > 1 {
log.AddError(nil, ast.Loc{},
"Must use \"outdir\" when there are multiple input files")
} else if options.AbsOutputDir == "" && options.CodeSplitting {
log.AddError(nil, ast.Loc{},
"Must use \"outdir\" when code splitting is enabled")
} else if options.AbsOutputFile != "" && options.AbsOutputDir != "" {
log.AddError(nil, ast.Loc{}, "Cannot use both \"outfile\" and \"outdir\"")
} else if options.AbsOutputFile != "" {
// If the output file is specified, use it to derive the output directory
options.AbsOutputDir = realFS.Dir(options.AbsOutputFile)
} else if options.AbsOutputDir == "" {
options.WriteToStdout = true
// Forbid certain features when writing to stdout
if options.SourceMap != config.SourceMapNone && options.SourceMap != config.SourceMapInline {
log.AddError(nil, ast.Loc{}, "Cannot use an external source map without an output path")
}
if options.AbsMetadataFile != "" {
log.AddError(nil, ast.Loc{}, "Cannot use \"metafile\" without an output path")
}
for _, loader := range options.ExtensionToLoader {
if loader == config.LoaderFile {
log.AddError(nil, ast.Loc{}, "Cannot use the \"file\" loader without an output path")
break
}
}
// Use the current directory as the output directory instead of an empty
// string because external modules with relative paths need a base directory.
options.AbsOutputDir = realFS.Cwd()
}
if !options.IsBundling {
// Disallow bundle-only options when not bundling
if options.OutputFormat != config.FormatPreserve {
log.AddError(nil, ast.Loc{}, "Cannot use \"format\" without \"bundle\"")
}
if len(options.ExternalModules.NodeModules) > 0 || len(options.ExternalModules.AbsPaths) > 0 {
log.AddError(nil, ast.Loc{}, "Cannot use \"external\" without \"bundle\"")
}
} else if options.OutputFormat == config.FormatPreserve {
// If the format isn't specified, set the default format using the platform
switch options.Platform {
case config.PlatformBrowser:
options.OutputFormat = config.FormatIIFE
case config.PlatformNode:
options.OutputFormat = config.FormatCommonJS
}
}
// Code splitting is experimental and currently only enabled for ES6 modules
if options.CodeSplitting && options.OutputFormat != config.FormatESModule {
log.AddError(nil, ast.Loc{}, "Splitting currently only works with the \"esm\" format")
}
var outputFiles []OutputFile
// Stop now if there were errors
if !log.HasErrors() {
// Scan over the bundle
resolver := resolver.NewResolver(realFS, log, options)
bundle := bundler.ScanBundle(log, realFS, resolver, entryPaths, options)
// Stop now if there were errors
if !log.HasErrors() {
// Compile the bundle
results := bundle.Compile(log, options)
// Return the results
outputFiles = make([]OutputFile, len(results))
for i, result := range results {
if options.WriteToStdout {
result.AbsPath = "<stdout>"
}
outputFiles[i] = OutputFile{
Path: result.AbsPath,
Contents: result.Contents,
}
}
if buildOpts.Write {
// Special-case writing to stdout
if options.WriteToStdout {
if len(outputFiles) != 1 {
log.AddError(nil, ast.Loc{}, fmt.Sprintf(
"Internal error: did not expect to generate %d files when writing to stdout", len(outputFiles)))
} else if _, err := os.Stdout.Write(outputFiles[0].Contents); err != nil {
log.AddError(nil, ast.Loc{}, fmt.Sprintf(
"Failed to write to stdout: %s", err.Error()))
}
} else {
for _, outputFile := range outputFiles {
if err := os.MkdirAll(filepath.Dir(outputFile.Path), 0755); err != nil {
log.AddError(nil, ast.Loc{}, fmt.Sprintf(
"Failed to create output directory: %s", err.Error()))
} else if err := ioutil.WriteFile(outputFile.Path, outputFile.Contents, 0644); err != nil {
log.AddError(nil, ast.Loc{}, fmt.Sprintf(
"Failed to write to output file: %s", err.Error()))
}
}
}
}
}
}
msgs := log.Done()
return BuildResult{
Errors: messagesOfKind(logging.Error, msgs),
Warnings: messagesOfKind(logging.Warning, msgs),
OutputFiles: outputFiles,
}
}
////////////////////////////////////////////////////////////////////////////////
// Transform API
func transformImpl(input string, transformOpts TransformOptions) TransformResult {
var log logging.Log
if transformOpts.LogLevel == LogLevelSilent {
log = logging.NewDeferLog()
} else {
log = logging.NewStderrLog(logging.StderrOptions{
IncludeSource: true,
ErrorLimit: transformOpts.ErrorLimit,
Color: validateColor(transformOpts.Color),
LogLevel: validateLogLevel(transformOpts.LogLevel),
})
}
// Convert and validate the transformOpts
options := config.Options{
UnsupportedFeatures: validateFeatures(log, transformOpts.Target, transformOpts.Engines),
Strict: validateStrict(transformOpts.Strict),
JSX: config.JSXOptions{
Factory: validateJSX(log, transformOpts.JSXFactory, "factory"),
Fragment: validateJSX(log, transformOpts.JSXFragment, "fragment"),
},
Defines: validateDefines(log, transformOpts.Defines, transformOpts.PureFunctions),
SourceMap: validateSourceMap(transformOpts.Sourcemap),
MangleSyntax: transformOpts.MinifySyntax,
RemoveWhitespace: transformOpts.MinifyWhitespace,
MinifyIdentifiers: transformOpts.MinifyIdentifiers,
AbsOutputFile: transformOpts.Sourcefile + "-out",
Stdin: &config.StdinInfo{
Loader: validateLoader(transformOpts.Loader),
Contents: input,
SourceFile: transformOpts.Sourcefile,
},
}
if options.SourceMap == config.SourceMapLinkedWithComment {
// Linked source maps don't make sense because there's no output file name
log.AddError(nil, ast.Loc{}, "Cannot transform with linked source maps")
}
if options.SourceMap != config.SourceMapNone && options.Stdin.SourceFile == "" {
log.AddError(nil, ast.Loc{},
"Must use \"sourcefile\" with \"sourcemap\" to set the original file name")
}
var results []bundler.OutputFile
// Stop now if there were errors
if !log.HasErrors() {
// Scan over the bundle
mockFS := fs.MockFS(make(map[string]string))
resolver := resolver.NewResolver(mockFS, log, options)
bundle := bundler.ScanBundle(log, mockFS, resolver, nil, options)
// Stop now if there were errors
if !log.HasErrors() {
// Compile the bundle
results = bundle.Compile(log, options)
}
}
// Return the results
var js []byte
var jsSourceMap []byte
// Unpack the JavaScript file and the source map file
if len(results) == 1 {
js = results[0].Contents
} else if len(results) == 2 {
a, b := results[0], results[1]
if a.AbsPath == b.AbsPath+".map" {
jsSourceMap, js = a.Contents, b.Contents
} else if a.AbsPath+".map" == b.AbsPath {
js, jsSourceMap = a.Contents, b.Contents
}
}
msgs := log.Done()
return TransformResult{
Errors: messagesOfKind(logging.Error, msgs),
Warnings: messagesOfKind(logging.Warning, msgs),
JS: js,
JSSourceMap: jsSourceMap,
}
}