Skip to content

Commit

Permalink
fix #28: add "--pure:name" to mark a pure function
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jul 6, 2020
1 parent 8133132 commit 3d39e8c
Show file tree
Hide file tree
Showing 12 changed files with 111 additions and 16 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Expand Up @@ -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:

Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/esbuild/main.go
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/ast/ast.go
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
38 changes: 29 additions & 9 deletions internal/config/globals.go
Expand Up @@ -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"},
Expand Down Expand Up @@ -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
}

Expand All @@ -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{
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand Down
22 changes: 22 additions & 0 deletions internal/parser/parser.go
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions lib/api-common.ts
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/api-types.ts
Expand Up @@ -18,6 +18,7 @@ export interface CommonOptions {
jsxFactory?: string;
jsxFragment?: string;
define?: { [key: string]: string };
pure?: string[];

color?: boolean;
logLevel?: LogLevel;
Expand Down
8 changes: 6 additions & 2 deletions pkg/api/api.go
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions pkg/api/api_impl.go
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions pkg/cli/cli_impl.go
Expand Up @@ -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, '=')
Expand Down
8 changes: 8 additions & 0 deletions scripts/js-api-tests.js
Expand Up @@ -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'),
Expand Down

0 comments on commit 3d39e8c

Please sign in to comment.