diff --git a/CHANGELOG.md b/CHANGELOG.md index 434318581e1..d80c917cedb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +* Fix a parser hang on invalid CSS ([#2276](https://github.com/evanw/esbuild/issues/2276)) + + Previously invalid CSS with unbalanced parentheses could cause esbuild's CSS parser to hang. An example of such an input is the CSS file `:x(`. This hang has been fixed. + ## 0.14.41 * Fix a minification regression in 0.14.40 ([#2270](https://github.com/evanw/esbuild/issues/2270), [#2271](https://github.com/evanw/esbuild/issues/2271), [#2273](https://github.com/evanw/esbuild/pull/2273)) diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index 2e0809dfbe1..157d60aedf2 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -113,6 +113,10 @@ func (p *parser) eat(kind css_lexer.T) bool { } func (p *parser) expect(kind css_lexer.T) bool { + return p.expectWithMatchingLoc(kind, logger.Loc{Start: -1}) +} + +func (p *parser) expectWithMatchingLoc(kind css_lexer.T, matchingLoc logger.Loc) bool { if p.eat(kind) { return true } @@ -123,6 +127,7 @@ func (p *parser) expect(kind css_lexer.T) bool { var text string var suggestion string + var notes []logger.MsgData expected := kind.String() if strings.HasPrefix(expected, "\"") && strings.HasSuffix(expected, "\"") { @@ -133,6 +138,11 @@ func (p *parser) expect(kind css_lexer.T) bool { // Have a nice error message for forgetting a trailing semicolon or colon text = fmt.Sprintf("Expected %s", expected) t = p.at(p.index - 1) + } else if (kind == css_lexer.TCloseBrace || kind == css_lexer.TCloseBracket || kind == css_lexer.TCloseParen) && matchingLoc.Start != -1 { + // Have a nice error message for forgetting a closing brace/bracket/parenthesis + c := p.source.Contents[matchingLoc.Start : matchingLoc.Start+1] + text = fmt.Sprintf("Expected %s to go with %q", expected, c) + notes = append(notes, p.tracker.MsgData(logger.Range{Loc: matchingLoc, Len: 1}, fmt.Sprintf("The unbalanced %q is here:", c))) } else { switch t.Kind { case css_lexer.TEndOfFile, css_lexer.TWhitespace: @@ -148,7 +158,7 @@ func (p *parser) expect(kind css_lexer.T) bool { if t.Range.Loc.Start > p.prevError.Start { data := p.tracker.MsgData(t.Range, text) data.Location.Suggestion = suggestion - p.log.AddMsg(logger.Msg{Kind: logger.Warning, Data: data}) + p.log.AddMsg(logger.Msg{Kind: logger.Warning, Data: data, Notes: notes}) p.prevError = t.Range.Loc } return false @@ -594,10 +604,11 @@ func (p *parser) parseURLOrString() (string, logger.Range, bool) { case css_lexer.TFunction: if p.decoded() == "url" { + matchingLoc := logger.Loc{Start: p.current().Range.End() - 1} p.advance() t = p.current() text := p.decoded() - if p.expect(css_lexer.TString) && p.expect(css_lexer.TCloseParen) { + if p.expect(css_lexer.TString) && p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) { return text, t.Range, true } } @@ -829,6 +840,7 @@ abortRuleParser: p.eat(css_lexer.TWhitespace) blockStart := p.index + matchingLoc := p.current().Range.Loc if p.expect(css_lexer.TOpenBrace) { var blocks []css_ast.KeyframeBlock @@ -866,7 +878,18 @@ abortRuleParser: continue case css_lexer.TOpenBrace: + blockMatchingLoc := p.current().Range.Loc p.advance() + rules := p.parseListOfDeclarations() + p.expectWithMatchingLoc(css_lexer.TCloseBrace, blockMatchingLoc) + + // "@keyframes { from {} to { color: red } }" => "@keyframes { to { color: red } }" + if !p.options.MinifySyntax || len(rules) > 0 { + blocks = append(blocks, css_ast.KeyframeBlock{ + Selectors: selectors, + Rules: rules, + }) + } break selectors case css_lexer.TCloseBrace, css_lexer.TEndOfFile: @@ -907,17 +930,6 @@ abortRuleParser: break badSyntax } } - - rules := p.parseListOfDeclarations() - p.expect(css_lexer.TCloseBrace) - - // "@keyframes { from {} to { color: red } }" => "@keyframes { to { color: red } }" - if !p.options.MinifySyntax || len(rules) > 0 { - blocks = append(blocks, css_ast.KeyframeBlock{ - Selectors: selectors, - Rules: rules, - }) - } } } @@ -925,7 +937,7 @@ abortRuleParser: for !p.peek(css_lexer.TCloseBrace) && !p.peek(css_lexer.TEndOfFile) { p.parseComponentValue() } - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) prelude := p.convertTokens(p.tokens[preludeStart:blockStart]) block, _ := p.convertTokensHelper(p.tokens[blockStart:p.index], css_lexer.TEndOfFile, convertTokensOpts{allowImports: true}) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}} @@ -978,11 +990,12 @@ abortRuleParser: } // Read the optional block + matchingLoc := p.current().Range.Loc if len(names) <= 1 && p.eat(css_lexer.TOpenBrace) { rules := p.parseListOfRules(ruleContext{ parseSelectors: true, }) - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names, Rules: rules}} } @@ -1064,13 +1077,15 @@ prelude: case atRuleDeclarations: // Parse known rules whose blocks always consist of declarations + matchingLoc := p.current().Range.Loc p.expect(css_lexer.TOpenBrace) rules := p.parseListOfDeclarations() - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules}} case atRuleInheritContext: // Parse known rules whose blocks consist of whatever the current context is + matchingLoc := p.current().Range.Loc p.expect(css_lexer.TOpenBrace) var rules []css_ast.Rule if context.isDeclarationList { @@ -1080,15 +1095,16 @@ prelude: parseSelectors: true, }) } - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules}} case atRuleQualifiedOrEmpty: + matchingLoc := p.current().Range.Loc if p.eat(css_lexer.TOpenBrace) { rules := p.parseListOfRules(ruleContext{ parseSelectors: true, }) - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules}} } p.expect(css_lexer.TSemicolon) @@ -1471,9 +1487,10 @@ func (p *parser) parseSelectorRuleFrom(preludeStart int, opts parseSelectorOpts) Selectors: list, HasAtNest: opts.atNestRange.Len != 0, } + matchingLoc := p.current().Range.Loc if p.expect(css_lexer.TOpenBrace) { selector.Rules = p.parseListOfDeclarations() - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) // Minify "@nest" when possible if p.options.MinifySyntax && selector.HasAtNest { @@ -1529,9 +1546,10 @@ loop: Prelude: p.convertTokens(p.tokens[preludeStart:p.index]), } + matchingLoc := p.current().Range.Loc if p.eat(css_lexer.TOpenBrace) { qualified.Rules = p.parseListOfDeclarations() - p.expect(css_lexer.TCloseBrace) + p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) } else if !opts.isAlreadyInvalid { p.expect(css_lexer.TOpenBrace) } @@ -1653,10 +1671,11 @@ func (p *parser) parseComponentValue() { } func (p *parser) parseBlock(open css_lexer.T, close css_lexer.T) { + matchingLoc := p.current().Range.Loc if p.expect(open) { for !p.eat(close) { if p.peek(css_lexer.TEndOfFile) { - p.expect(close) + p.expectWithMatchingLoc(close, matchingLoc) return } diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index 8acb298e10c..8e7eacd9620 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -169,7 +169,6 @@ subclassSelectors: p.expect(css_lexer.TIdent) case css_lexer.TOpenBracket: - p.advance() attr, good := p.parseAttributeSelector() if !good { return @@ -231,6 +230,9 @@ subclassSelectors: } func (p *parser) parseAttributeSelector() (attr css_ast.SSAttribute, ok bool) { + matchingLoc := p.current().Range.Loc + p.advance() + // Parse the namespaced name switch p.current().Kind { case css_lexer.TDelimBar, css_lexer.TDelimAsterisk: @@ -314,7 +316,7 @@ func (p *parser) parseAttributeSelector() (attr css_ast.SSAttribute, ok bool) { } } - p.expect(css_lexer.TCloseBracket) + p.expectWithMatchingLoc(css_lexer.TCloseBracket, matchingLoc) ok = true return } @@ -324,9 +326,10 @@ func (p *parser) parsePseudoClassSelector() css_ast.SSPseudoClass { if p.peek(css_lexer.TFunction) { text := p.decoded() + matchingLoc := logger.Loc{Start: p.current().Range.End() - 1} p.advance() args := p.convertTokens(p.parseAnyValue()) - p.expect(css_lexer.TCloseParen) + p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) return css_ast.SSPseudoClass{Name: text, Args: args} } @@ -367,6 +370,9 @@ loop: case css_lexer.TOpenBrace: p.stack = append(p.stack, css_lexer.TCloseBrace) + + case css_lexer.TEndOfFile: + break loop } p.advance() diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 5704224aa52..0068f7fc52b 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -273,22 +273,26 @@ func TestString(t *testing.T) { expectParseError(t, "a:after { content: '\r' }", `: ERROR: Unterminated string token : ERROR: Unterminated string token -: WARNING: Expected "}" but found end of file +: WARNING: Expected "}" to go with "{" +: NOTE: The unbalanced "{" is here: `) expectParseError(t, "a:after { content: '\n' }", `: ERROR: Unterminated string token : ERROR: Unterminated string token -: WARNING: Expected "}" but found end of file +: WARNING: Expected "}" to go with "{" +: NOTE: The unbalanced "{" is here: `) expectParseError(t, "a:after { content: '\f' }", `: ERROR: Unterminated string token : ERROR: Unterminated string token -: WARNING: Expected "}" but found end of file +: WARNING: Expected "}" to go with "{" +: NOTE: The unbalanced "{" is here: `) expectParseError(t, "a:after { content: '\r\n' }", `: ERROR: Unterminated string token : ERROR: Unterminated string token -: WARNING: Expected "}" but found end of file +: WARNING: Expected "}" to go with "{" +: NOTE: The unbalanced "{" is here: `) expectPrinted(t, "a:after { content: '\\1010101' }", "a:after {\n content: \"\U001010101\";\n}\n") @@ -596,9 +600,9 @@ func TestSelector(t *testing.T) { expectPrinted(t, "a[b] {}", "a[b] {\n}\n") expectPrinted(t, "a [b] {}", "a [b] {\n}\n") expectParseError(t, "[] {}", ": WARNING: Expected identifier but found \"]\"\n") - expectParseError(t, "[b {}", ": WARNING: Expected \"]\" but found \"{\"\n") + expectParseError(t, "[b {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n") expectParseError(t, "[b]] {}", ": WARNING: Unexpected \"]\"\n") - expectParseError(t, "a[b {}", ": WARNING: Expected \"]\" but found \"{\"\n") + expectParseError(t, "a[b {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n") expectParseError(t, "a[b]] {}", ": WARNING: Unexpected \"]\"\n") expectPrinted(t, "[|b]{}", "[b] {\n}\n") // "[|b]" is equivalent to "[b]" @@ -618,7 +622,7 @@ func TestSelector(t *testing.T) { expectPrinted(t, "[b$=\"c\"] {}", "[b$=c] {\n}\n") expectPrinted(t, "[b*=\"c\"] {}", "[b*=c] {\n}\n") expectPrinted(t, "[b|=\"c\"] {}", "[b|=c] {\n}\n") - expectParseError(t, "[b?=\"c\"] {}", ": WARNING: Expected \"]\" but found \"?\"\n") + expectParseError(t, "[b?=\"c\"] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n") expectPrinted(t, "[b = \"c\"] {}", "[b=c] {\n}\n") expectPrinted(t, "[b ~= \"c\"] {}", "[b~=c] {\n}\n") @@ -626,16 +630,16 @@ func TestSelector(t *testing.T) { expectPrinted(t, "[b $= \"c\"] {}", "[b$=c] {\n}\n") expectPrinted(t, "[b *= \"c\"] {}", "[b*=c] {\n}\n") expectPrinted(t, "[b |= \"c\"] {}", "[b|=c] {\n}\n") - expectParseError(t, "[b ?= \"c\"] {}", ": WARNING: Expected \"]\" but found \"?\"\n") + expectParseError(t, "[b ?= \"c\"] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n") expectPrinted(t, "[b = \"c\" i] {}", "[b=c i] {\n}\n") expectPrinted(t, "[b = \"c\" I] {}", "[b=c I] {\n}\n") expectPrinted(t, "[b = \"c\" s] {}", "[b=c s] {\n}\n") expectPrinted(t, "[b = \"c\" S] {}", "[b=c S] {\n}\n") - expectParseError(t, "[b i] {}", ": WARNING: Expected \"]\" but found \"i\"\n: WARNING: Unexpected \"]\"\n") - expectParseError(t, "[b I] {}", ": WARNING: Expected \"]\" but found \"I\"\n: WARNING: Unexpected \"]\"\n") - expectParseError(t, "[b s] {}", ": WARNING: Expected \"]\" but found \"s\"\n: WARNING: Unexpected \"]\"\n") - expectParseError(t, "[b S] {}", ": WARNING: Expected \"]\" but found \"S\"\n: WARNING: Unexpected \"]\"\n") + expectParseError(t, "[b i] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n: WARNING: Unexpected \"]\"\n") + expectParseError(t, "[b I] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n: WARNING: Unexpected \"]\"\n") + expectParseError(t, "[b s] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n: WARNING: Unexpected \"]\"\n") + expectParseError(t, "[b S] {}", ": WARNING: Expected \"]\" to go with \"[\"\n: NOTE: The unbalanced \"[\" is here:\n: WARNING: Unexpected \"]\"\n") expectPrinted(t, "|b {}", "|b {\n}\n") expectPrinted(t, "|* {}", "|* {\n}\n") @@ -665,6 +669,11 @@ func TestSelector(t *testing.T) { expectPrinted(t, "a:b(:c) {}", "a:b(:c) {\n}\n") expectPrinted(t, "a: b {}", "a: b {\n}\n") + // These test cases previously caused a hang (see https://github.com/evanw/esbuild/issues/2276) + expectParseError(t, ":x(", ": WARNING: Unexpected end of file\n") + expectParseError(t, ":x( {}", ": WARNING: Expected \")\" to go with \"(\"\n: NOTE: The unbalanced \"(\" is here:\n") + expectParseError(t, ":x(, :y() {}", ": WARNING: Expected \")\" to go with \"(\"\n: NOTE: The unbalanced \"(\" is here:\n") + expectPrinted(t, "#id {}", "#id {\n}\n") expectPrinted(t, "#--0 {}", "#--0 {\n}\n") expectPrinted(t, "#\\-0 {}", "#\\-0 {\n}\n") @@ -964,7 +973,7 @@ func TestAtImport(t *testing.T) { expectParseError(t, "@import;", ": WARNING: Expected URL token but found \";\"\n") expectParseError(t, "@import ;", ": WARNING: Expected URL token but found \";\"\n") expectParseError(t, "@import \"foo.css\"", ": WARNING: Expected \";\" but found end of file\n") - expectParseError(t, "@import url(\"foo.css\";", ": WARNING: Expected \")\" but found \";\"\n") + expectParseError(t, "@import url(\"foo.css\";", ": WARNING: Expected \")\" to go with \"(\"\n: NOTE: The unbalanced \"(\" is here:\n") expectParseError(t, "@import noturl(\"foo.css\");", ": WARNING: Expected URL token but found \"noturl(\"\n") expectParseError(t, "@import url(", `: WARNING: Expected URL token but found bad URL token : ERROR: Expected ")" to end URL token @@ -1041,7 +1050,7 @@ func TestAtKeyframes(t *testing.T) { expectParseError(t, "@keyframes name { 1% }", ": WARNING: Expected \"{\" but found \"}\"\n") expectParseError(t, "@keyframes name { 1%", ": WARNING: Expected \"{\" but found end of file\n") expectParseError(t, "@keyframes name { 1%,,2% {} }", ": WARNING: Expected percentage but found \",\"\n") - expectParseError(t, "@keyframes name {", ": WARNING: Expected \"}\" but found end of file\n") + expectParseError(t, "@keyframes name {", ": WARNING: Expected \"}\" to go with \"{\"\n: NOTE: The unbalanced \"{\" is here:\n") expectPrinted(t, "@keyframes x { 1%, {} } @keyframes z { 1% {} }", "@keyframes x { 1%, {} }\n@keyframes z {\n 1% {\n }\n}\n") expectPrinted(t, "@keyframes x { .y {} } @keyframes z { 1% {} }", "@keyframes x { .y {} }\n@keyframes z {\n 1% {\n }\n}\n")