Skip to content

Commit

Permalink
fix #3170: add --line-limit= to limit long lines
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jun 21, 2023
1 parent f0b5803 commit 7d5df10
Show file tree
Hide file tree
Showing 13 changed files with 402 additions and 51 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

* Add a `--line-limit=` flag to limit line length ([#3170](https://github.com/evanw/esbuild/issues/3170))

Long lines are common in minified code. However, many tools and text editors can't handle long lines. This release introduces the `--line-limit=` flag to tell esbuild to wrap lines longer than the provided number of bytes. For example, `--line-limit=80` tells esbuild to insert a newline soon after a given line reaches 80 bytes in length. This setting applies to both JavaScript and CSS, and works even when minification is disabled. Note that turning this setting on will make your files bigger, as the extra newlines take up additional space in the file (even after gzip compression).

## 0.18.6

* Fix tree-shaking of classes with decorators ([#3164](https://github.com/evanw/esbuild/issues/3164))
Expand Down
1 change: 1 addition & 0 deletions cmd/esbuild/main.go
Expand Up @@ -90,6 +90,7 @@ var helpText = func(colors logger.Colors) string {
--legal-comments=... Where to place legal comments (none | inline |
eof | linked | external, default eof when bundling
and inline otherwise)
--line-limit=... Lines longer than this will be wrap onto a new line
--log-level=... Disable logging (verbose | debug | info | warning |
error | silent, default info)
--log-limit=... Maximum message count or 0 to disable (default 6)
Expand Down
81 changes: 81 additions & 0 deletions internal/bundler_tests/bundler_default_test.go
Expand Up @@ -8275,3 +8275,84 @@ NOTE: You can mark the path "node_modules/fflate" as external to exclude it from
`,
})
}

func TestLineLimitNotMinified(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/script.jsx": `
export const SignUpForm = (props) => {
return <p class="signup">
<label>Username: <input class="username" type="text"/></label>
<label>Password: <input class="password" type="password"/></label>
<div class="primary disabled">
{props.buttonText}
</div>
<small>By signing up, you are agreeing to our <a href="/tos/">terms of service</a>.</small>
</p>
}
`,
"/style.css": `
body.light-mode.new-user-segment:not(.logged-in) .signup,
body.light-mode.new-user-segment:not(.logged-in) .login {
font: 10px/12px 'Font 1', 'Font 2', 'Font 3', 'Font 4', sans-serif;
user-select: none;
color: var(--fg, rgba(11, 22, 33, 0.5));
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sb` +
`nM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxMDAiIGN5PSIxMDAiIHI9IjEwM` +
`CIgZmlsbD0iI0ZGQ0YwMCIvPgogIDxwYXRoIGQ9Ik00Ny41IDUyLjVMOTUgMTAwbC00Ny41IDQ3LjVtNjAtOTVMM` +
`TU1IDEwMGwtNDcuNSA0Ny41IiBmaWxsPSJub25lIiBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMjQiL` +
`z4KPC9zdmc+Cg==);
}
`,
},
entryPaths: []string{
"/script.jsx",
"/style.css",
},
options: config.Options{
AbsOutputDir: "/out",
LineLimit: 32,
},
})
}

func TestLineLimitMinified(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/script.jsx": `
export const SignUpForm = (props) => {
return <p class="signup">
<label>Username: <input class="username" type="text"/></label>
<label>Password: <input class="password" type="password"/></label>
<div class="primary disabled">
{props.buttonText}
</div>
<small>By signing up, you are agreeing to our <a href="/tos/">terms of service</a>.</small>
</p>
}
`,
"/style.css": `
body.light-mode.new-user-segment:not(.logged-in) .signup,
body.light-mode.new-user-segment:not(.logged-in) .login {
font: 10px/12px 'Font 1', 'Font 2', 'Font 3', 'Font 4', sans-serif;
user-select: none;
color: var(--fg, rgba(11, 22, 33, 0.5));
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sb` +
`nM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxMDAiIGN5PSIxMDAiIHI9IjEwM` +
`CIgZmlsbD0iI0ZGQ0YwMCIvPgogIDxwYXRoIGQ9Ik00Ny41IDUyLjVMOTUgMTAwbC00Ny41IDQ3LjVtNjAtOTVMM` +
`TU1IDEwMGwtNDcuNSA0Ny41IiBmaWxsPSJub25lIiBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMjQiL` +
`z4KPC9zdmc+Cg==);
}
`,
},
entryPaths: []string{
"/script.jsx",
"/style.css",
},
options: config.Options{
AbsOutputDir: "/out",
LineLimit: 32,
MinifyWhitespace: true,
},
})
}
91 changes: 91 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_default.txt
Expand Up @@ -2938,6 +2938,97 @@ c {

/* entry.css */

================================================================================
TestLineLimitMinified
---------- /out/script.js ----------
export const SignUpForm=props=>{
return React.createElement("p",{
class:"signup"},React.createElement(
"label",null,"Username: ",React.
createElement("input",{class:"us\
ername",type:"text"})),React.createElement(
"label",null,"Password: ",React.
createElement("input",{class:"pa\
ssword",type:"password"})),React.
createElement("div",{class:"prim\
ary disabled"},props.buttonText),
React.createElement("small",null,
"By signing up, you are agreeing\
to our ",React.createElement("a",
{href:"/tos/"},"terms of service"),
"."))};

---------- /out/style.css ----------
body.light-mode.new-user-segment:not(.logged-in)
.signup,body.light-mode.new-user-segment:not(.logged-in)
.login{font:10px/12px "Font 1","\
Font 2","Font 3","Font 4",sans-serif;
user-select:none;color:var(--fg,
rgba(11, 22, 33, 0.5));background:url("\
data:image/svg+xml;base64,PHN2Zy\
B3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMC\
IgeG1sbnM9Imh0dHA6Ly93d3cudzMub3\
JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIG\
N4PSIxMDAiIGN5PSIxMDAiIHI9IjEwMC\
IgZmlsbD0iI0ZGQ0YwMCIvPgogIDxwYX\
RoIGQ9Ik00Ny41IDUyLjVMOTUgMTAwbC\
00Ny41IDQ3LjVtNjAtOTVMMTU1IDEwMG\
wtNDcuNSA0Ny41IiBmaWxsPSJub25lIi\
BzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS\
13aWR0aD0iMjQiLz4KPC9zdmc+Cg==")}

================================================================================
TestLineLimitNotMinified
---------- /out/script.js ----------
export const SignUpForm = (props) => {
return /* @__PURE__ */ React.createElement(
"p", { class: "signup" }, /* @__PURE__ */ React.
createElement("label", null, "\
Username: ", /* @__PURE__ */ React.
createElement("input", { class: "\
username", type: "text" })), /* @__PURE__ */ React.
createElement("label", null, "\
Password: ", /* @__PURE__ */ React.
createElement("input", { class: "\
password", type: "password" })),
/* @__PURE__ */ React.createElement(
"div", { class: "primary disab\
led" }, props.buttonText), /* @__PURE__ */ React.
createElement("small", null, "\
By signing up, you are agreeing \
to our ", /* @__PURE__ */ React.
createElement("a", { href: "/t\
os/" }, "terms of service"), "."));
};

---------- /out/style.css ----------
body.light-mode.new-user-segment:not(.logged-in)
.signup,
body.light-mode.new-user-segment:not(.logged-in)
.login {
font:
10px/12px "Font 1",
"Font 2",
"Font 3",
"Font 4",
sans-serif;
user-select: none;
color: var(--fg, rgba(11, 22, 33,
0.5));
background: url("data:image/sv\
g+xml;base64,PHN2ZyB3aWR0aD0iMjA\
wIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh\
0dHA6Ly93d3cudzMub3JnLzIwMDAvc3Z\
nIj4KICA8Y2lyY2xlIGN4PSIxMDAiIGN\
5PSIxMDAiIHI9IjEwMCIgZmlsbD0iI0Z\
GQ0YwMCIvPgogIDxwYXRoIGQ9Ik00Ny4\
1IDUyLjVMOTUgMTAwbC00Ny41IDQ3LjV\
tNjAtOTVMMTU1IDEwMGwtNDcuNSA0Ny4\
1IiBmaWxsPSJub25lIiBzdHJva2U9IiM\
xOTE5MTkiIHN0cm9rZS13aWR0aD0iMjQ\
iLz4KPC9zdmc+Cg==");
}

================================================================================
TestMangleNoQuotedProps
---------- /out/entry.js ----------
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Expand Up @@ -418,6 +418,7 @@ type Options struct {
SourceRoot string
Stdin *StdinInfo
JSX JSXOptions
LineLimit int

UnsupportedJSFeatures compat.JSFeature
UnsupportedCSSFeatures compat.CSSFeature
Expand Down
85 changes: 77 additions & 8 deletions internal/css_printer/css_printer.go
Expand Up @@ -24,6 +24,8 @@ type printer struct {
extractedLegalComments []string
jsonMetadataImports []string
builder sourcemap.ChunkBuilder
oldLineStart int
oldLineEnd int
}

type Options struct {
Expand All @@ -35,6 +37,7 @@ type Options struct {
// us do binary search on to figure out what line a given AST node came from
LineOffsetTables []sourcemap.LineOffsetTable

LineLimit int
UnsupportedFeatures compat.CSSFeature
MinifyWhitespace bool
ASCIIOnly bool
Expand Down Expand Up @@ -114,6 +117,10 @@ func (p *printer) printRule(rule css_ast.Rule, indent int32, omitTrailingSemicol
}
}

if p.options.LineLimit > 0 {
p.printNewlinePastLineLimit(indent)
}

if p.options.AddSourceMappings {
p.builder.AddSourceMapping(rule.Loc, "", p.css)
}
Expand Down Expand Up @@ -338,32 +345,37 @@ func (p *printer) printComplexSelectors(selectors []css_ast.ComplexSelector, ind
if i > 0 {
if p.options.MinifyWhitespace {
p.print(",")
if p.options.LineLimit > 0 {
p.printNewlinePastLineLimit(indent)
}
} else {
p.print(",\n")
p.printIndent(indent)
}
}

for j, compound := range complex.Selectors {
p.printCompoundSelector(compound, j == 0, j+1 == len(complex.Selectors))
p.printCompoundSelector(compound, j == 0, j+1 == len(complex.Selectors), indent)
}
}
}

func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bool, isLast bool) {
func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bool, isLast bool, indent int32) {
if !isFirst && sel.Combinator == 0 {
// A space is required in between compound selectors if there is no
// combinator in the middle. It's fine to convert "a + b" into "a+b"
// but not to convert "a b" into "ab".
p.print(" ")
if p.options.LineLimit <= 0 || !p.printNewlinePastLineLimit(indent) {
p.print(" ")
}
}

if sel.Combinator != 0 {
if !isFirst && !p.options.MinifyWhitespace {
p.print(" ")
}
p.css = append(p.css, sel.Combinator)
if !p.options.MinifyWhitespace {
if (p.options.LineLimit <= 0 || !p.printNewlinePastLineLimit(indent)) && !p.options.MinifyWhitespace {
p.print(" ")
}
}
Expand Down Expand Up @@ -585,7 +597,28 @@ func (p *printer) printQuotedWithQuote(text string, quote byte) {
i := 0
runStart := 0

// Only compute the line length if necessary
var startLineLength int
wrapLongLines := false
if p.options.LineLimit > 0 && quote != quoteForURL {
startLineLength = p.currentLineLength()
if startLineLength > p.options.LineLimit {
startLineLength = p.options.LineLimit
}
wrapLongLines = true
}

for i < n {
// Wrap long lines that are over the limit using escaped newlines
if wrapLongLines && startLineLength+i >= p.options.LineLimit {
if runStart < i {
p.css = append(p.css, text[runStart:i]...)
runStart = i
}
p.css = append(p.css, "\\\n"...)
startLineLength -= p.options.LineLimit
}

c, width := utf8.DecodeRuneInString(text[i:])
escape := escapeNone

Expand Down Expand Up @@ -634,6 +667,34 @@ func (p *printer) printQuotedWithQuote(text string, quote byte) {
}
}

func (p *printer) currentLineLength() int {
css := p.css
n := len(css)
stop := p.oldLineEnd

// Update "oldLineStart" to the start of the current line
for i := n; i > stop; i-- {
if c := css[i-1]; c == '\r' || c == '\n' {
p.oldLineStart = i
break
}
}

p.oldLineEnd = n
return n - p.oldLineStart
}

func (p *printer) printNewlinePastLineLimit(indent int32) bool {
if p.currentLineLength() < p.options.LineLimit {
return false
}
p.print("\n")
if !p.options.MinifyWhitespace {
p.printIndent(indent)
}
return true
}

type identMode uint8

const (
Expand Down Expand Up @@ -725,7 +786,11 @@ func (p *printer) printIdent(text string, mode identMode, whitespace trailingWhi
}

func (p *printer) printIndent(indent int32) {
for i, n := 0, int(indent); i < n; i++ {
n := int(indent)
if p.options.LineLimit > 0 && n*2 >= p.options.LineLimit {
n = p.options.LineLimit / 2
}
for i := 0; i < n; i++ {
p.css = append(p.css, " "...)
}
}
Expand Down Expand Up @@ -759,7 +824,7 @@ func (p *printer) printTokens(tokens []css_ast.Token, opts printTokensOpts) bool
if isMultiLineValue && (i == 0 || tokens[i-1].Kind == css_lexer.TComma) {
p.print("\n")
p.printIndent(opts.indent + 1)
} else {
} else if p.options.LineLimit <= 0 || !p.printNewlinePastLineLimit(opts.indent+1) {
p.print(" ")
}
}
Expand Down Expand Up @@ -801,8 +866,12 @@ func (p *printer) printTokens(tokens []css_ast.Token, opts printTokensOpts) bool

case css_lexer.TURL:
text := p.importRecords[t.ImportRecordIndex].Path.Text
tryToAvoidQuote := true
if p.options.LineLimit > 0 && p.currentLineLength()+len(text) >= p.options.LineLimit {
tryToAvoidQuote = false
}
p.print("url(")
p.printQuotedWithQuote(text, bestQuoteCharForString(text, true))
p.printQuotedWithQuote(text, bestQuoteCharForString(text, tryToAvoidQuote))
p.print(")")
p.recordImportPathForMetafile(t.ImportRecordIndex)

Expand All @@ -820,7 +889,7 @@ func (p *printer) printTokens(tokens []css_ast.Token, opts printTokensOpts) bool
}

if t.Children != nil {
p.printTokens(*t.Children, printTokensOpts{})
p.printTokens(*t.Children, printTokensOpts{indent: opts.indent})

switch t.Kind {
case css_lexer.TFunction:
Expand Down

0 comments on commit 7d5df10

Please sign in to comment.