Files
esbuild/internal/css_parser/css_parser.go
2020-10-24 05:14:05 -07:00

798 lines
20 KiB
Go

package css_parser
import (
"fmt"
"strings"
"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/config"
"github.com/evanw/esbuild/internal/css_ast"
"github.com/evanw/esbuild/internal/css_lexer"
"github.com/evanw/esbuild/internal/logger"
)
// This is mostly a normal CSS parser with one exception: the addition of
// support for parsing https://drafts.csswg.org/css-nesting-1/.
type parser struct {
log logger.Log
source logger.Source
options config.Options
tokens []css_lexer.Token
stack []css_lexer.T
index int
end int
prevError logger.Loc
importRecords []ast.ImportRecord
}
func Parse(log logger.Log, source logger.Source, options config.Options) css_ast.AST {
p := parser{
log: log,
source: source,
options: options,
tokens: css_lexer.Tokenize(log, source),
prevError: logger.Loc{Start: -1},
}
p.end = len(p.tokens)
tree := css_ast.AST{}
tree.Rules = p.parseListOfRules(ruleContext{
isTopLevel: true,
parseSelectors: true,
})
tree.ImportRecords = p.importRecords
p.expect(css_lexer.TEndOfFile)
return tree
}
func (p *parser) advance() {
if p.index < p.end {
p.index++
}
}
func (p *parser) at(index int) css_lexer.Token {
if index < p.end {
return p.tokens[index]
}
if p.end < len(p.tokens) {
return css_lexer.Token{
Kind: css_lexer.TEndOfFile,
Range: logger.Range{Loc: p.tokens[p.end].Range.Loc},
}
}
return css_lexer.Token{
Kind: css_lexer.TEndOfFile,
Range: logger.Range{Loc: logger.Loc{Start: int32(len(p.source.Contents))}},
}
}
func (p *parser) current() css_lexer.Token {
return p.at(p.index)
}
func (p *parser) next() css_lexer.Token {
return p.at(p.index + 1)
}
func (p *parser) raw() string {
t := p.current()
return p.source.Contents[t.Range.Loc.Start:t.Range.End()]
}
func (p *parser) decoded() string {
return p.current().DecodedText(p.source.Contents)
}
func (p *parser) peek(kind css_lexer.T) bool {
return kind == p.current().Kind
}
func (p *parser) eat(kind css_lexer.T) bool {
if p.peek(kind) {
p.advance()
return true
}
return false
}
func (p *parser) expect(kind css_lexer.T) bool {
if p.eat(kind) {
return true
}
t := p.current()
var text string
if kind == css_lexer.TSemicolon && p.index > 0 && p.at(p.index-1).Kind == css_lexer.TWhitespace {
// Have a nice error message for forgetting a trailing semicolon
text = fmt.Sprintf("Expected \";\"")
t = p.at(p.index - 1)
} else {
switch t.Kind {
case css_lexer.TEndOfFile, css_lexer.TWhitespace:
text = fmt.Sprintf("Expected %s but found %s", kind.String(), t.Kind.String())
t.Range.Len = 0
case css_lexer.TBadURL, css_lexer.TBadString:
text = fmt.Sprintf("Expected %s but found %s", kind.String(), t.Kind.String())
default:
text = fmt.Sprintf("Expected %s but found %q", kind.String(), p.raw())
}
}
if t.Range.Loc.Start > p.prevError.Start {
p.log.AddRangeWarning(&p.source, t.Range, text)
p.prevError = t.Range.Loc
}
return false
}
func (p *parser) unexpected() {
if t := p.current(); t.Range.Loc.Start > p.prevError.Start {
var text string
switch t.Kind {
case css_lexer.TEndOfFile, css_lexer.TWhitespace:
text = fmt.Sprintf("Unexpected %s", t.Kind.String())
t.Range.Len = 0
case css_lexer.TBadURL, css_lexer.TBadString:
text = fmt.Sprintf("Unexpected %s", t.Kind.String())
default:
text = fmt.Sprintf("Unexpected %q", p.raw())
}
p.log.AddRangeWarning(&p.source, t.Range, text)
p.prevError = t.Range.Loc
}
}
type ruleContext struct {
isTopLevel bool
parseSelectors bool
}
func (p *parser) parseListOfRules(context ruleContext) []css_ast.R {
rules := []css_ast.R{}
for {
switch p.current().Kind {
case css_lexer.TEndOfFile, css_lexer.TCloseBrace:
return rules
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TAtKeyword:
rules = append(rules, p.parseAtRule(atRuleContext{}))
continue
case css_lexer.TCDO, css_lexer.TCDC:
if context.isTopLevel {
p.advance()
continue
}
}
if context.parseSelectors {
rules = append(rules, p.parseSelectorRule())
} else {
rules = append(rules, p.parseQualifiedRuleFrom(p.index, false /* isAlreadyInvalid */))
}
}
}
func (p *parser) parseListOfDeclarations() (list []css_ast.R) {
for {
switch p.current().Kind {
case css_lexer.TWhitespace, css_lexer.TSemicolon:
p.advance()
case css_lexer.TEndOfFile, css_lexer.TCloseBrace:
p.processDeclarations(list)
return
case css_lexer.TAtKeyword:
list = append(list, p.parseAtRule(atRuleContext{
isDeclarationList: true,
}))
case css_lexer.TDelimAmpersand:
// Reference: https://drafts.csswg.org/css-nesting-1/
list = append(list, p.parseSelectorRule())
default:
list = append(list, p.parseDeclaration())
}
}
}
func (p *parser) parseURLOrString() (string, logger.Range, bool) {
t := p.current()
switch t.Kind {
case css_lexer.TString:
text := p.decoded()
p.advance()
return text, t.Range, true
case css_lexer.TURL:
text := p.decoded()
p.advance()
return text, t.Range, true
case css_lexer.TFunction:
if p.decoded() == "url" {
p.advance()
t = p.current()
text := p.decoded()
if p.expect(css_lexer.TString) && p.expect(css_lexer.TCloseParen) {
return text, t.Range, true
}
}
}
return "", logger.Range{}, false
}
func (p *parser) expectURLOrString() (url string, r logger.Range, ok bool) {
url, r, ok = p.parseURLOrString()
if !ok {
p.expect(css_lexer.TURL)
}
return
}
type atRuleKind uint8
const (
atRuleUnknown atRuleKind = iota
atRuleDeclarations
atRuleInheritContext
atRuleEmpty
)
var specialAtRules = map[string]atRuleKind{
"font-face": atRuleDeclarations,
"page": atRuleDeclarations,
"document": atRuleInheritContext,
"media": atRuleInheritContext,
"scope": atRuleInheritContext,
"supports": atRuleInheritContext,
}
type atRuleContext struct {
isDeclarationList bool
}
func (p *parser) parseAtRule(context atRuleContext) css_ast.R {
// Parse the name
atToken := p.decoded()
atRange := p.current().Range
kind := specialAtRules[atToken]
p.advance()
// Parse the prelude
preludeStart := p.index
switch atToken {
case "charset":
kind = atRuleEmpty
p.expect(css_lexer.TWhitespace)
if p.peek(css_lexer.TString) {
encoding := p.decoded()
if encoding != "UTF-8" {
p.log.AddRangeWarning(&p.source, p.current().Range,
fmt.Sprintf("\"UTF-8\" will be used instead of unsupported charset %q", encoding))
}
p.advance()
p.expect(css_lexer.TSemicolon)
return &css_ast.RAtCharset{Encoding: encoding}
}
p.expect(css_lexer.TString)
case "namespace":
kind = atRuleEmpty
p.eat(css_lexer.TWhitespace)
prefix := ""
if p.peek(css_lexer.TIdent) {
prefix = p.decoded()
p.advance()
p.eat(css_lexer.TWhitespace)
}
if path, _, ok := p.expectURLOrString(); ok {
p.eat(css_lexer.TWhitespace)
p.expect(css_lexer.TSemicolon)
return &css_ast.RAtNamespace{Prefix: prefix, Path: path}
}
case "import":
kind = atRuleEmpty
p.eat(css_lexer.TWhitespace)
if path, r, ok := p.expectURLOrString(); ok {
p.eat(css_lexer.TWhitespace)
p.expect(css_lexer.TSemicolon)
importRecordIndex := uint32(len(p.importRecords))
p.importRecords = append(p.importRecords, ast.ImportRecord{
Kind: ast.ImportAt,
Path: logger.Path{Text: path},
Range: r,
})
return &css_ast.RAtImport{ImportRecordIndex: importRecordIndex}
}
case "keyframes", "-webkit-keyframes", "-moz-keyframes", "-ms-keyframes", "-o-keyframes":
p.eat(css_lexer.TWhitespace)
var name string
if p.peek(css_lexer.TIdent) {
name = p.decoded()
p.advance()
} else if !p.expect(css_lexer.TIdent) && !p.eat(css_lexer.TString) && !p.peek(css_lexer.TOpenBrace) {
// Consider string names a syntax error even though they are allowed by
// the specification and they work in Firefox because they do not work in
// Chrome or Safari.
break
}
p.eat(css_lexer.TWhitespace)
if p.expect(css_lexer.TOpenBrace) {
var blocks []css_ast.KeyframeBlock
blocks:
for {
switch p.current().Kind {
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TCloseBrace, css_lexer.TEndOfFile:
break blocks
case css_lexer.TOpenBrace:
p.expect(css_lexer.TPercentage)
p.parseComponentValue()
default:
var selectors []string
selectors:
for {
t := p.current()
switch t.Kind {
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TOpenBrace, css_lexer.TEndOfFile:
break selectors
case css_lexer.TIdent, css_lexer.TPercentage:
text := p.decoded()
if t.Kind == css_lexer.TIdent {
if text == "from" {
if p.options.MangleSyntax {
text = "0%" // "0%" is equivalent to but shorter than "from"
}
} else if text != "to" {
p.expect(css_lexer.TPercentage)
}
} else if p.options.MangleSyntax && text == "100%" {
text = "to" // "to" is equivalent to but shorter than "100%"
}
selectors = append(selectors, text)
p.advance()
default:
p.expect(css_lexer.TPercentage)
p.parseComponentValue()
}
p.eat(css_lexer.TWhitespace)
if t.Kind != css_lexer.TComma && !p.peek(css_lexer.TOpenBrace) {
p.expect(css_lexer.TComma)
}
}
if p.expect(css_lexer.TOpenBrace) {
rules := p.parseListOfDeclarations()
p.expect(css_lexer.TCloseBrace)
blocks = append(blocks, css_ast.KeyframeBlock{
Selectors: selectors,
Rules: rules,
})
}
}
}
p.expect(css_lexer.TCloseBrace)
return &css_ast.RAtKeyframes{
AtToken: atToken,
Name: name,
Blocks: blocks,
}
}
default:
// Warn about unsupported at-rules since they will be passed through
// unmodified and may be part of a CSS preprocessor syntax that should
// have been compiled away but wasn't.
//
// The list of supported at-rules that esbuild draws from is here:
// https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule. Deprecated
// and Firefox-only at-rules have been removed.
if kind == atRuleUnknown {
p.log.AddRangeWarning(&p.source, atRange, fmt.Sprintf("%q is not a known rule name", "@"+atToken))
}
}
// Parse an unknown prelude
prelude:
for {
switch p.current().Kind {
case css_lexer.TOpenBrace, css_lexer.TEndOfFile:
break prelude
case css_lexer.TSemicolon, css_lexer.TCloseBrace:
prelude := p.convertTokens(p.tokens[preludeStart:p.index])
// Report an error for rules that should have blocks
if kind != atRuleEmpty && kind != atRuleUnknown {
p.expect(css_lexer.TOpenBrace)
p.eat(css_lexer.TSemicolon)
return &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude}
}
// Otherwise, parse an unknown at rule
p.expect(css_lexer.TSemicolon)
return &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude}
default:
p.parseComponentValue()
}
}
prelude := p.convertTokens(p.tokens[preludeStart:p.index])
blockStart := p.index
switch kind {
case atRuleEmpty:
// Report an error for rules that shouldn't have blocks
p.expect(css_lexer.TSemicolon)
p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace)
block := p.convertTokens(p.tokens[blockStart:p.index])
return &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}
case atRuleDeclarations:
// Parse known rules whose blocks consist of whatever the current context is
p.advance()
rules := p.parseListOfDeclarations()
p.expect(css_lexer.TCloseBrace)
return &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules}
case atRuleInheritContext:
// Parse known rules whose blocks consist of whatever the current context is
p.advance()
var rules []css_ast.R
if context.isDeclarationList {
rules = p.parseListOfDeclarations()
} else {
rules = p.parseListOfRules(ruleContext{
parseSelectors: true,
})
}
p.expect(css_lexer.TCloseBrace)
return &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules}
default:
// Otherwise, parse an unknown rule
p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace)
block := p.convertTokensWithImports(p.tokens[blockStart:p.index])
return &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}
}
}
func (p *parser) convertTokens(tokens []css_lexer.Token) []css_ast.Token {
result, _ := p.convertTokensHelper(tokens, css_lexer.TEndOfFile, false)
return result
}
func (p *parser) convertTokensWithImports(tokens []css_lexer.Token) []css_ast.Token {
result, _ := p.convertTokensHelper(tokens, css_lexer.TEndOfFile, true)
return result
}
func (p *parser) convertTokensHelper(tokens []css_lexer.Token, close css_lexer.T, allowImports bool) ([]css_ast.Token, []css_lexer.Token) {
var result []css_ast.Token
loop:
for len(tokens) > 0 {
t := tokens[0]
tokens = tokens[1:]
token := css_ast.Token{
Kind: t.Kind,
Text: t.DecodedText(p.source.Contents),
}
switch t.Kind {
case css_lexer.TWhitespace:
if last := len(result) - 1; last >= 0 {
result[last].HasWhitespaceAfter = true
}
continue
case css_lexer.TComma:
// Assume that whitespace can always be removed before a comma
if last := len(result) - 1; last >= 0 {
result[last].HasWhitespaceAfter = false
}
// Assume whitespace can always be added after a comma (it will be
// automatically omitted by the printer if we're minifying)
token.HasWhitespaceAfter = true
case css_lexer.TNumber:
if p.options.MangleSyntax {
if text, ok := mangleNumber(token.Text); ok {
token.Text = text
}
}
case css_lexer.TPercentage:
if p.options.MangleSyntax {
if text, ok := mangleNumber(token.PercentValue()); ok {
token.Text = text + "%"
}
}
case css_lexer.TDimension:
token.UnitOffset = t.UnitOffset
if p.options.MangleSyntax {
if text, ok := mangleNumber(token.DimensionValue()); ok {
token.Text = text + token.DimensionUnit()
token.UnitOffset = uint16(len(text))
}
}
case css_lexer.TURL:
token.ImportRecordIndex = uint32(len(p.importRecords))
p.importRecords = append(p.importRecords, ast.ImportRecord{
Kind: ast.ImportURL,
Path: logger.Path{Text: token.Text},
Range: t.Range,
IsUnused: !allowImports,
})
token.Text = ""
case css_lexer.TFunction:
var nested []css_ast.Token
original := tokens
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, allowImports)
token.Children = &nested
// Treat a URL function call with a string just like a URL token
if token.Text == "url" && len(nested) == 1 && nested[0].Kind == css_lexer.TString {
token.Kind = css_lexer.TURL
token.Text = ""
token.Children = nil
token.ImportRecordIndex = uint32(len(p.importRecords))
p.importRecords = append(p.importRecords, ast.ImportRecord{
Kind: ast.ImportURL,
Path: logger.Path{Text: nested[0].Text},
Range: original[0].Range,
IsUnused: !allowImports,
})
}
case css_lexer.TOpenParen:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, allowImports)
token.Children = &nested
case css_lexer.TOpenBrace:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBrace, allowImports)
token.Children = &nested
case css_lexer.TOpenBracket:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBracket, allowImports)
token.Children = &nested
default:
if t.Kind == close {
break loop
}
}
result = append(result, token)
}
return result, tokens
}
func mangleNumber(t string) (string, bool) {
original := t
if dot := strings.IndexByte(t, '.'); dot != -1 {
// Remove trailing zeros
for len(t) > 0 && t[len(t)-1] == '0' {
t = t[:len(t)-1]
}
// Remove the decimal point if it's unnecessary
if dot+1 == len(t) {
t = t[:dot]
} else {
// Remove a leading zero
if len(t) >= 3 && t[0] == '0' && t[1] == '.' && t[2] >= '0' && t[2] <= '9' {
t = t[1:]
} else if len(t) >= 4 && (t[0] == '+' || t[0] == '-') && t[1] == '0' && t[2] == '.' && t[3] >= '0' && t[3] <= '9' {
t = t[0:1] + t[2:]
}
}
}
return t, t != original
}
func (p *parser) parseSelectorRule() css_ast.R {
preludeStart := p.index
// Try parsing the prelude as a selector list
if list, ok := p.parseSelectorList(); ok {
rule := css_ast.RSelector{Selectors: list}
if p.expect(css_lexer.TOpenBrace) {
rule.Rules = p.parseListOfDeclarations()
p.expect(css_lexer.TCloseBrace)
return &rule
}
}
// Otherwise, parse a generic qualified rule
return p.parseQualifiedRuleFrom(preludeStart, true /* isAlreadyInvalid */)
}
func (p *parser) parseQualifiedRuleFrom(preludeStart int, isAlreadyInvalid bool) *css_ast.RQualified {
loop:
for {
switch p.current().Kind {
case css_lexer.TOpenBrace, css_lexer.TEndOfFile:
break loop
case css_lexer.TSemicolon:
// Error recovery if the block is omitted (likely some CSS meta-syntax)
if !isAlreadyInvalid {
p.expect(css_lexer.TOpenBrace)
}
prelude := p.convertTokens(p.tokens[preludeStart:p.index])
p.advance()
return &css_ast.RQualified{Prelude: prelude}
default:
p.parseComponentValue()
}
}
rule := css_ast.RQualified{
Prelude: p.convertTokens(p.tokens[preludeStart:p.index]),
}
if p.eat(css_lexer.TOpenBrace) {
rule.Rules = p.parseListOfDeclarations()
p.expect(css_lexer.TCloseBrace)
} else if !isAlreadyInvalid {
p.expect(css_lexer.TOpenBrace)
}
return &rule
}
func (p *parser) parseDeclaration() css_ast.R {
// Parse the key
keyStart := p.index
ok := false
if p.expect(css_lexer.TIdent) {
p.eat(css_lexer.TWhitespace)
if p.expect(css_lexer.TColon) {
ok = true
}
}
// Parse the value
valueStart := p.index
stop:
for {
switch p.current().Kind {
case css_lexer.TEndOfFile, css_lexer.TSemicolon, css_lexer.TCloseBrace:
break stop
case css_lexer.TOpenBrace:
// Error recovery if there is an unexpected block (likely some CSS meta-syntax)
p.parseComponentValue()
p.eat(css_lexer.TWhitespace)
if ok && !p.peek(css_lexer.TSemicolon) {
p.expect(css_lexer.TSemicolon)
}
break stop
default:
p.parseComponentValue()
}
}
// Stop now if this is not a valid declaration
if !ok {
return &css_ast.RBadDeclaration{
Tokens: p.convertTokens(p.tokens[keyStart:p.index]),
}
}
// Remove leading and trailing whitespace from the value
value := trimWhitespace(p.tokens[valueStart:p.index])
// Remove trailing "!important"
important := false
if last := len(value) - 1; last >= 0 {
if t := value[last]; t.Kind == css_lexer.TIdent && strings.EqualFold(t.DecodedText(p.source.Contents), "important") {
i := len(value) - 2
if i >= 0 && value[i].Kind == css_lexer.TWhitespace {
i--
}
if i >= 0 && value[i].Kind == css_lexer.TDelimExclamation {
if i >= 1 && value[i-1].Kind == css_lexer.TWhitespace {
i--
}
value = value[:i]
important = true
}
}
}
keyToken := p.tokens[keyStart]
keyText := keyToken.DecodedText(p.source.Contents)
return &css_ast.RDeclaration{
Key: css_ast.KnownDeclarations[keyText],
KeyText: keyText,
KeyRange: keyToken.Range,
Value: p.convertTokensWithImports(value),
Important: important,
}
}
func (p *parser) parseComponentValue() {
switch p.current().Kind {
case css_lexer.TFunction:
p.parseBlock(css_lexer.TFunction, css_lexer.TCloseParen)
case css_lexer.TOpenParen:
p.parseBlock(css_lexer.TOpenParen, css_lexer.TCloseParen)
case css_lexer.TOpenBrace:
p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace)
case css_lexer.TOpenBracket:
p.parseBlock(css_lexer.TOpenBracket, css_lexer.TCloseBracket)
case css_lexer.TEndOfFile:
p.unexpected()
default:
p.advance()
}
}
func (p *parser) parseBlock(open css_lexer.T, close css_lexer.T) {
if p.expect(open) {
for !p.eat(close) {
if p.peek(css_lexer.TEndOfFile) {
p.expect(close)
return
}
p.parseComponentValue()
}
}
}
func trimWhitespace(tokens []css_lexer.Token) []css_lexer.Token {
if len(tokens) > 0 && tokens[0].Kind == css_lexer.TWhitespace {
tokens = tokens[1:]
}
if i := len(tokens) - 1; i >= 0 && tokens[i].Kind == css_lexer.TWhitespace {
tokens = tokens[:i]
}
return tokens
}