Files
esbuild/internal/css_parser/css_parser.go
T

2530 lines
76 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package css_parser
import (
"fmt"
"strings"
"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/compat"
"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
tokens []css_lexer.Token
allComments []logger.Range
legalComments []css_lexer.Comment
stack []css_lexer.T
importRecords []ast.ImportRecord
symbols []ast.Symbol
composes map[ast.Ref]*css_ast.Composes
localSymbols []ast.LocRef
localScope map[string]ast.LocRef
globalScope map[string]ast.LocRef
nestingWarnings map[logger.Loc]struct{}
tracker logger.LineColumnTracker
enclosingAtMedia [][]css_ast.MediaQuery
layersPreImport [][]string
layersPostImport [][]string
enclosingLayer []string
anonLayerCount int
index int
legalCommentIndex int
inSelectorSubtree int
prevError logger.Loc
options Options
nestingIsPresent bool
makeLocalSymbols bool
hasSeenAtImport bool
}
type Options struct {
cssPrefixData map[css_ast.D]compat.CSSPrefix
// This is an embedded struct. Always access these directly instead of off
// the name "optionsThatSupportStructuralEquality". This is only grouped like
// this to make the equality comparison easier and safer (and hopefully faster).
optionsThatSupportStructuralEquality
}
type symbolMode uint8
const (
symbolModeDisabled symbolMode = iota
symbolModeGlobal
symbolModeLocal
)
type optionsThatSupportStructuralEquality struct {
originalTargetEnv string
unsupportedCSSFeatures compat.CSSFeature
minifySyntax bool
minifyWhitespace bool
minifyIdentifiers bool
symbolMode symbolMode
}
func OptionsFromConfig(loader config.Loader, options *config.Options) Options {
var symbolMode symbolMode
switch loader {
case config.LoaderGlobalCSS:
symbolMode = symbolModeGlobal
case config.LoaderLocalCSS:
symbolMode = symbolModeLocal
}
return Options{
cssPrefixData: options.CSSPrefixData,
optionsThatSupportStructuralEquality: optionsThatSupportStructuralEquality{
minifySyntax: options.MinifySyntax,
minifyWhitespace: options.MinifyWhitespace,
minifyIdentifiers: options.MinifyIdentifiers,
unsupportedCSSFeatures: options.UnsupportedCSSFeatures,
originalTargetEnv: options.OriginalTargetEnv,
symbolMode: symbolMode,
},
}
}
func (a *Options) Equal(b *Options) bool {
// Compare "optionsThatSupportStructuralEquality"
if a.optionsThatSupportStructuralEquality != b.optionsThatSupportStructuralEquality {
return false
}
// Compare "cssPrefixData"
if len(a.cssPrefixData) != len(b.cssPrefixData) {
return false
}
for k, va := range a.cssPrefixData {
vb, ok := b.cssPrefixData[k]
if !ok || va != vb {
return false
}
}
for k := range b.cssPrefixData {
if _, ok := b.cssPrefixData[k]; !ok {
return false
}
}
return true
}
func Parse(log logger.Log, source logger.Source, options Options) css_ast.AST {
result := css_lexer.Tokenize(log, source, css_lexer.Options{
RecordAllComments: options.minifyIdentifiers,
})
p := parser{
log: log,
source: source,
tracker: logger.MakeLineColumnTracker(&source),
options: options,
tokens: result.Tokens,
allComments: result.AllComments,
legalComments: result.LegalComments,
prevError: logger.Loc{Start: -1},
composes: make(map[ast.Ref]*css_ast.Composes),
localScope: make(map[string]ast.LocRef),
globalScope: make(map[string]ast.LocRef),
makeLocalSymbols: options.symbolMode == symbolModeLocal,
}
rules := p.parseListOfRules(ruleContext{
isTopLevel: true,
parseSelectors: true,
})
p.expect(css_lexer.TEndOfFile)
return css_ast.AST{
Rules: rules,
CharFreq: p.computeCharacterFrequency(),
Symbols: p.symbols,
ImportRecords: p.importRecords,
ApproximateLineCount: result.ApproximateLineCount,
SourceMapComment: result.SourceMapComment,
LocalSymbols: p.localSymbols,
LocalScope: p.localScope,
GlobalScope: p.globalScope,
Composes: p.composes,
LayersPreImport: p.layersPreImport,
LayersPostImport: p.layersPostImport,
}
}
// Compute a character frequency histogram for everything that's not a bound
// symbol. This is used to modify how minified names are generated for slightly
// better gzip compression. Even though it's a very small win, we still do it
// because it's simple to do and very cheap to compute.
func (p *parser) computeCharacterFrequency() *ast.CharFreq {
if !p.options.minifyIdentifiers {
return nil
}
// Add everything in the file to the histogram
charFreq := &ast.CharFreq{}
charFreq.Scan(p.source.Contents, 1)
// Subtract out all comments
for _, commentRange := range p.allComments {
charFreq.Scan(p.source.TextForRange(commentRange), -1)
}
// Subtract out all import paths
for _, record := range p.importRecords {
if !record.SourceIndex.IsValid() {
charFreq.Scan(record.Path.Text, -1)
}
}
// Subtract out all symbols that will be minified
for _, symbol := range p.symbols {
if symbol.Kind == ast.SymbolLocalCSS {
charFreq.Scan(symbol.OriginalName, -int32(symbol.UseCountEstimate))
}
}
return charFreq
}
func (p *parser) advance() {
if p.index < len(p.tokens) {
p.index++
}
}
func (p *parser) at(index int) css_lexer.Token {
if index < len(p.tokens) {
return p.tokens[index]
}
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 {
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
}
t := p.current()
if (t.Flags & css_lexer.DidWarnAboutSingleLineComment) != 0 {
return false
}
var text string
var suggestion string
var notes []logger.MsgData
expected := kind.String()
if strings.HasPrefix(expected, "\"") && strings.HasSuffix(expected, "\"") {
suggestion = expected[1 : len(expected)-1]
}
if (kind == css_lexer.TSemicolon || kind == css_lexer.TColon) && p.index > 0 && p.at(p.index-1).Kind == css_lexer.TWhitespace {
// 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 && int(matchingLoc.Start)+1 <= len(p.source.Contents) {
// 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:
text = fmt.Sprintf("Expected %s but found %s", expected, t.Kind.String())
t.Range.Len = 0
case css_lexer.TBadURL, css_lexer.TUnterminatedString:
text = fmt.Sprintf("Expected %s but found %s", expected, t.Kind.String())
default:
text = fmt.Sprintf("Expected %s but found %q", expected, p.raw())
}
}
if t.Range.Loc.Start > p.prevError.Start {
data := p.tracker.MsgData(t.Range, text)
data.Location.Suggestion = suggestion
p.log.AddMsgID(logger.MsgID_CSS_CSSSyntaxError, logger.Msg{Kind: logger.Warning, Data: data, Notes: notes})
p.prevError = t.Range.Loc
}
return false
}
func (p *parser) unexpected() {
if t := p.current(); t.Range.Loc.Start > p.prevError.Start && (t.Flags&css_lexer.DidWarnAboutSingleLineComment) == 0 {
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.TUnterminatedString:
text = fmt.Sprintf("Unexpected %s", t.Kind.String())
default:
text = fmt.Sprintf("Unexpected %q", p.raw())
}
p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, t.Range, text)
p.prevError = t.Range.Loc
}
}
func (p *parser) symbolForName(loc logger.Loc, name string) ast.LocRef {
var kind ast.SymbolKind
var scope map[string]ast.LocRef
if p.makeLocalSymbols {
kind = ast.SymbolLocalCSS
scope = p.localScope
} else {
kind = ast.SymbolGlobalCSS
scope = p.globalScope
}
entry, ok := scope[name]
if !ok {
entry = ast.LocRef{
Loc: loc,
Ref: ast.Ref{
SourceIndex: p.source.Index,
InnerIndex: uint32(len(p.symbols)),
},
}
p.symbols = append(p.symbols, ast.Symbol{
Kind: kind,
OriginalName: name,
Link: ast.InvalidRef,
})
scope[name] = entry
if kind == ast.SymbolLocalCSS {
p.localSymbols = append(p.localSymbols, entry)
}
}
p.symbols[entry.Ref.InnerIndex].UseCountEstimate++
return entry
}
func (p *parser) recordAtLayerRule(layers [][]string) {
if p.anonLayerCount > 0 {
return
}
for _, layer := range layers {
if len(p.enclosingLayer) > 0 {
clone := make([]string, 0, len(p.enclosingLayer)+len(layer))
layer = append(append(clone, p.enclosingLayer...), layer...)
}
p.layersPostImport = append(p.layersPostImport, layer)
}
}
type ruleContext struct {
isTopLevel bool
parseSelectors bool
}
func (p *parser) parseListOfRules(context ruleContext) []css_ast.Rule {
atRuleContext := atRuleContext{}
if context.isTopLevel {
atRuleContext.charsetValidity = atRuleValid
atRuleContext.importValidity = atRuleValid
atRuleContext.isTopLevel = true
}
rules := []css_ast.Rule{}
didFindAtImport := false
loop:
for {
if context.isTopLevel {
p.nestingIsPresent = false
}
// If there are any legal comments immediately before the current token,
// turn them all into comment rules and append them to the current rule list
for p.legalCommentIndex < len(p.legalComments) {
comment := p.legalComments[p.legalCommentIndex]
if comment.TokenIndexAfter > uint32(p.index) {
break
}
if comment.TokenIndexAfter == uint32(p.index) {
rules = append(rules, css_ast.Rule{Loc: comment.Loc, Data: &css_ast.RComment{Text: comment.Text}})
}
p.legalCommentIndex++
}
switch p.current().Kind {
case css_lexer.TEndOfFile:
break loop
case css_lexer.TCloseBrace:
if !context.isTopLevel {
break loop
}
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TAtKeyword:
rule := p.parseAtRule(atRuleContext)
// Disallow "@charset" and "@import" after other rules
if context.isTopLevel {
switch r := rule.Data.(type) {
case *css_ast.RAtCharset:
// This doesn't invalidate anything because it always comes first
case *css_ast.RAtImport:
didFindAtImport = true
if atRuleContext.charsetValidity == atRuleValid {
atRuleContext.afterLoc = rule.Loc
atRuleContext.charsetValidity = atRuleInvalidAfter
}
case *css_ast.RAtLayer:
if atRuleContext.charsetValidity == atRuleValid {
atRuleContext.afterLoc = rule.Loc
atRuleContext.charsetValidity = atRuleInvalidAfter
}
// From the specification: "Note: No @layer rules are allowed between
// @import and @namespace rules. Any @layer rule that comes after an
// @import or @namespace rule will cause any subsequent @import or
// @namespace rules to be ignored."
if atRuleContext.importValidity == atRuleValid && (r.Rules != nil || didFindAtImport) {
atRuleContext.afterLoc = rule.Loc
atRuleContext.charsetValidity = atRuleInvalidAfter
atRuleContext.importValidity = atRuleInvalidAfter
}
default:
if atRuleContext.importValidity == atRuleValid {
atRuleContext.afterLoc = rule.Loc
atRuleContext.charsetValidity = atRuleInvalidAfter
atRuleContext.importValidity = atRuleInvalidAfter
}
}
}
// Lower CSS nesting if it's not supported (but only at the top level)
if p.nestingIsPresent && p.options.unsupportedCSSFeatures.Has(compat.Nesting) && context.isTopLevel {
rules = p.lowerNestingInRule(rule, rules)
} else {
rules = append(rules, rule)
}
continue
case css_lexer.TCDO, css_lexer.TCDC:
if context.isTopLevel {
p.advance()
continue
}
}
if atRuleContext.importValidity == atRuleValid {
atRuleContext.afterLoc = p.current().Range.Loc
atRuleContext.charsetValidity = atRuleInvalidAfter
atRuleContext.importValidity = atRuleInvalidAfter
}
// Note: CSS recently changed to parse and discard declarations
// here instead of treating them as the start of a qualified rule.
// See also: https://github.com/w3c/csswg-drafts/issues/8834
if !context.isTopLevel {
if scan, index := p.scanForEndOfRule(); scan == endOfRuleSemicolon {
tokens := p.convertTokens(p.tokens[p.index:index])
rules = append(rules, css_ast.Rule{Loc: p.current().Range.Loc, Data: &css_ast.RBadDeclaration{Tokens: tokens}})
p.index = index + 1
continue
}
}
var rule css_ast.Rule
if context.parseSelectors {
rule = p.parseSelectorRule(context.isTopLevel, parseSelectorOpts{})
} else {
rule = p.parseQualifiedRule(parseQualifiedRuleOpts{isTopLevel: context.isTopLevel})
}
// Lower CSS nesting if it's not supported (but only at the top level)
if p.nestingIsPresent && p.options.unsupportedCSSFeatures.Has(compat.Nesting) && context.isTopLevel {
rules = p.lowerNestingInRule(rule, rules)
} else {
rules = append(rules, rule)
}
}
if p.options.minifySyntax {
rules = p.mangleRules(rules, context.isTopLevel)
}
return rules
}
type listOfDeclarationsOpts struct {
composesContext *composesContext
canInlineNoOpNesting bool
}
func (p *parser) parseListOfDeclarations(opts listOfDeclarationsOpts) (list []css_ast.Rule) {
list = []css_ast.Rule{}
foundNesting := false
for {
switch p.current().Kind {
case css_lexer.TWhitespace, css_lexer.TSemicolon:
p.advance()
case css_lexer.TEndOfFile, css_lexer.TCloseBrace:
list = p.processDeclarations(list, opts.composesContext)
if p.options.minifySyntax {
list = p.mangleRules(list, false /* isTopLevel */)
// Pull out all unnecessarily-nested declarations and stick them at the end
if opts.canInlineNoOpNesting {
// "a { & { x: y } }" => "a { x: y }"
// "a { & { b: c } d: e }" => "a { d: e; b: c }"
if foundNesting {
var inlineDecls []css_ast.Rule
n := 0
for _, rule := range list {
if rule, ok := rule.Data.(*css_ast.RSelector); ok && len(rule.Selectors) == 1 {
if sel := rule.Selectors[0]; len(sel.Selectors) == 1 && sel.Selectors[0].IsSingleAmpersand() {
inlineDecls = append(inlineDecls, rule.Rules...)
continue
}
}
list[n] = rule
n++
}
list = append(list[:n], inlineDecls...)
}
} else {
// "a, b::before { & { x: y } }" => "a, b::before { & { x: y } }"
}
}
return
case css_lexer.TAtKeyword:
if p.inSelectorSubtree > 0 {
p.nestingIsPresent = true
}
list = append(list, p.parseAtRule(atRuleContext{
isDeclarationList: true,
canInlineNoOpNesting: opts.canInlineNoOpNesting,
}))
// Reference: https://drafts.csswg.org/css-nesting-1/
default:
if scan, _ := p.scanForEndOfRule(); scan == endOfRuleOpenBrace {
p.nestingIsPresent = true
foundNesting = true
rule := p.parseSelectorRule(false, parseSelectorOpts{
isDeclarationContext: true,
composesContext: opts.composesContext,
})
// If this rule was a single ":global" or ":local", inline it here. This
// is handled differently than a bare "&" with normal CSS nesting because
// that would be inlined at the end of the parent rule's body instead,
// which is probably unexpected (e.g. it would trip people up when trying
// to write rules in a specific order).
if sel, ok := rule.Data.(*css_ast.RSelector); ok && len(sel.Selectors) == 1 {
if first := sel.Selectors[0]; len(first.Selectors) == 1 {
if first := first.Selectors[0]; first.WasEmptyFromLocalOrGlobal && first.IsSingleAmpersand() {
list = append(list, sel.Rules...)
continue
}
}
}
list = append(list, rule)
} else {
list = append(list, p.parseDeclaration())
}
}
}
}
func (p *parser) mangleRules(rules []css_ast.Rule, isTopLevel bool) []css_ast.Rule {
// Remove empty rules
mangledRules := make([]css_ast.Rule, 0, len(rules))
var prevNonComment css_ast.R
next:
for _, rule := range rules {
nextNonComment := rule.Data
switch r := rule.Data.(type) {
case *css_ast.RAtKeyframes:
// Do not remove empty "@keyframe foo {}" rules. Even empty rules still
// dispatch JavaScript animation events, so removing them changes
// behavior: https://bugzilla.mozilla.org/show_bug.cgi?id=1004377.
case *css_ast.RAtLayer:
if len(r.Rules) == 0 && len(r.Names) > 0 {
// Do not remove empty "@layer foo {}" rules. The specification says:
// "Cascade layers are sorted by the order in which they first are
// declared, with nested layers grouped within their parent layers
// before any unlayered rules." So removing empty rules could change
// the order in which they are first declared, and is therefore invalid.
//
// We can turn "@layer foo {}" into "@layer foo;" to be shorter. But
// don't collapse anonymous "@layer {}" into "@layer;" because that is
// a syntax error.
r.Rules = nil
} else if len(r.Rules) == 1 && len(r.Names) == 1 {
// Only collapse layers if each layer has exactly one name
if r2, ok := r.Rules[0].Data.(*css_ast.RAtLayer); ok && len(r2.Names) == 1 {
// "@layer a { @layer b {} }" => "@layer a.b;"
// "@layer a { @layer b { c {} } }" => "@layer a.b { c {} }"
r.Names[0] = append(r.Names[0], r2.Names[0]...)
r.Rules = r2.Rules
}
}
case *css_ast.RKnownAt:
if len(r.Rules) == 0 && atKnownRuleCanBeRemovedIfEmpty[r.AtToken] {
continue
}
case *css_ast.RAtMedia:
if len(r.Rules) == 0 {
continue
}
// Unwrap "@media" rules that duplicate conditions from a parent "@media"
// rule. This is unlikely to be authored manually but can be automatically
// generated when using a CSS framework such as Tailwind.
//
// @media (min-width: 1024px) {
// .md\:class {
// color: red;
// }
// @media (min-width: 1024px) {
// .md\:class {
// color: red;
// }
// }
// }
//
// This converts that code into the following:
//
// @media (min-width: 1024px) {
// .md\:class {
// color: red;
// }
// .md\:class {
// color: red;
// }
// }
//
// Which can then be mangled further.
for _, queries := range p.enclosingAtMedia {
if css_ast.MediaQueriesEqual(r.Queries, queries, nil) {
mangledRules = append(mangledRules, r.Rules...)
continue next
}
}
case *css_ast.RSelector:
if len(r.Rules) == 0 {
continue
}
// Merge adjacent selectors with the same content
// "a { color: red; } b { color: red; }" => "a, b { color: red; }"
if prevNonComment != nil {
if r, ok := rule.Data.(*css_ast.RSelector); ok {
if prev, ok := prevNonComment.(*css_ast.RSelector); ok && css_ast.RulesEqual(r.Rules, prev.Rules, nil) &&
isSafeSelectors(r.Selectors) && isSafeSelectors(prev.Selectors) {
nextSelector:
for _, sel := range r.Selectors {
for _, prevSel := range prev.Selectors {
if sel.Equal(prevSel, nil) {
// Don't add duplicate selectors more than once
continue nextSelector
}
}
prev.Selectors = append(prev.Selectors, sel)
}
continue
}
}
}
case *css_ast.RComment:
nextNonComment = nil
}
if nextNonComment != nil {
prevNonComment = nextNonComment
}
mangledRules = append(mangledRules, rule)
}
// Mangle non-top-level rules using a back-to-front pass. Top-level rules
// will be mangled by the linker instead for cross-file rule mangling.
if !isTopLevel {
remover := MakeDeadRuleMangler(ast.SymbolMap{})
mangledRules = remover.RemoveDeadRulesInPlace(p.source.Index, mangledRules, p.importRecords)
}
return mangledRules
}
type ruleEntry struct {
data css_ast.R
callCounter uint32
}
type hashEntry struct {
rules []ruleEntry
}
type callEntry struct {
importRecords []ast.ImportRecord
sourceIndex uint32
}
type DeadRuleRemover struct {
entries map[uint32]hashEntry
calls []callEntry
check css_ast.CrossFileEqualityCheck
}
func MakeDeadRuleMangler(symbols ast.SymbolMap) DeadRuleRemover {
return DeadRuleRemover{
entries: make(map[uint32]hashEntry),
check: css_ast.CrossFileEqualityCheck{Symbols: symbols},
}
}
func (remover *DeadRuleRemover) RemoveDeadRulesInPlace(sourceIndex uint32, rules []css_ast.Rule, importRecords []ast.ImportRecord) []css_ast.Rule {
// The caller may call this function multiple times, each with a different
// set of import records. Remember each set of import records for equality
// checks later.
callCounter := uint32(len(remover.calls))
remover.calls = append(remover.calls, callEntry{importRecords, sourceIndex})
// Remove duplicate rules, scanning from the back so we keep the last
// duplicate. Note that the linker calls this, so we do not want to do
// anything that modifies the rules themselves. One reason is that ASTs
// are immutable at the linking stage. Another reason is that merging
// CSS ASTs from separate files will mess up source maps because a single
// AST cannot simultaneously represent offsets from multiple files.
n := len(rules)
start := n
skipRule:
for i := n - 1; i >= 0; i-- {
rule := rules[i]
// Remove rules with selectors that don't apply to anything (e.g. ":is()")
if r, ok := rule.Data.(*css_ast.RSelector); ok && allSelectorsAreDead(r.Selectors) {
continue skipRule
}
// For duplicate rules, omit all but the last copy
if hash, ok := rule.Data.Hash(); ok {
entry := remover.entries[hash]
for _, current := range entry.rules {
var check *css_ast.CrossFileEqualityCheck
// If this rule was from another file, then pass along both arrays
// of import records so that the equality check for "url()" tokens
// can use them to check for equality.
if current.callCounter != callCounter {
// Reuse the same memory allocation
check = &remover.check
call := remover.calls[current.callCounter]
check.ImportRecordsA = importRecords
check.ImportRecordsB = call.importRecords
check.SourceIndexA = sourceIndex
check.SourceIndexB = call.sourceIndex
}
if rule.Data.Equal(current.data, check) {
continue skipRule
}
}
entry.rules = append(entry.rules, ruleEntry{
data: rule.Data,
callCounter: callCounter,
})
remover.entries[hash] = entry
}
start--
rules[start] = rule
}
return rules[start:]
}
func containsDeadSelectors(selectors []css_ast.CompoundSelector) bool {
for _, sel := range selectors {
for _, ss := range sel.SubclassSelectors {
if pseudo, ok := ss.Data.(*css_ast.SSPseudoClassWithSelectorList); ok && len(pseudo.Selectors) == 0 &&
(pseudo.Kind == css_ast.PseudoClassIs || pseudo.Kind == css_ast.PseudoClassWhere) {
// ":is()" and ":where()" never match anything when empty
return true
}
}
}
return false
}
func allSelectorsAreDead(selectors []css_ast.ComplexSelector) bool {
for _, sel := range selectors {
if !containsDeadSelectors(sel.Selectors) {
return false
}
}
return true
}
// Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element
var nonDeprecatedElementsSupportedByIE7 = map[string]bool{
"a": true,
"abbr": true,
"address": true,
"area": true,
"b": true,
"base": true,
"blockquote": true,
"body": true,
"br": true,
"button": true,
"caption": true,
"cite": true,
"code": true,
"col": true,
"colgroup": true,
"dd": true,
"del": true,
"dfn": true,
"div": true,
"dl": true,
"dt": true,
"em": true,
"embed": true,
"fieldset": true,
"form": true,
"h1": true,
"h2": true,
"h3": true,
"h4": true,
"h5": true,
"h6": true,
"head": true,
"hr": true,
"html": true,
"i": true,
"iframe": true,
"img": true,
"input": true,
"ins": true,
"kbd": true,
"label": true,
"legend": true,
"li": true,
"link": true,
"map": true,
"menu": true,
"meta": true,
"noscript": true,
"object": true,
"ol": true,
"optgroup": true,
"option": true,
"p": true,
"param": true,
"pre": true,
"q": true,
"ruby": true,
"s": true,
"samp": true,
"script": true,
"select": true,
"small": true,
"span": true,
"strong": true,
"style": true,
"sub": true,
"sup": true,
"table": true,
"tbody": true,
"td": true,
"textarea": true,
"tfoot": true,
"th": true,
"thead": true,
"title": true,
"tr": true,
"u": true,
"ul": true,
"var": true,
}
// This only returns true if all of these selectors are considered "safe" which
// means that they are very likely to work in any browser a user might reasonably
// be using. We do NOT want to merge adjacent qualified rules with the same body
// if any of the selectors are unsafe, since then browsers which don't support
// that particular feature would ignore the entire merged qualified rule:
//
// Input:
// a { color: red }
// b { color: red }
// input::-moz-placeholder { color: red }
//
// Valid output:
// a, b { color: red }
// input::-moz-placeholder { color: red }
//
// Invalid output:
// a, b, input::-moz-placeholder { color: red }
//
// This considers IE 7 and above to be a browser that a user could possibly use.
// Versions of IE less than 6 are not considered.
func isSafeSelectors(complexSelectors []css_ast.ComplexSelector) bool {
for _, complex := range complexSelectors {
for _, compound := range complex.Selectors {
if len(compound.NestingSelectorLocs) > 0 {
// Bail because this is an extension: https://drafts.csswg.org/css-nesting-1/
return false
}
if compound.Combinator.Byte != 0 {
// "Before Internet Explorer 10, the combinator only works in standards mode"
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors
return false
}
if compound.TypeSelector != nil {
if compound.TypeSelector.NamespacePrefix != nil {
// Bail if we hit a namespace, which doesn't work in IE before version 9
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Type_selectors
return false
}
if compound.TypeSelector.Name.Kind == css_lexer.TIdent && !nonDeprecatedElementsSupportedByIE7[compound.TypeSelector.Name.Text] {
// Bail if this element is either deprecated or not supported in IE 7
return false
}
}
for _, ss := range compound.SubclassSelectors {
switch s := ss.Data.(type) {
case *css_ast.SSAttribute:
if s.MatcherModifier != 0 {
// Bail if we hit a case modifier, which doesn't work in IE at all
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
return false
}
case *css_ast.SSPseudoClass:
// Bail if this pseudo class doesn't match a hard-coded list that's
// known to work everywhere. For example, ":focus" doesn't work in IE 7.
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes
if s.Args == nil && !s.IsElement {
switch s.Name {
case "active", "first-child", "hover", "link", "visited":
continue
}
}
return false
case *css_ast.SSPseudoClassWithSelectorList:
// These definitely don't work in IE 7
return false
}
}
}
}
return true
}
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 strings.EqualFold(p.decoded(), "url") {
matchingLoc := logger.Loc{Start: p.current().Range.End() - 1}
i := p.index + 1
// Skip over whitespace
for p.at(i).Kind == css_lexer.TWhitespace {
i++
}
// Consume a string
if p.at(i).Kind == css_lexer.TString {
stringIndex := i
i++
// Skip over whitespace
for p.at(i).Kind == css_lexer.TWhitespace {
i++
}
// Consume a closing parenthesis
if close := p.at(i).Kind; close == css_lexer.TCloseParen || close == css_lexer.TEndOfFile {
t := p.at(stringIndex)
text := t.DecodedText(p.source.Contents)
p.index = i
p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc)
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
atRuleQualifiedOrEmpty
atRuleEmpty
)
var specialAtRules = map[string]atRuleKind{
"media": atRuleInheritContext,
"supports": atRuleInheritContext,
"font-face": atRuleDeclarations,
"page": atRuleDeclarations,
// These go inside "@page": https://www.w3.org/TR/css-page-3/#syntax-page-selector
"bottom-center": atRuleDeclarations,
"bottom-left-corner": atRuleDeclarations,
"bottom-left": atRuleDeclarations,
"bottom-right-corner": atRuleDeclarations,
"bottom-right": atRuleDeclarations,
"left-bottom": atRuleDeclarations,
"left-middle": atRuleDeclarations,
"left-top": atRuleDeclarations,
"right-bottom": atRuleDeclarations,
"right-middle": atRuleDeclarations,
"right-top": atRuleDeclarations,
"top-center": atRuleDeclarations,
"top-left-corner": atRuleDeclarations,
"top-left": atRuleDeclarations,
"top-right-corner": atRuleDeclarations,
"top-right": atRuleDeclarations,
// These properties are very deprecated and appear to only be useful for
// mobile versions of internet explorer (which may no longer exist?), but
// they are used by the https://ant.design/ design system so we recognize
// them to avoid the warning.
//
// Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@viewport
// Discussion: https://github.com/w3c/csswg-drafts/issues/4766
//
"viewport": atRuleDeclarations,
"-ms-viewport": atRuleDeclarations,
// This feature has been removed from the web because it's actively harmful.
// However, there is one exception where "@-moz-document url-prefix() {" is
// accepted by Firefox to basically be an "if Firefox" conditional rule.
//
// Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@document
// Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1035091
//
"document": atRuleInheritContext,
"-moz-document": atRuleInheritContext,
// This is a new feature that changes how the CSS rule cascade works. It can
// end in either a "{}" block or a ";" rule terminator so we need this special
// case to support both.
//
// Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer
// Motivation: https://developer.chrome.com/blog/cascade-layers/
//
"layer": atRuleQualifiedOrEmpty,
// Reference: https://drafts.csswg.org/css-cascade-6/#scoped-styles
"scope": atRuleInheritContext,
// Reference: https://drafts.csswg.org/css-fonts-4/#font-palette-values
"font-palette-values": atRuleDeclarations,
// Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@counter-style
// Reference: https://drafts.csswg.org/css-counter-styles/#the-counter-style-rule
"counter-style": atRuleDeclarations,
// Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-feature-values
// Reference: https://drafts.csswg.org/css-fonts/#font-feature-values
"font-feature-values": atRuleDeclarations,
"annotation": atRuleDeclarations,
"character-variant": atRuleDeclarations,
"historical-forms": atRuleDeclarations,
"ornaments": atRuleDeclarations,
"styleset": atRuleDeclarations,
"stylistic": atRuleDeclarations,
"swash": atRuleDeclarations,
// Container Queries
// Reference: https://drafts.csswg.org/css-contain-3/#container-rule
"container": atRuleInheritContext,
// Defining before-change style: the @starting-style rule
// Reference: https://drafts.csswg.org/css-transitions-2/#defining-before-change-style-the-starting-style-rule
"starting-style": atRuleInheritContext,
// Anchor Positioning
// Reference: https://drafts.csswg.org/css-anchor-position-1/#at-ruledef-position-try
"position-try": atRuleDeclarations,
// @view-transition
// Reference: https://drafts.csswg.org/css-view-transitions-2/#view-transition-rule
"view-transition": atRuleDeclarations,
}
var atKnownRuleCanBeRemovedIfEmpty = map[string]bool{
"media": true,
"supports": true,
"font-face": true,
"page": true,
// https://www.w3.org/TR/css-page-3/#syntax-page-selector
"bottom-center": true,
"bottom-left-corner": true,
"bottom-left": true,
"bottom-right-corner": true,
"bottom-right": true,
"left-bottom": true,
"left-middle": true,
"left-top": true,
"right-bottom": true,
"right-middle": true,
"right-top": true,
"top-center": true,
"top-left-corner": true,
"top-left": true,
"top-right-corner": true,
"top-right": true,
// https://drafts.csswg.org/css-cascade-6/#scoped-styles
"scope": true,
// https://drafts.csswg.org/css-fonts-4/#font-palette-values
"font-palette-values": true,
// https://drafts.csswg.org/css-contain-3/#container-rule
"container": true,
}
type atRuleValidity uint8
const (
atRuleInvalid atRuleValidity = iota
atRuleValid
atRuleInvalidAfter
)
type atRuleContext struct {
afterLoc logger.Loc
charsetValidity atRuleValidity
importValidity atRuleValidity
canInlineNoOpNesting bool
isDeclarationList bool
isTopLevel bool
}
func (p *parser) parseAtRule(context atRuleContext) css_ast.Rule {
// Parse the name
atToken := p.decoded()
atRange := p.current().Range
lowerAtToken := strings.ToLower(atToken)
kind := specialAtRules[lowerAtToken]
p.advance()
// Parse the prelude
preludeStart := p.index
abortRuleParser:
switch lowerAtToken {
case "charset":
switch context.charsetValidity {
case atRuleInvalid:
p.log.AddID(logger.MsgID_CSS_InvalidAtCharset, logger.Warning, &p.tracker, atRange, "\"@charset\" must be the first rule in the file")
case atRuleInvalidAfter:
p.log.AddIDWithNotes(logger.MsgID_CSS_InvalidAtCharset, logger.Warning, &p.tracker, atRange,
"\"@charset\" must be the first rule in the file",
[]logger.MsgData{p.tracker.MsgData(logger.Range{Loc: context.afterLoc},
"This rule cannot come before a \"@charset\" rule")})
case atRuleValid:
kind = atRuleEmpty
p.expect(css_lexer.TWhitespace)
if p.peek(css_lexer.TString) {
encoding := p.decoded()
if !strings.EqualFold(encoding, "UTF-8") {
p.log.AddID(logger.MsgID_CSS_UnsupportedAtCharset, logger.Warning, &p.tracker, 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.Rule{Loc: atRange.Loc, Data: &css_ast.RAtCharset{Encoding: encoding}}
}
p.expect(css_lexer.TString)
}
case "import":
switch context.importValidity {
case atRuleInvalid:
p.log.AddID(logger.MsgID_CSS_InvalidAtImport, logger.Warning, &p.tracker, atRange, "\"@import\" is only valid at the top level")
case atRuleInvalidAfter:
p.log.AddIDWithNotes(logger.MsgID_CSS_InvalidAtImport, logger.Warning, &p.tracker, atRange,
"All \"@import\" rules must come first",
[]logger.MsgData{p.tracker.MsgData(logger.Range{Loc: context.afterLoc},
"This rule cannot come before an \"@import\" rule")})
case atRuleValid:
kind = atRuleEmpty
p.eat(css_lexer.TWhitespace)
if path, r, ok := p.expectURLOrString(); ok {
var conditions css_ast.ImportConditions
importConditionsStart := p.index
// Parse the optional "layer()"
p.eat(css_lexer.TWhitespace)
if (p.peek(css_lexer.TIdent) || p.peek(css_lexer.TFunction)) && strings.EqualFold(p.decoded(), "layer") {
p.parseComponentValue()
conditions.Layers = p.convertTokens(p.tokens[importConditionsStart:p.index])
importConditionsStart = p.index
// Remove leading and trailing whitespace
if len(conditions.Layers) > 0 {
conditions.Layers[0].Whitespace &= ^(css_ast.WhitespaceBefore | css_ast.WhitespaceAfter)
}
}
// Parse the optional "supports()"
p.eat(css_lexer.TWhitespace)
if p.peek(css_lexer.TFunction) && strings.EqualFold(p.decoded(), "supports") {
p.parseComponentValue()
conditions.Supports = p.convertTokens(p.tokens[importConditionsStart:p.index])
importConditionsStart = p.index
// Remove leading and trailing whitespace
if len(conditions.Supports) > 0 {
conditions.Supports[0].Whitespace &= ^(css_ast.WhitespaceBefore | css_ast.WhitespaceAfter)
}
}
// Parse the optional media query list
conditions.Queries = p.parseMediaQueryListUntil(func(kind css_lexer.T) bool {
return kind == css_lexer.TSemicolon || kind == css_lexer.TOpenBrace ||
kind == css_lexer.TCloseBrace || kind == css_lexer.TEndOfFile
})
if p.peek(css_lexer.TOpenBrace) {
break // Avoid parsing an invalid "@import" rule
}
// Check whether any import conditions are present
var importConditions *css_ast.ImportConditions
if len(conditions.Layers) > 0 || len(conditions.Supports) > 0 || len(conditions.Queries) > 0 {
importConditions = &conditions
}
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,
})
// Fill in the pre-import layers once we see the first "@import"
if !p.hasSeenAtImport {
p.hasSeenAtImport = true
p.layersPreImport = p.layersPostImport
p.layersPostImport = nil
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtImport{
ImportRecordIndex: importRecordIndex,
ImportConditions: importConditions,
}}
}
}
case "keyframes", "-webkit-keyframes", "-moz-keyframes", "-ms-keyframes", "-o-keyframes":
p.eat(css_lexer.TWhitespace)
nameLoc := p.current().Range.Loc
var name string
if p.peek(css_lexer.TIdent) {
name = p.decoded()
if isInvalidAnimationName(name) {
msg := logger.Msg{
ID: logger.MsgID_CSS_CSSSyntaxError,
Kind: logger.Warning,
Data: p.tracker.MsgData(p.current().Range, fmt.Sprintf("Cannot use %q as a name for \"@keyframes\" without quotes", name)),
Notes: []logger.MsgData{{Text: fmt.Sprintf("You can put %q in quotes to prevent it from becoming a CSS keyword.", name)}},
}
msg.Data.Location.Suggestion = fmt.Sprintf("%q", name)
p.log.AddMsg(msg)
break
}
p.advance()
} else if p.peek(css_lexer.TString) {
// Note: Strings as names is allowed in the CSS specification and works in
// Firefox and Safari but Chrome has strangely decided to deliberately not
// support this. We always turn all string names into identifiers to avoid
// them silently breaking in Chrome.
name = p.decoded()
p.advance()
if !p.makeLocalSymbols && isInvalidAnimationName(name) {
break
}
} else if !p.expect(css_lexer.TIdent) {
break
}
p.eat(css_lexer.TWhitespace)
blockStart := p.index
matchingLoc := p.current().Range.Loc
if p.expect(css_lexer.TOpenBrace) {
var blocks []css_ast.KeyframeBlock
badSyntax:
for {
switch p.current().Kind {
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TCloseBrace:
closeBraceLoc := p.current().Range.Loc
p.advance()
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtKeyframes{
AtToken: atToken,
Name: p.symbolForName(nameLoc, name),
Blocks: blocks,
CloseBraceLoc: closeBraceLoc,
}}
case css_lexer.TEndOfFile:
break badSyntax
case css_lexer.TOpenBrace:
p.expect(css_lexer.TPercentage)
break badSyntax
default:
var selectors []string
var firstSelectorLoc logger.Loc
selectors:
for {
t := p.current()
switch t.Kind {
case css_lexer.TWhitespace:
p.advance()
continue
case css_lexer.TOpenBrace:
blockMatchingLoc := p.current().Range.Loc
p.advance()
rules := p.parseListOfDeclarations(listOfDeclarationsOpts{})
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, blockMatchingLoc) {
closeBraceLoc = logger.Loc{}
}
// "@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,
Loc: firstSelectorLoc,
CloseBraceLoc: closeBraceLoc,
})
}
break selectors
case css_lexer.TCloseBrace, css_lexer.TEndOfFile:
p.expect(css_lexer.TOpenBrace)
break badSyntax
case css_lexer.TIdent, css_lexer.TPercentage:
if firstSelectorLoc.Start == 0 {
firstSelectorLoc = p.current().Range.Loc
}
text := p.decoded()
if t.Kind == css_lexer.TIdent {
if strings.EqualFold(text, "from") {
if p.options.minifySyntax {
text = "0%" // "0%" is equivalent to but shorter than "from"
}
} else if !strings.EqualFold(text, "to") {
p.expect(css_lexer.TPercentage)
}
} else if p.options.minifySyntax && text == "100%" {
text = "to" // "to" is equivalent to but shorter than "100%"
}
selectors = append(selectors, text)
p.advance()
// Keyframe selectors are comma-separated
p.eat(css_lexer.TWhitespace)
if p.eat(css_lexer.TComma) {
p.eat(css_lexer.TWhitespace)
if k := p.current().Kind; k != css_lexer.TIdent && k != css_lexer.TPercentage {
p.expect(css_lexer.TPercentage)
break badSyntax
}
} else if k := p.current().Kind; k != css_lexer.TOpenBrace && k != css_lexer.TCloseBrace && k != css_lexer.TEndOfFile {
p.expect(css_lexer.TComma)
break badSyntax
}
default:
p.expect(css_lexer.TPercentage)
break badSyntax
}
}
}
}
// Otherwise, finish parsing the body and return an unknown rule
for !p.peek(css_lexer.TCloseBrace) && !p.peek(css_lexer.TEndOfFile) {
p.parseComponentValue()
}
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}}
}
case "layer":
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer
// Read the layer name list
var names [][]string
p.eat(css_lexer.TWhitespace)
if p.peek(css_lexer.TIdent) {
for {
ident, ok := p.expectValidLayerNameIdent()
if !ok {
break abortRuleParser
}
name := []string{ident}
for {
p.eat(css_lexer.TWhitespace)
if !p.eat(css_lexer.TDelimDot) {
break
}
p.eat(css_lexer.TWhitespace)
ident, ok := p.expectValidLayerNameIdent()
if !ok {
break abortRuleParser
}
name = append(name, ident)
}
names = append(names, name)
p.eat(css_lexer.TWhitespace)
if !p.eat(css_lexer.TComma) {
break
}
p.eat(css_lexer.TWhitespace)
}
}
// Read the optional block
matchingLoc := p.current().Range.Loc
if len(names) <= 1 && p.eat(css_lexer.TOpenBrace) {
p.recordAtLayerRule(names)
oldEnclosingLayer := p.enclosingLayer
if len(names) == 1 {
p.enclosingLayer = append(p.enclosingLayer, names[0]...)
} else {
p.anonLayerCount++
}
// Parse the block for this rule
var rules []css_ast.Rule
if context.isDeclarationList {
rules = p.parseListOfDeclarations(listOfDeclarationsOpts{
canInlineNoOpNesting: context.canInlineNoOpNesting,
})
} else {
rules = p.parseListOfRules(ruleContext{
parseSelectors: true,
})
}
if len(names) != 1 {
p.anonLayerCount--
}
p.enclosingLayer = oldEnclosingLayer
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names, Rules: rules, CloseBraceLoc: closeBraceLoc}}
}
// Handle lack of a block
if len(names) >= 1 && p.eat(css_lexer.TSemicolon) {
p.recordAtLayerRule(names)
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}}
}
// Otherwise there's some kind of syntax error
switch p.current().Kind {
case css_lexer.TEndOfFile:
p.expect(css_lexer.TSemicolon)
p.recordAtLayerRule(names)
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}}
case css_lexer.TCloseBrace:
p.expect(css_lexer.TSemicolon)
if !context.isTopLevel {
p.recordAtLayerRule(names)
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}}
}
case css_lexer.TOpenBrace:
p.expect(css_lexer.TSemicolon)
default:
p.unexpected()
}
case "media":
queries := p.parseMediaQueryListUntil(func(kind css_lexer.T) bool {
return kind == css_lexer.TOpenBrace
})
// Expect a block after the query
matchingLoc := p.current().Range.Loc
if !p.expect(css_lexer.TOpenBrace) {
break
}
// Push the "@media" conditions
p.enclosingAtMedia = append(p.enclosingAtMedia, queries)
// Parse the block for this rule
var rules []css_ast.Rule
if context.isDeclarationList {
rules = p.parseListOfDeclarations(listOfDeclarationsOpts{
canInlineNoOpNesting: context.canInlineNoOpNesting,
})
} else {
rules = p.parseListOfRules(ruleContext{
parseSelectors: true,
})
}
// Pop the "@media" conditions
p.enclosingAtMedia = p.enclosingAtMedia[:len(p.enclosingAtMedia)-1]
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtMedia{Queries: queries, Rules: rules, CloseBraceLoc: closeBraceLoc}}
case "scope":
var ok bool
// Parse the start limit
var start []css_ast.ComplexSelector
p.eat(css_lexer.TWhitespace)
if p.eat(css_lexer.TOpenParen) {
if start, ok = p.parseSelectorList(parseSelectorOpts{stopOnCloseParen: true}); !ok || !p.expect(css_lexer.TCloseParen) {
break
}
p.eat(css_lexer.TWhitespace)
}
// Parse the end limit
var end []css_ast.ComplexSelector
if strings.EqualFold(p.decoded(), "to") && p.eat(css_lexer.TIdent) {
p.eat(css_lexer.TWhitespace)
if !p.expect(css_lexer.TOpenParen) {
break
}
if end, ok = p.parseSelectorList(parseSelectorOpts{stopOnCloseParen: true}); !ok || !p.expect(css_lexer.TCloseParen) {
break
}
}
p.eat(css_lexer.TWhitespace)
// Expect a block after the query
matchingLoc := p.current().Range.Loc
if !p.expect(css_lexer.TOpenBrace) {
break
}
// Parse the block for this rule
var rules []css_ast.Rule
if context.isDeclarationList {
rules = p.parseListOfDeclarations(listOfDeclarationsOpts{
canInlineNoOpNesting: context.canInlineNoOpNesting,
})
} else {
rules = p.parseListOfRules(ruleContext{
parseSelectors: true,
})
}
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtScope{Start: start, End: end, Rules: rules, CloseBraceLoc: closeBraceLoc}}
default:
if kind == atRuleUnknown && lowerAtToken == "namespace" {
// CSS namespaces are a weird feature that appears to only really be
// useful for styling XML. And the world has moved on from XHTML to
// HTML5 so pretty much no one uses CSS namespaces anymore. They are
// also complicated to support in a bundler because CSS namespaces are
// file-scoped, which means:
//
// * Default namespaces can be different in different files, in which
// case some default namespaces would have to be converted to prefixed
// namespaces to avoid collisions.
//
// * Prefixed namespaces from different files can use the same name, in
// which case some prefixed namespaces would need to be renamed to
// avoid collisions.
//
// Instead of implementing all of that for an extremely obscure feature,
// CSS namespaces are just explicitly not supported.
p.log.AddID(logger.MsgID_CSS_UnsupportedAtNamespace, logger.Warning, &p.tracker, atRange, "\"@namespace\" rules are not supported")
}
}
// Parse an unknown prelude
p.index = preludeStart
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])
switch kind {
case atRuleQualifiedOrEmpty:
// Parse a known at rule below
break prelude
case atRuleEmpty, atRuleUnknown:
// Parse an unknown at rule
p.expect(css_lexer.TSemicolon)
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude}}
default:
// Report an error for rules that should have blocks
p.expect(css_lexer.TOpenBrace)
p.eat(css_lexer.TSemicolon)
return css_ast.Rule{Loc: atRange.Loc, Data: &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.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}}
case atRuleDeclarations:
// Parse known rules whose blocks always consist of declarations
matchingLoc := p.current().Range.Loc
p.expect(css_lexer.TOpenBrace)
rules := p.parseListOfDeclarations(listOfDeclarationsOpts{})
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
// Handle local names for "@counter-style"
if len(prelude) == 1 && lowerAtToken == "counter-style" {
if t := &prelude[0]; t.Kind == css_lexer.TIdent {
t.Kind = css_lexer.TSymbol
t.PayloadIndex = p.symbolForName(t.Loc, t.Text).Ref.InnerIndex
}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}}
case atRuleInheritContext:
// Parse known rules whose blocks consist of whatever the current context is
matchingLoc := p.current().Range.Loc
p.expect(css_lexer.TOpenBrace)
// Parse the block for this rule
var rules []css_ast.Rule
if context.isDeclarationList {
rules = p.parseListOfDeclarations(listOfDeclarationsOpts{
canInlineNoOpNesting: context.canInlineNoOpNesting,
})
} else {
rules = p.parseListOfRules(ruleContext{
parseSelectors: true,
})
}
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
// Handle local names for "@container"
if len(prelude) >= 1 && lowerAtToken == "container" {
if t := &prelude[0]; t.Kind == css_lexer.TIdent && !strings.EqualFold(t.Text, "not") {
t.Kind = css_lexer.TSymbol
t.PayloadIndex = p.symbolForName(t.Loc, t.Text).Ref.InnerIndex
}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}}
case atRuleQualifiedOrEmpty:
matchingLoc := p.current().Range.Loc
if p.eat(css_lexer.TOpenBrace) {
rules := p.parseListOfRules(ruleContext{
parseSelectors: true,
})
closeBraceLoc := p.current().Range.Loc
if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
closeBraceLoc = logger.Loc{}
}
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}}
}
p.expect(css_lexer.TSemicolon)
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude}}
default:
// Otherwise, parse an unknown rule
p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace)
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}}
}
}
func (p *parser) expectValidLayerNameIdent() (string, bool) {
r := p.current().Range
text := p.decoded()
if !p.expect(css_lexer.TIdent) {
return "", false
}
switch text {
case "initial", "inherit", "unset":
p.log.AddID(logger.MsgID_CSS_InvalidAtLayer, logger.Warning, &p.tracker, r, fmt.Sprintf("%q cannot be used as a layer name", text))
p.prevError = r.Loc
return "", false
}
return text, true
}
func (p *parser) convertTokens(tokens []css_lexer.Token) []css_ast.Token {
result, _ := p.convertTokensHelper(tokens, css_lexer.TEndOfFile, convertTokensOpts{})
return result
}
type convertTokensOpts struct {
allowImports bool
verbatimWhitespace bool
isInsideCalcFunction bool
}
func (p *parser) convertTokensHelper(tokens []css_lexer.Token, close css_lexer.T, opts convertTokensOpts) ([]css_ast.Token, []css_lexer.Token) {
result := []css_ast.Token{}
var nextWhitespace css_ast.WhitespaceFlags
// Enable verbatim whitespace mode when the first two non-whitespace tokens
// are a CSS variable name followed by a colon. This is because it could be
// a form of CSS variable usage, and removing whitespace could potentially
// break this usage. For example, the following CSS is ignored by Chrome if
// the whitespace isn't preserved:
//
// @supports (--foo: ) {
// html { background: green; }
// }
//
// Strangely whitespace removal doesn't cause the declaration to be ignored
// in Firefox or Safari, so there's definitely a browser bug somewhere.
if !opts.verbatimWhitespace {
for i, t := range tokens {
if t.Kind == css_lexer.TWhitespace {
continue
}
if t.Kind == css_lexer.TIdent && strings.HasPrefix(t.DecodedText(p.source.Contents), "--") {
for _, t := range tokens[i+1:] {
if t.Kind == css_lexer.TWhitespace {
continue
}
if t.Kind == css_lexer.TColon {
opts.verbatimWhitespace = true
}
break
}
}
break
}
}
loop:
for len(tokens) > 0 {
t := tokens[0]
tokens = tokens[1:]
if t.Kind == close {
break loop
}
token := css_ast.Token{
Loc: t.Range.Loc,
Kind: t.Kind,
Text: t.DecodedText(p.source.Contents),
Whitespace: nextWhitespace,
}
nextWhitespace = 0
// Warn about invalid "+" and "-" operators that break the containing "calc()"
if opts.isInsideCalcFunction && t.Kind.IsNumeric() && len(result) > 0 && result[len(result)-1].Kind.IsNumeric() &&
(strings.HasPrefix(token.Text, "+") || strings.HasPrefix(token.Text, "-")) {
// "calc(1+2)" and "calc(1-2)" are invalid
p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, logger.Range{Loc: t.Range.Loc, Len: 1},
fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text[:1]))
}
switch t.Kind {
case css_lexer.TWhitespace:
if last := len(result) - 1; last >= 0 {
result[last].Whitespace |= css_ast.WhitespaceAfter
}
nextWhitespace = css_ast.WhitespaceBefore
continue
case css_lexer.TDelimPlus, css_lexer.TDelimMinus:
// Warn about invalid "+" and "-" operators that break the containing "calc()"
if opts.isInsideCalcFunction && len(tokens) > 0 {
if len(result) == 0 || result[len(result)-1].Kind == css_lexer.TComma {
// "calc(-(1 + 2))" is invalid
p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, t.Range,
fmt.Sprintf("%q can only be used as an infix operator, not a prefix operator", token.Text))
} else if token.Whitespace != css_ast.WhitespaceBefore || tokens[0].Kind != css_lexer.TWhitespace {
// "calc(1- 2)" and "calc(1 -(2))" are invalid
p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, t.Range,
fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text))
}
}
case css_lexer.TNumber:
if p.options.minifySyntax {
if text, ok := mangleNumber(token.Text); ok {
token.Text = text
}
}
case css_lexer.TPercentage:
if p.options.minifySyntax {
if text, ok := mangleNumber(token.PercentageValue()); ok {
token.Text = text + "%"
}
}
case css_lexer.TDimension:
token.UnitOffset = t.UnitOffset
if p.options.minifySyntax {
if text, ok := mangleNumber(token.DimensionValue()); ok {
token.Text = text + token.DimensionUnit()
token.UnitOffset = uint16(len(text))
}
if value, unit, ok := mangleDimension(token.DimensionValue(), token.DimensionUnit()); ok {
token.Text = value + unit
token.UnitOffset = uint16(len(value))
}
}
case css_lexer.TURL:
token.PayloadIndex = uint32(len(p.importRecords))
var flags ast.ImportRecordFlags
if !opts.allowImports {
flags |= ast.IsUnused
}
p.importRecords = append(p.importRecords, ast.ImportRecord{
Kind: ast.ImportURL,
Path: logger.Path{Text: token.Text},
Range: t.Range,
Flags: flags,
})
token.Text = ""
case css_lexer.TFunction:
var nested []css_ast.Token
original := tokens
nestedOpts := opts
if strings.EqualFold(token.Text, "var") {
// CSS variables require verbatim whitespace for correctness
nestedOpts.verbatimWhitespace = true
}
if strings.EqualFold(token.Text, "calc") {
nestedOpts.isInsideCalcFunction = true
}
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, nestedOpts)
token.Children = &nested
// Apply "calc" simplification rules when minifying
if p.options.minifySyntax && strings.EqualFold(token.Text, "calc") {
token = p.tryToReduceCalcExpression(token)
}
// Treat a URL function call with a string just like a URL token
if strings.EqualFold(token.Text, "url") && len(nested) == 1 && nested[0].Kind == css_lexer.TString {
token.Kind = css_lexer.TURL
token.Text = ""
token.Children = nil
token.PayloadIndex = uint32(len(p.importRecords))
var flags ast.ImportRecordFlags
if !opts.allowImports {
flags |= ast.IsUnused
}
p.importRecords = append(p.importRecords, ast.ImportRecord{
Kind: ast.ImportURL,
Path: logger.Path{Text: nested[0].Text},
Range: original[0].Range,
Flags: flags,
})
}
case css_lexer.TOpenParen:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, opts)
token.Children = &nested
case css_lexer.TOpenBrace:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBrace, opts)
// Pretty-printing: insert leading and trailing whitespace when not minifying
if !opts.verbatimWhitespace && !p.options.minifyWhitespace && len(nested) > 0 {
nested[0].Whitespace |= css_ast.WhitespaceBefore
nested[len(nested)-1].Whitespace |= css_ast.WhitespaceAfter
}
token.Children = &nested
case css_lexer.TOpenBracket:
var nested []css_ast.Token
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBracket, opts)
token.Children = &nested
}
result = append(result, token)
}
if !opts.verbatimWhitespace {
for i := range result {
token := &result[i]
// Always remove leading and trailing whitespace
if i == 0 {
token.Whitespace &= ^css_ast.WhitespaceBefore
}
if i+1 == len(result) {
token.Whitespace &= ^css_ast.WhitespaceAfter
}
switch token.Kind {
case css_lexer.TComma:
// Assume that whitespace can always be removed before a comma
token.Whitespace &= ^css_ast.WhitespaceBefore
if i > 0 {
result[i-1].Whitespace &= ^css_ast.WhitespaceAfter
}
// Assume whitespace can always be added after a comma
if p.options.minifyWhitespace {
token.Whitespace &= ^css_ast.WhitespaceAfter
if i+1 < len(result) {
result[i+1].Whitespace &= ^css_ast.WhitespaceBefore
}
} else {
token.Whitespace |= css_ast.WhitespaceAfter
if i+1 < len(result) {
result[i+1].Whitespace |= css_ast.WhitespaceBefore
}
}
}
}
}
// Insert an explicit whitespace token if we're in verbatim mode and all
// tokens were whitespace. In this case there is no token to attach the
// whitespace before/after flags so this is the only way to represent this.
// This is the only case where this function generates an explicit whitespace
// token. It represents whitespace as flags in all other cases.
if opts.verbatimWhitespace && len(result) == 0 && nextWhitespace == css_ast.WhitespaceBefore {
result = append(result, css_ast.Token{
Kind: css_lexer.TWhitespace,
})
}
return result, tokens
}
func shiftDot(text string, dotOffset int) (string, bool) {
// This doesn't handle numbers with exponents
if strings.ContainsAny(text, "eE") {
return "", false
}
// Handle a leading sign
sign := ""
if len(text) > 0 && (text[0] == '-' || text[0] == '+') {
sign = text[:1]
text = text[1:]
}
// Remove the dot
dot := strings.IndexByte(text, '.')
if dot == -1 {
dot = len(text)
} else {
text = text[:dot] + text[dot+1:]
}
// Move the dot
dot += dotOffset
// Remove any leading zeros before the dot
for len(text) > 0 && dot > 0 && text[0] == '0' {
text = text[1:]
dot--
}
// Remove any trailing zeros after the dot
for len(text) > 0 && len(text) > dot && text[len(text)-1] == '0' {
text = text[:len(text)-1]
}
// Does this number have no fractional component?
if dot >= len(text) {
trailingZeros := strings.Repeat("0", dot-len(text))
return fmt.Sprintf("%s%s%s", sign, text, trailingZeros), true
}
// Potentially add leading zeros
if dot < 0 {
text = strings.Repeat("0", -dot) + text
dot = 0
}
// Insert the dot again
return fmt.Sprintf("%s%s.%s", sign, text[:dot], text[dot:]), true
}
func mangleDimension(value string, unit string) (string, string, bool) {
const msLen = 2
const sLen = 1
// Mangle times: https://developer.mozilla.org/en-US/docs/Web/CSS/time
if strings.EqualFold(unit, "ms") {
if shifted, ok := shiftDot(value, -3); ok && len(shifted)+sLen < len(value)+msLen {
// Convert "ms" to "s" if shorter
return shifted, "s", true
}
}
if strings.EqualFold(unit, "s") {
if shifted, ok := shiftDot(value, 3); ok && len(shifted)+msLen < len(value)+sLen {
// Convert "s" to "ms" if shorter
return shifted, "ms", true
}
}
return "", "", false
}
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]
if t == "" || t == "+" || t == "-" {
t += "0"
}
} 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(isTopLevel bool, opts parseSelectorOpts) css_ast.Rule {
// Save and restore the local symbol state in case there are any bare
// ":global" or ":local" annotations. The effect of these should be scoped
// to within the selector rule.
local := p.makeLocalSymbols
preludeStart := p.index
// Try parsing the prelude as a selector list
if list, ok := p.parseSelectorList(opts); ok {
canInlineNoOpNesting := true
for _, sel := range list {
// We cannot transform the CSS "a, b::before { & { color: red } }" into
// "a, b::before { color: red }" because it's basically equivalent to
// ":is(a, b::before) { color: red }" which only applies to "a", not to
// "b::before" because pseudo-elements are not valid within :is():
// https://www.w3.org/TR/selectors-4/#matches-pseudo. This restriction
// may be relaxed in the future, but this restriction hash shipped so
// we're stuck with it: https://github.com/w3c/csswg-drafts/issues/7433.
if sel.UsesPseudoElement() {
canInlineNoOpNesting = false
break
}
}
selector := css_ast.RSelector{Selectors: list}
matchingLoc := p.current().Range.Loc
if p.expect(css_lexer.TOpenBrace) {
p.inSelectorSubtree++
declOpts := listOfDeclarationsOpts{
canInlineNoOpNesting: canInlineNoOpNesting,
}
// Prepare for "composes" declarations
if opts.composesContext != nil && len(list) == 1 && len(list[0].Selectors) == 1 && list[0].Selectors[0].IsSingleAmpersand() {
// Support code like this:
//
// .foo {
// :local { composes: bar }
// :global { composes: baz }
// }
//
declOpts.composesContext = opts.composesContext
} else {
composesContext := composesContext{parentRange: list[0].Selectors[0].Range()}
if opts.composesContext != nil {
composesContext.problemRange = opts.composesContext.parentRange
}
for _, sel := range list {
first := sel.Selectors[0]
if first.Combinator.Byte != 0 {
composesContext.problemRange = logger.Range{Loc: first.Combinator.Loc, Len: 1}
} else if first.TypeSelector != nil {
composesContext.problemRange = first.TypeSelector.Range()
} else if len(first.NestingSelectorLocs) > 0 {
composesContext.problemRange = logger.Range{Loc: first.NestingSelectorLocs[0], Len: 1}
} else {
for i, ss := range first.SubclassSelectors {
class, ok := ss.Data.(*css_ast.SSClass)
if i > 0 || !ok {
composesContext.problemRange = ss.Range
} else {
composesContext.parentRefs = append(composesContext.parentRefs, class.Name.Ref)
}
}
}
if composesContext.problemRange.Len > 0 {
break
}
if len(sel.Selectors) > 1 {
composesContext.problemRange = sel.Selectors[1].Range()
break
}
}
declOpts.composesContext = &composesContext
}
selector.Rules = p.parseListOfDeclarations(declOpts)
p.inSelectorSubtree--
closeBraceLoc := p.current().Range.Loc
if p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
selector.CloseBraceLoc = closeBraceLoc
}
p.makeLocalSymbols = local
return css_ast.Rule{Loc: p.tokens[preludeStart].Range.Loc, Data: &selector}
}
}
p.makeLocalSymbols = local
p.index = preludeStart
// Otherwise, parse a generic qualified rule
return p.parseQualifiedRule(parseQualifiedRuleOpts{
isAlreadyInvalid: true,
isTopLevel: isTopLevel,
isDeclarationContext: opts.isDeclarationContext,
})
}
type parseQualifiedRuleOpts struct {
isAlreadyInvalid bool
isTopLevel bool
isDeclarationContext bool
}
func (p *parser) parseQualifiedRule(opts parseQualifiedRuleOpts) css_ast.Rule {
preludeStart := p.index
preludeLoc := p.current().Range.Loc
loop:
for {
switch p.current().Kind {
case css_lexer.TOpenBrace, css_lexer.TEndOfFile:
break loop
case css_lexer.TCloseBrace:
if !opts.isTopLevel {
break loop
}
case css_lexer.TSemicolon:
if opts.isDeclarationContext {
return css_ast.Rule{Loc: preludeLoc, Data: &css_ast.RBadDeclaration{
Tokens: p.convertTokens(p.tokens[preludeStart:p.index]),
}}
}
}
p.parseComponentValue()
}
qualified := css_ast.RQualified{
Prelude: p.convertTokens(p.tokens[preludeStart:p.index]),
}
matchingLoc := p.current().Range.Loc
if p.eat(css_lexer.TOpenBrace) {
qualified.Rules = p.parseListOfDeclarations(listOfDeclarationsOpts{})
closeBraceLoc := p.current().Range.Loc
if p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) {
qualified.CloseBraceLoc = closeBraceLoc
}
} else if !opts.isAlreadyInvalid {
p.expect(css_lexer.TOpenBrace)
}
return css_ast.Rule{Loc: preludeLoc, Data: &qualified}
}
type endOfRuleScan uint8
const (
endOfRuleUnknown endOfRuleScan = iota
endOfRuleSemicolon
endOfRuleOpenBrace
)
// Note: This was a late change to the CSS nesting syntax.
// See also: https://github.com/w3c/csswg-drafts/issues/7961
func (p *parser) scanForEndOfRule() (endOfRuleScan, int) {
var initialStack [4]css_lexer.T
stack := initialStack[:0]
// Small state machine to recognize "--foo:"
const (
customPropIdent = iota
customPropColon
customPropAccept
customPropReject
)
customProp := customPropIdent
for i, t := range p.tokens[p.index:] {
// People sometimes use CSS custom properties to store JSON. This is
// problematic because the "{...}" braces look like a CSS block unless
// this edge case is special-cased. See the (quite long) discussion on
// https://github.com/w3c/csswg-drafts/issues/7961 for more information.
//
// I couldn't find any actual specification for how this is supposed to
// work. Just a lot of discussion on GitHub. I did find this text, from
// https://drafts.csswg.org/css-syntax/#consume-a-declaration:
//
// Otherwise, if decl’s value contains a top-level simple block with
// an associated token of <{-token>, and also contains any other non-
// <whitespace-token> value, return nothing. (That is, a top-level {}-
// block is only allowed as the entire value of a non-custom property.)
//
// However, current browsers appear to accept custom properties with a
// {}-block contained anywhere inside them, not just as the entire value.
// So that's what I'm attempting to do here.
if customProp < customPropAccept {
switch customProp {
case customPropIdent:
if t.Kind == css_lexer.TIdent && strings.HasPrefix(p.source.Contents[t.Range.Loc.Start:], "--") {
customProp = customPropColon
} else if t.Kind != css_lexer.TWhitespace {
customProp = customPropReject
}
case customPropColon:
if t.Kind == css_lexer.TColon {
customProp = customPropAccept
} else if t.Kind != css_lexer.TWhitespace {
customProp = customPropReject
}
}
}
switch t.Kind {
case css_lexer.TSemicolon:
if len(stack) == 0 {
return endOfRuleSemicolon, p.index + i
}
case css_lexer.TFunction, css_lexer.TOpenParen:
stack = append(stack, css_lexer.TCloseParen)
case css_lexer.TOpenBracket:
stack = append(stack, css_lexer.TCloseBracket)
case css_lexer.TOpenBrace:
if len(stack) == 0 && customProp != customPropAccept {
return endOfRuleOpenBrace, p.index + i
}
stack = append(stack, css_lexer.TCloseBrace)
case css_lexer.TCloseParen, css_lexer.TCloseBracket:
if n := len(stack); n > 0 && t.Kind == stack[n-1] {
stack = stack[:n-1]
}
case css_lexer.TCloseBrace:
if n := len(stack); n > 0 && t.Kind == stack[n-1] {
stack = stack[:n-1]
} else {
return endOfRuleUnknown, -1
}
}
}
return endOfRuleUnknown, -1
}
func (p *parser) parseDeclaration() css_ast.Rule {
// Parse the key
keyStart := p.index
keyRange := p.tokens[keyStart].Range
keyIsIdent := p.expect(css_lexer.TIdent)
ok := false
if keyIsIdent {
p.eat(css_lexer.TWhitespace)
ok = p.eat(css_lexer.TColon)
}
// Parse the value
valueStart := p.index
stop:
for {
switch p.current().Kind {
case css_lexer.TEndOfFile, css_lexer.TSemicolon, css_lexer.TCloseBrace:
break stop
default:
p.parseComponentValue()
}
}
// Stop now if this is not a valid declaration
if !ok {
if keyIsIdent {
if end := keyRange.End(); end > p.prevError.Start {
p.prevError.Start = end
data := p.tracker.MsgData(logger.Range{Loc: logger.Loc{Start: end}}, "Expected \":\"")
data.Location.Suggestion = ":"
p.log.AddMsgID(logger.MsgID_CSS_CSSSyntaxError, logger.Msg{
Kind: logger.Warning,
Data: data,
})
}
}
return css_ast.Rule{Loc: keyRange.Loc, Data: &css_ast.RBadDeclaration{
Tokens: p.convertTokens(p.tokens[keyStart:p.index]),
}}
}
keyToken := p.tokens[keyStart]
keyText := keyToken.DecodedText(p.source.Contents)
value := p.tokens[valueStart:p.index]
verbatimWhitespace := strings.HasPrefix(keyText, "--")
// Remove trailing "!important"
important := false
i := len(value) - 1
if i >= 0 && value[i].Kind == css_lexer.TWhitespace {
i--
}
if i >= 0 && value[i].Kind == css_lexer.TIdent && strings.EqualFold(value[i].DecodedText(p.source.Contents), "important") {
i--
if i >= 0 && value[i].Kind == css_lexer.TWhitespace {
i--
}
if i >= 0 && value[i].Kind == css_lexer.TDelimExclamation {
value = value[:i]
important = true
}
}
result, _ := p.convertTokensHelper(value, css_lexer.TEndOfFile, convertTokensOpts{
allowImports: true,
// CSS variables require verbatim whitespace for correctness
verbatimWhitespace: verbatimWhitespace,
})
// Insert or remove whitespace before the first token
if !verbatimWhitespace && len(result) > 0 {
if p.options.minifyWhitespace {
result[0].Whitespace &= ^css_ast.WhitespaceBefore
} else {
result[0].Whitespace |= css_ast.WhitespaceBefore
}
}
lowerKeyText := strings.ToLower(keyText)
key := css_ast.KnownDeclarations[lowerKeyText]
// Attempt to point out trivial typos
if key == css_ast.DUnknown {
if corrected, ok := css_ast.MaybeCorrectDeclarationTypo(lowerKeyText); ok {
data := p.tracker.MsgData(keyToken.Range, fmt.Sprintf("%q is not a known CSS property", keyText))
data.Location.Suggestion = corrected
p.log.AddMsgID(logger.MsgID_CSS_UnsupportedCSSProperty, logger.Msg{Kind: logger.Warning, Data: data,
Notes: []logger.MsgData{{Text: fmt.Sprintf("Did you mean %q instead?", corrected)}}})
}
}
return css_ast.Rule{Loc: keyRange.Loc, Data: &css_ast.RDeclaration{
Key: key,
KeyText: keyText,
KeyRange: keyToken.Range,
Value: result,
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) {
current := p.current()
matchingStart := current.Range.End() - 1
if p.expect(open) {
for !p.eat(close) {
if p.peek(css_lexer.TEndOfFile) {
p.expectWithMatchingLoc(close, logger.Loc{Start: matchingStart})
return
}
p.parseComponentValue()
}
}
}