From f70234145bfa505e832eaebe997c937de576c459 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Wed, 30 Dec 2020 21:29:43 -0800 Subject: [PATCH] fix #631: optionally print a build summary --- CHANGELOG.md | 15 ++ Makefile | 14 +- cmd/esbuild/main.go | 1 + internal/logger/logger.go | 223 +++++++++++++++++++++++++++++- internal/logger/logger_windows.go | 12 ++ pkg/cli/cli_impl.go | 64 ++++++++- 6 files changed, 316 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9215bf68a..072a4447a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +* Add a `--summary` flag that prints helpful information after a build ([#631](https://github.com/evanw/esbuild/issues/631)) + + Normally esbuild's CLI doesn't print anything after doing a build if nothing went wrong. This allows esbuild to be used as part of a more complex chain of tools without the output cluttering the terminal. However, sometimes it is nice to have a quick overview in your terminal of what the build just did. You can now add the `--summary` flag when using the CLI and esbuild will print a summary of what the build generated. It looks something like this: + + ``` + $ ./esbuild --summary --bundle src/Three.js --outfile=build/three.js --sourcemap + + build/three.js 1.0mb ⚠️ + build/three.js.map 1.8mb + + ⚡ Done in 43ms + ``` + ## 0.8.27 * Mark `import.meta` as supported in node 10.4+ ([#626](https://github.com/evanw/esbuild/issues/626)) diff --git a/Makefile b/Makefile index 804dc2b2343..224a92e6cc2 100644 --- a/Makefile +++ b/Makefile @@ -442,13 +442,13 @@ demo-three: demo-three-esbuild demo-three-rollup demo-three-webpack demo-three-w demo-three-esbuild: esbuild | demo/three rm -fr demo/three/esbuild - time -p ./esbuild --bundle --global-name=THREE --sourcemap --minify demo/three/src/Three.js --outfile=demo/three/esbuild/Three.esbuild.js + time -p ./esbuild --bundle --summary --global-name=THREE --sourcemap --minify demo/three/src/Three.js --outfile=demo/three/esbuild/Three.esbuild.js du -h demo/three/esbuild/Three.esbuild.js* shasum demo/three/esbuild/Three.esbuild.js* demo-three-eswasm: npm/esbuild-wasm/esbuild.wasm | demo/three rm -fr demo/three/eswasm - time -p ./npm/esbuild-wasm/bin/esbuild --bundle --global-name=THREE \ + time -p ./npm/esbuild-wasm/bin/esbuild --bundle --summary --global-name=THREE \ --sourcemap --minify demo/three/src/Three.js --outfile=demo/three/eswasm/Three.eswasm.js du -h demo/three/eswasm/Three.eswasm.js* shasum demo/three/eswasm/Three.eswasm.js* @@ -528,13 +528,13 @@ bench-three: bench-three-esbuild bench-three-rollup bench-three-webpack bench-th bench-three-esbuild: esbuild | bench/three rm -fr bench/three/esbuild - time -p ./esbuild --bundle --global-name=THREE --sourcemap --minify bench/three/src/entry.js --outfile=bench/three/esbuild/entry.esbuild.js + time -p ./esbuild --bundle --summary --global-name=THREE --sourcemap --minify bench/three/src/entry.js --outfile=bench/three/esbuild/entry.esbuild.js du -h bench/three/esbuild/entry.esbuild.js* shasum bench/three/esbuild/entry.esbuild.js* bench-three-eswasm: npm/esbuild-wasm/esbuild.wasm | bench/three rm -fr bench/three/eswasm - time -p ./npm/esbuild-wasm/bin/esbuild --bundle --global-name=THREE \ + time -p ./npm/esbuild-wasm/bin/esbuild --bundle --summary --global-name=THREE \ --sourcemap --minify bench/three/src/entry.js --outfile=bench/three/eswasm/entry.eswasm.js du -h bench/three/eswasm/entry.eswasm.js* shasum bench/three/eswasm/entry.eswasm.js* @@ -634,7 +634,7 @@ bench-rome: bench-rome-esbuild bench-rome-webpack bench-rome-webpack5 bench-rome bench-rome-esbuild: esbuild | bench/rome bench/rome-verify rm -fr bench/rome/esbuild - time -p ./esbuild --bundle --sourcemap --minify bench/rome/src/entry.ts --outfile=bench/rome/esbuild/rome.esbuild.js --platform=node + time -p ./esbuild --bundle --summary --sourcemap --minify bench/rome/src/entry.ts --outfile=bench/rome/esbuild/rome.esbuild.js --platform=node du -h bench/rome/esbuild/rome.esbuild.js* shasum bench/rome/esbuild/rome.esbuild.js* cd bench/rome-verify && rm -fr esbuild && ROME_CACHE=0 node ../rome/esbuild/rome.esbuild.js bundle packages/rome esbuild @@ -771,7 +771,7 @@ bench-readmin: bench-readmin-esbuild bench-readmin-esbuild: esbuild | bench/readmin rm -fr bench/readmin/esbuild - time -p ./esbuild --bundle --minify --loader:.js=jsx --define:process.env.NODE_ENV='"production"' \ + time -p ./esbuild --bundle --summary --minify --loader:.js=jsx --define:process.env.NODE_ENV='"production"' \ --define:global=window --sourcemap --outfile=bench/readmin/esbuild/main.js bench/readmin/repo/src/index.js echo "$(READMIN_HTML)" > bench/readmin/esbuild/index.html du -h bench/readmin/esbuild/main.js* @@ -780,7 +780,7 @@ bench-readmin-esbuild: esbuild | bench/readmin bench-readmin-eswasm: npm/esbuild-wasm/esbuild.wasm | bench/readmin rm -fr bench/readmin/eswasm time -p ./npm/esbuild-wasm/bin/esbuild \ - --bundle --minify --loader:.js=jsx --define:process.env.NODE_ENV='"production"' \ + --bundle --summary --minify --loader:.js=jsx --define:process.env.NODE_ENV='"production"' \ --define:global=window --sourcemap --outfile=bench/readmin/eswasm/main.js bench/readmin/repo/src/index.js echo "$(READMIN_HTML)" > bench/readmin/eswasm/index.html du -h bench/readmin/eswasm/main.js* diff --git a/cmd/esbuild/main.go b/cmd/esbuild/main.go index 8a2002e7934..459ee887b2e 100644 --- a/cmd/esbuild/main.go +++ b/cmd/esbuild/main.go @@ -47,6 +47,7 @@ var helpText = func(colors logger.Colors) string { --serve=... Start a local HTTP server on this host:port for outputs --sourcemap Emit a source map --splitting Enable code splitting (currently only for esm) + --summary Print some helpful information at the end of a build --target=... Environment target (e.g. es2017, chrome58, firefox57, safari11, edge16, node10, default esnext) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 7672be5563d..dacad4a0db8 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -8,9 +8,11 @@ package logger import ( "fmt" "os" + "runtime" "sort" "strings" "sync" + "time" ) type Log struct { @@ -383,11 +385,18 @@ func PrintMessageToStderr(osArgs []string, msg Msg) { } type Colors struct { - Default string - Bold string - Dim string - Red string - Green string + Default string + Bold string + Dim string + + Red string + Green string + Blue string + + Cyan string + Magenta string + Yellow string + Underline string } @@ -414,13 +423,211 @@ func PrintText(file *os.File, level LogLevel, osArgs []string, callback func(Col colors.Default = colorReset colors.Bold = colorResetBold colors.Dim = colorResetDim + colors.Red = colorRed colors.Green = colorGreen + colors.Blue = colorBlue + + colors.Cyan = colorCyan + colors.Magenta = colorMagenta + colors.Yellow = colorYellow + colors.Underline = colorResetUnderline } writeStringWithColor(file, callback(colors)) } +type SummaryTableEntry struct { + Dir string + Base string + Size string + Bytes int + IsSourceMap bool +} + +// This type is just so we can use Go's native sort function +type SummaryTable []SummaryTableEntry + +func (t SummaryTable) Len() int { return len(t) } +func (t SummaryTable) Swap(i int, j int) { t[i], t[j] = t[j], t[i] } + +func (t SummaryTable) Less(i int, j int) bool { + ti := t[i] + tj := t[j] + + // Sort source maps last + if !ti.IsSourceMap && tj.IsSourceMap { + return true + } + if ti.IsSourceMap && !tj.IsSourceMap { + return false + } + + // Sort by size first + if ti.Bytes > tj.Bytes { + return true + } + if ti.Bytes < tj.Bytes { + return false + } + + // Sort subdirectories first + if strings.HasPrefix(ti.Dir, tj.Dir) { + return true + } + if strings.HasPrefix(tj.Dir, ti.Dir) { + return false + } + + // Sort alphabetically by directory first + if ti.Dir < tj.Dir { + return true + } + if ti.Dir > tj.Dir { + return false + } + + // Then sort alphabetically by file name + return ti.Base < tj.Base +} + +// Show a warning icon next to output files that are 1mb or larger +const sizeWarningThreshold = 1024 * 1024 + +func PrintSummary(osArgs []string, table SummaryTable, start time.Time) { + PrintText(os.Stderr, LevelInfo, osArgs, func(colors Colors) string { + isProbablyWindowsCommandPrompt := false + sb := strings.Builder{} + + // Assume we are running in Windows Command Prompt if we're on Windows. If + // so, we can't use emoji because it won't be supported. Except we can + // still use emoji if the WT_SESSION environment variable is present + // because that means we're running in the new Windows Terminal instead. + if runtime.GOOS == "windows" { + isProbablyWindowsCommandPrompt = true + for _, env := range os.Environ() { + if strings.HasPrefix(env, "WT_SESSION=") { + isProbablyWindowsCommandPrompt = false + break + } + } + } + + if len(table) > 0 { + // Compute the maximum width of the size column + spacingBetweenColumns := 2 + hasSizeWarning := false + maxPath := 0 + maxSize := 0 + for _, entry := range table { + path := len(entry.Dir) + len(entry.Base) + size := len(entry.Size) + spacingBetweenColumns + if path > maxPath { + maxPath = path + } + if size > maxSize { + maxSize = size + } + if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold { + hasSizeWarning = true + } + } + + margin := " " + layoutWidth := GetTerminalInfo(os.Stderr).Width - 2*len(margin) + if hasSizeWarning { + // Add space for the warning icon + layoutWidth -= 2 + } + if layoutWidth > maxPath+maxSize { + layoutWidth = maxPath + maxSize + } + sort.Sort(table) + sb.WriteString("\n") + + wasSourceMap := false + for i, entry := range table { + dir, base := entry.Dir, entry.Base + pathWidth := layoutWidth - maxSize + + // Truncate the path with "..." to fit on one line + if len(dir)+len(base) > pathWidth { + // Trim the directory from the front, leaving the trailing slash + if len(dir) > 0 { + n := pathWidth - len(base) - 3 + if n < 1 { + n = 1 + } + dir = "..." + dir[len(dir)-n:] + } + + // Trim the file name from the back + if len(dir)+len(base) > pathWidth { + n := pathWidth - len(dir) - 3 + if n < 0 { + n = 0 + } + base = base[:n] + "..." + } + } + + spacer := layoutWidth - len(entry.Size) - len(dir) - len(base) + if spacer < 0 { + spacer = 0 + } + + // Print a boundary in between normal files and source map files if + // there was more than one normal file. This improves scannability. + if !wasSourceMap && entry.IsSourceMap && i > 1 { + sb.WriteString("\n") + wasSourceMap = true + } + + // Put a warning next to the size if it's above a certain threshold + sizeColor := colors.Cyan + sizeWarning := "" + if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold { + sizeColor = colors.Yellow + + // Emoji don't work in Windows Command Prompt + if !isProbablyWindowsCommandPrompt { + sizeWarning = " ⚠️" + } + } + + sb.WriteString(fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s\n", + margin, + colors.Dim, + dir, + colors.Bold, + base, + colors.Default, + strings.Repeat(" ", spacer), + sizeColor, + entry.Size, + sizeWarning, + colors.Default, + )) + } + } + + lightningSymbol := "⚡ " + + // Emoji don't work in Windows Command Prompt + if isProbablyWindowsCommandPrompt { + lightningSymbol = "" + } + + sb.WriteString(fmt.Sprintf("\n%s%sDone in %dms%s\n\n", + lightningSymbol, + colors.Green, + time.Since(start).Milliseconds(), + colors.Default, + )) + return sb.String() + }) +} + func NewDeferLog() Log { var msgs SortableMsgs var mutex sync.Mutex @@ -450,9 +657,15 @@ func NewDeferLog() Log { } const colorReset = "\033[0m" + const colorRed = "\033[31m" const colorGreen = "\033[32m" +const colorBlue = "\033[34m" + +const colorCyan = "\033[36m" const colorMagenta = "\033[35m" +const colorYellow = "\033[33m" + const colorResetDim = "\033[0;37m" const colorBold = "\033[1m" const colorResetBold = "\033[0;1m" diff --git a/internal/logger/logger_windows.go b/internal/logger/logger_windows.go index 531419ca7f9..03c4e5cebeb 100644 --- a/internal/logger/logger_windows.go +++ b/internal/logger/logger_windows.go @@ -78,10 +78,22 @@ func writeStringWithColor(file *os.File, text string) { i += len(colorGreen) attributes = FOREGROUND_GREEN + case strings.HasPrefix(text[i:], colorBlue): + i += len(colorBlue) + attributes = FOREGROUND_BLUE + + case strings.HasPrefix(text[i:], colorCyan): + i += len(colorCyan) + attributes = FOREGROUND_GREEN | FOREGROUND_BLUE + case strings.HasPrefix(text[i:], colorMagenta): i += len(colorMagenta) attributes = FOREGROUND_RED | FOREGROUND_BLUE + case strings.HasPrefix(text[i:], colorYellow): + i += len(colorYellow) + attributes = FOREGROUND_RED | FOREGROUND_GREEN + case strings.HasPrefix(text[i:], colorResetDim): i += len(colorResetDim) attributes = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 27aeb928236..7ae5c5d6da3 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -5,9 +5,11 @@ import ( "io/ioutil" "net" "os" + "path/filepath" "sort" "strconv" "strings" + "time" "github.com/evanw/esbuild/internal/cli_helpers" "github.com/evanw/esbuild/internal/logger" @@ -517,8 +519,12 @@ func parseOptionsForRun(osArgs []string) (*api.BuildOptions, *api.TransformOptio } func runImpl(osArgs []string) int { - // Special-case running a server + shouldPrintSummary := false + start := time.Now() + end := 0 + for i, arg := range osArgs { + // Special-case running a server if arg == "--serve" { arg = "--serve=0" } @@ -531,7 +537,17 @@ func runImpl(osArgs []string) int { } return 0 } + + // Filter out the "--summary" flag + if arg == "--summary" { + shouldPrintSummary = true + continue + } + + osArgs[end] = arg + end++ } + osArgs = osArgs[:end] buildOptions, transformOptions, err := parseOptionsForRun(osArgs) @@ -567,6 +583,11 @@ func runImpl(osArgs []string) int { return 1 } + // Print a summary to stderr + if shouldPrintSummary { + printSummary(osArgs, result.OutputFiles, start) + } + case transformOptions != nil: // Read the input from stdin bytes, err := ioutil.ReadAll(os.Stdin) @@ -585,6 +606,11 @@ func runImpl(osArgs []string) int { // Write the output to stdout os.Stdout.Write(result.Code) + // Print a summary to stderr + if shouldPrintSummary { + printSummary(osArgs, nil, start) + } + case err != nil: logger.PrintErrorToStderr(osArgs, err.Error()) return 1 @@ -593,6 +619,42 @@ func runImpl(osArgs []string) int { return 0 } +func printSummary(osArgs []string, outputFiles []api.OutputFile, start time.Time) { + var table logger.SummaryTable = make([]logger.SummaryTableEntry, len(outputFiles)) + + if len(outputFiles) > 0 { + cwd, _ := os.Getwd() + + for i, file := range outputFiles { + path, err := filepath.Rel(cwd, file.Path) + if err != nil { + path = file.Path + } + dir, base := filepath.Split(path) + n := len(file.Contents) + var size string + if n < 1024 { + size = fmt.Sprintf("%db ", n) + } else if n < 1024*1024 { + size = fmt.Sprintf("%.1fkb", float64(n)/(1024)) + } else if n < 1024*1024*1024 { + size = fmt.Sprintf("%.1fmb", float64(n)/(1024*1024)) + } else { + size = fmt.Sprintf("%.1fgb", float64(n)/(1024*1024*1024)) + } + table[i] = logger.SummaryTableEntry{ + Dir: dir, + Base: base, + Size: size, + Bytes: n, + IsSourceMap: strings.HasSuffix(base, ".map"), + } + } + } + + logger.PrintSummary(osArgs, table, start) +} + func serveImpl(serveText string, osArgs []string) error { host := "" portText := serveText