diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d32e459495..05aeb4c51fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * Support `@__PURE__` annotations for tree shaking - You can now annotate call expressions and new expressions with a `/* @__PURE__ */ comment, which tells esbuild that the function call is allowed to be removed if the result is not used. This is a convention from other tools (e.g. UglifyJS and Rollup). + You can now annotate call expressions and new expressions with a `/* @__PURE__ */` comment, which tells esbuild that the function call is allowed to be removed if the result is not used. This is a convention from other tools (e.g. UglifyJS and Rollup). For example, the code below will now be completely removed during bundling if the `fib` variable is never used. The initializer is a function call and esbuild cannot determine that it has no side effects, but the annotation forces esbuild to consider it removable anyway: @@ -22,6 +22,10 @@ })() ``` +* Add `--pure:name` to annotate calls to globals ([#28](https://github.com/evanw/esbuild/issues/28)) + + This flag makes calls to the named function behave as if that call was prefixed by `/* @__PURE__ */`. For example, `--pure:console.log` means calls to `console.log()` will behave as if they were calls to `/* @__PURE__ */ console.log()` instead. This means when `--minify` is active, the calls will be removed as long as the return value is unused (any function arguments with side effects will be kept, however). + ## 0.5.21 * Binaries for FreeBSD ([#217](https://github.com/evanw/esbuild/pull/217)) diff --git a/README.md b/README.md index 6c066a02d5f..c39b2d29704 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,7 @@ Advanced options: --resolve-extensions=... A comma-separated list of implicit extensions --metafile=... Write metadata about the build to a JSON file --strict Transforms handle edge cases but have more overhead + --pure=N Mark the name N as a pure function for tree shaking --trace=... Write a CPU trace to this file --cpuprofile=... Write a CPU profile to this file diff --git a/cmd/esbuild/main.go b/cmd/esbuild/main.go index 6eeef0a2615..ab406883a55 100644 --- a/cmd/esbuild/main.go +++ b/cmd/esbuild/main.go @@ -51,6 +51,7 @@ Advanced options: --resolve-extensions=... A comma-separated list of implicit extensions --metafile=... Write metadata about the build to a JSON file --strict Transforms handle edge cases but have more overhead + --pure=N Mark the name N as a pure function for tree shaking --trace=... Write a CPU trace to this file --cpuprofile=... Write a CPU profile to this file diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 6e3af445c9a..e76e83b7860 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -447,6 +447,11 @@ type EDot struct { // If true, this property access is known to be free of side-effects. That // means it can be removed if the resulting value isn't used. CanBeRemovedIfUnused bool + + // If true, this property access is a function that, when called, can be + // unwrapped if the resulting value is unused. Unwrapping means discarding + // the call target but keeping any arguments with side effects. + CallCanBeUnwrappedIfUnused bool } type EIndex struct { @@ -476,6 +481,11 @@ type EIdentifier struct { // not have side effects when referenced. This is used to allow the removal // of known globals such as "Object" if they aren't used. CanBeRemovedIfUnused bool + + // If true, this identifier represents a function that, when called, can be + // unwrapped if the resulting value is unused. Unwrapping means discarding + // the call target but keeping any arguments with side effects. + CallCanBeUnwrappedIfUnused bool } // This is similar to an EIdentifier but it represents a reference to an ES6 diff --git a/internal/config/globals.go b/internal/config/globals.go index 0af079f4a16..818c5b12cad 100644 --- a/internal/config/globals.go +++ b/internal/config/globals.go @@ -3,11 +3,14 @@ package config import ( "math" "strings" + "sync" "github.com/evanw/esbuild/internal/ast" ) +var processedGlobalsMutex sync.Mutex var processedGlobals *ProcessedDefines + var knownGlobals = [][]string{ // These global identifiers should exist in all JavaScript environments {"Array"}, @@ -113,9 +116,17 @@ type DefineFunc func(FindSymbol) ast.E type DefineData struct { DefineFunc DefineFunc + + // True if a call to this value is known to not have any side effects. For + // example, a bare call to "Object()" can be removed because it does not + // have any observable side effects. + CallCanBeUnwrappedIfUnused bool } func mergeDefineData(old DefineData, new DefineData) DefineData { + if old.CallCanBeUnwrappedIfUnused { + new.CallCanBeUnwrappedIfUnused = true + } return new } @@ -136,9 +147,14 @@ type ProcessedDefines struct { // slows down our benchmarks. func ProcessDefines(userDefines map[string]DefineData) ProcessedDefines { // Optimization: reuse known globals if there are no user-specified defines - hasUserDefines := userDefines == nil || len(userDefines) == 0 - if !hasUserDefines && processedGlobals != nil { - return *processedGlobals + hasUserDefines := len(userDefines) != 0 + if !hasUserDefines { + processedGlobalsMutex.Lock() + if processedGlobals != nil { + defer processedGlobalsMutex.Unlock() + return *processedGlobals + } + processedGlobalsMutex.Unlock() } result := ProcessedDefines{ @@ -173,12 +189,12 @@ func ProcessDefines(userDefines map[string]DefineData) ProcessedDefines { // Then copy the user-specified defines in afterwards, which will overwrite // any known globals above. - for k, v := range userDefines { - parts := strings.Split(k, ".") + for key, data := range userDefines { + parts := strings.Split(key, ".") // Identifier defines are special-cased if len(parts) == 1 { - result.IdentifierDefines[k] = mergeDefineData(result.IdentifierDefines[k], v) + result.IdentifierDefines[key] = mergeDefineData(result.IdentifierDefines[key], data) continue } @@ -190,21 +206,25 @@ func ProcessDefines(userDefines map[string]DefineData) ProcessedDefines { for i, define := range dotDefines { if arePartsEqual(parts, define.Parts) { define := &dotDefines[i] - define.Data = mergeDefineData(define.Data, v) + define.Data = mergeDefineData(define.Data, data) found = true break } } if !found { - dotDefines = append(dotDefines, DotDefine{Parts: parts, Data: v}) + dotDefines = append(dotDefines, DotDefine{Parts: parts, Data: data}) } result.DotDefines[tail] = dotDefines } // Potentially cache the result for next time if !hasUserDefines { - processedGlobals = &result + processedGlobalsMutex.Lock() + defer processedGlobalsMutex.Unlock() + if processedGlobals == nil { + processedGlobals = &result + } } return result } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 4d53d981900..2027d4fab93 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -6678,6 +6678,11 @@ func (p *parser) visitExprInOut(expr ast.Expr, in exprIn) (ast.Expr, exprOut) { } } + // Copy the call side effect flag over in case this expression is called + if data.CallCanBeUnwrappedIfUnused { + e.CallCanBeUnwrappedIfUnused = true + } + // All identifier defines that don't have user-specified replacements // are known to be side-effect free. Mark them as such if we get here. e.CanBeRemovedIfUnused = true @@ -7219,6 +7224,11 @@ func (p *parser) visitExprInOut(expr ast.Expr, in exprIn) (ast.Expr, exprOut) { return p.valueForDefine(expr.Loc, in.assignTarget, define.Data.DefineFunc), exprOut{} } + // Copy the call side effect flag over in case this expression is called + if define.Data.CallCanBeUnwrappedIfUnused { + e.CallCanBeUnwrappedIfUnused = true + } + // All dot defines that don't have user-specified replacements are // known to be side-effect free. Mark them as such if we get here. e.CanBeRemovedIfUnused = true @@ -7372,6 +7382,18 @@ func (p *parser) visitExprInOut(expr ast.Expr, in exprIn) (ast.Expr, exprOut) { } } + // Copy the call side effect flag over if this is a known target + switch t := target.Data.(type) { + case *ast.EIdentifier: + if t.CallCanBeUnwrappedIfUnused { + e.CanBeUnwrappedIfUnused = true + } + case *ast.EDot: + if t.CallCanBeUnwrappedIfUnused { + e.CanBeUnwrappedIfUnused = true + } + } + // Lower optional chaining if we're the top of the chain containsOptionalChain := e.OptionalChain != ast.OptionalChainNone if containsOptionalChain && !in.hasChainParent { diff --git a/lib/api-common.ts b/lib/api-common.ts index 3fb9e95be6a..8f04e84a702 100644 --- a/lib/api-common.ts +++ b/lib/api-common.ts @@ -13,6 +13,7 @@ function pushCommonFlags(flags: string[], options: types.CommonOptions, isTTY: b if (options.jsxFactory) flags.push(`--jsx-factory=${options.jsxFactory}`); if (options.jsxFragment) flags.push(`--jsx-fragment=${options.jsxFragment}`); if (options.define) for (let key in options.define) flags.push(`--define:${key}=${options.define[key]}`); + if (options.pure) for (let fn of options.pure) flags.push(`--pure:${fn}`); if (options.color) flags.push(`--color=${options.color}`); else if (isTTY) flags.push(`--color=true`); // This is needed to fix "execFileSync" which buffers stderr diff --git a/lib/api-types.ts b/lib/api-types.ts index aa4a1506995..42ed370276c 100644 --- a/lib/api-types.ts +++ b/lib/api-types.ts @@ -18,6 +18,7 @@ export interface CommonOptions { jsxFactory?: string; jsxFragment?: string; define?: { [key: string]: string }; + pure?: string[]; color?: boolean; logLevel?: LogLevel; diff --git a/pkg/api/api.go b/pkg/api/api.go index 04c24336b9d..1bdc1ab8ca4 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -183,7 +183,9 @@ type BuildOptions struct { JSXFactory string JSXFragment string - Defines map[string]string + + Defines map[string]string + PureFunctions []string GlobalName string Bundle bool @@ -234,7 +236,9 @@ type TransformOptions struct { JSXFactory string JSXFragment string - Defines map[string]string + + Defines map[string]string + PureFunctions []string Sourcefile string Loader Loader diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index afd38712941..e12f540876a 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -188,8 +188,8 @@ func validateJSX(log logging.Log, text string, name string) []string { return parts } -func validateDefines(log logging.Log, defines map[string]string) *config.ProcessedDefines { - if len(defines) == 0 { +func validateDefines(log logging.Log, defines map[string]string, pureFns []string) *config.ProcessedDefines { + if len(defines) == 0 && len(pureFns) == 0 { return nil } @@ -244,6 +244,21 @@ func validateDefines(log logging.Log, defines map[string]string) *config.Process 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) @@ -325,7 +340,7 @@ func buildImpl(buildOpts BuildOptions) BuildResult { Factory: validateJSX(log, buildOpts.JSXFactory, "factory"), Fragment: validateJSX(log, buildOpts.JSXFragment, "fragment"), }, - Defines: validateDefines(log, buildOpts.Defines), + Defines: validateDefines(log, buildOpts.Defines, buildOpts.PureFunctions), Platform: validatePlatform(buildOpts.Platform), SourceMap: validateSourceMap(buildOpts.Sourcemap), MangleSyntax: buildOpts.MinifySyntax, @@ -459,7 +474,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult Factory: validateJSX(log, transformOpts.JSXFactory, "factory"), Fragment: validateJSX(log, transformOpts.JSXFragment, "fragment"), }, - Defines: validateDefines(log, transformOpts.Defines), + Defines: validateDefines(log, transformOpts.Defines, transformOpts.PureFunctions), SourceMap: validateSourceMap(transformOpts.Sourcemap), MangleSyntax: transformOpts.MinifySyntax, RemoveWhitespace: transformOpts.MinifyWhitespace, diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index ee5f2664e35..86d54c5fd7b 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -123,6 +123,14 @@ func parseOptionsImpl(osArgs []string, buildOpts *api.BuildOptions, transformOpt transformOpts.Defines[value[:equals]] = value[equals+1:] } + case strings.HasPrefix(arg, "--pure:"): + value := arg[len("--pure:"):] + if buildOpts != nil { + buildOpts.PureFunctions = append(buildOpts.PureFunctions, value) + } else { + transformOpts.PureFunctions = append(transformOpts.PureFunctions, value) + } + case strings.HasPrefix(arg, "--loader:") && buildOpts != nil: value := arg[len("--loader:"):] equals := strings.IndexByte(value, '=') diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 09ef011c78e..238adebff12 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -301,6 +301,14 @@ let transformTests = { assert.strictEqual(js, `a !== null && a !== void 0 ? a : b;\n`) }, + async pureCallConsoleLog({ service }) { + const { js: js1 } = await service.transform(`console.log(123, foo)`, { minifySyntax: true, pure: [] }) + assert.strictEqual(js1, `console.log(123, foo);\n`) + + const { js: js2 } = await service.transform(`console.log(123, foo)`, { minifySyntax: true, pure: ['console.log'] }) + assert.strictEqual(js2, `foo;\n`) + }, + // Future syntax forAwait: ({ service }) => futureSyntax(service, 'async function foo() { for await (let x of y) {} }', 'es2017', 'es2018'), bigInt: ({ service }) => futureSyntax(service, '123n', 'es2019', 'es2020'),