package check import ( "fmt" "regexp" "sort" "strings" "testing" "amadan.net/rastrillo/fiesta/internal/markup" ) // The grammar every rule 1.x adds is written in, read by a parser // rather than by pattern. A check that a new rule mentions a new name // is fooled twice over: [rst-page]:not([rst-snippet]) mentions one and // matches every 1.0 page because it lacks it, and // :is([rst-page], [rst-snippet]) matches 1.0 pages through its first // branch. So a new rule is held to a grammar narrow enough that what a // selector requires can be read off it: // // - an alternative is compounds joined by descendant, > or +; // - a compound is an optional type selector, then classes, attribute // selectors ([a], [a="v"], [a~="v"]), the pseudo-classes :hover, // :active, :focus-visible, :disabled, :empty, :first-child, // :last-child, :nth-child(An+B), :has() and :not(), and last // ::before or ::after (a state like :disabled, or :empty, only // narrows, and never stands for a name); // - :is(), :where(), * and anything else are outside it, except * // inside a :not() argument, where it can only narrow. // plainSpelling refuses the spellings of a name that the gates' plain // reading would take for a different name, though a browser matches // the same elements: a CSS escape (\73 is s, so rst-\73pin is // rst-spin), whitespace inside an attribute selector's brackets // ([ rst-page] is [rst-page]), and capitals in an attribute or class // name (HTML attribute names match whatever their case, so [rst-PAGE] // is [rst-page] on every 1.0 page). Rather than decode each spelling, // the files are held to the one spelling the gates read. It is a // lexical check, so the spelling and coverage gates apply it to every // selector, :where() and :is() included, and the grammar before it // parses one. func plainSpelling(sel string) error { if strings.ContainsRune(sel, '\\') { return fmt.Errorf("a CSS escape (\\) is outside the grammar") } for i := 0; i < len(sel); i++ { switch c := sel[i]; c { case '"', '\'': end := strings.IndexByte(sel[i+1:], c) if end < 0 { return fmt.Errorf("an unclosed string") } i += end + 1 case '[': end := i + 1 for end < len(sel) && sel[end] != ']' { if q := sel[end]; q == '"' || q == '\'' { if k := strings.IndexByte(sel[end+1:], q); k >= 0 { end += k + 1 } } end++ } inner := sel[i+1 : min(end, len(sel))] if strings.ContainsAny(inner, " \t\n\r\f") { return fmt.Errorf("whitespace inside [%s] is outside the grammar", inner) } name := inner if k := strings.IndexAny(inner, "~|^$*="); k >= 0 { name = inner[:k] } if name != strings.ToLower(name) { return fmt.Errorf("the attribute name %q is not lower case", name) } i = end case '.': if m := identifier.FindString(sel[i+1:]); m != "" && m != strings.ToLower(m) { return fmt.Errorf("the class name %q is not lower case", m) } } } return nil } // assertPlainSpelling holds every selector of css to plainSpelling, so // a rule spelt to slip past a gate's reading fails by name in the // spelling and coverage gates as well as in the grammar. func assertPlainSpelling(t *testing.T, name, css string) { t.Helper() for _, rule := range cssRules(t, css) { for _, sel := range rule.selectors { if strings.HasPrefix(sel, "@") { if strings.ContainsRune(sel, '\\') { t.Errorf("%s:%d: %q holds a CSS escape; write the name the escape spells", name, rule.line, sel) } continue } if err := plainSpelling(sel); err != nil { t.Errorf("%s:%d: %q: %v", name, rule.line, sel, err) } } } } // attrSel is one attribute selector: Op is "", "=" or "~=". type attrSel struct{ Name, Op, Value string } // pseudo is one pseudo-class: Arg is :nth-child()'s argument, List the // selectors inside :has() or :not(). type pseudo struct { Name, Arg string List []complexSel } // compound is one compound selector. Classes and Attrs are sorted, // since their order within a compound means nothing and two spellings // of one selector must compare equal. type compound struct { Type string Classes []string Attrs []attrSel Pseudos []pseudo Element string } // complexSel is one alternative: Combinators[i] joins Compounds[i] and // Compounds[i+1], " " for a descendant. Inside :has(), an argument is // relative, and its first combinator is the one before its first // compound, so it has as many combinators as compounds. type complexSel struct { Compounds []compound Combinators []string } // parseSelector reads one alternative of a selector list in the // grammar above, or says what in it is outside the grammar. func parseSelector(sel string) (complexSel, error) { if err := plainSpelling(sel); err != nil { return complexSel{}, fmt.Errorf("%s: %w", sel, err) } p := &selParser{s: strings.TrimSpace(sel)} c, err := p.complex(false, false) if err == nil && p.i < len(p.s) { err = p.errorf("%q is outside the grammar", p.s[p.i:]) } if err != nil { return complexSel{}, fmt.Errorf("%s: %w", sel, err) } return c, nil } type selParser struct { s string i int } func (p *selParser) errorf(format string, args ...any) error { return fmt.Errorf(format, args...) } func (p *selParser) peek() byte { if p.i < len(p.s) { return p.s[p.i] } return 0 } func (p *selParser) space() bool { start := p.i for p.i < len(p.s) && strings.IndexByte(" \t\n\r", p.s[p.i]) >= 0 { p.i++ } return p.i > start } // complex reads compounds and the combinators between them, up to the // end, a comma or a closing parenthesis. relative reads :has()'s // leading combinator; inNot allows *. func (p *selParser) complex(relative, inNot bool) (complexSel, error) { var c complexSel p.space() if relative { comb := " " if b := p.peek(); b == '>' || b == '+' { comb = string(b) p.i++ p.space() } c.Combinators = append(c.Combinators, comb) } for { cp, err := p.compound(inNot) if err != nil { return complexSel{}, err } c.Compounds = append(c.Compounds, cp) spaced := p.space() switch b := p.peek(); { case b == 0 || b == ',' || b == ')': return c, nil case b == '>' || b == '+': p.i++ p.space() c.Combinators = append(c.Combinators, string(b)) case b == '~': return complexSel{}, p.errorf("the ~ combinator is outside the grammar") case spaced: c.Combinators = append(c.Combinators, " ") default: return complexSel{}, p.errorf("%q is outside the grammar", p.s[p.i:]) } if cp.Element != "" { return complexSel{}, p.errorf("::%s must end the selector", cp.Element) } } } // identifier is a CSS name as these files write one. var identifier = regexp.MustCompile(`^-?[A-Za-z_][A-Za-z0-9_-]*`) func (p *selParser) ident() string { m := identifier.FindString(p.s[p.i:]) p.i += len(m) return m } // nthArg is the An+B forms :nth-child() takes here; "of S" is not. var nthArg = regexp.MustCompile(`^(odd|even|[+-]?[0-9]*n(\s*[+-]\s*[0-9]+)?|[+-]?[0-9]+)$`) func (p *selParser) compound(inNot bool) (compound, error) { var c compound start := p.i switch b := p.peek(); { case b == '*': if !inNot { return c, p.errorf("`*` is outside the grammar except inside a :not() argument") } p.i++ c.Type = "*" case identifier.MatchString(p.s[p.i:]): c.Type = strings.ToLower(p.ident()) } for { if c.Element != "" && strings.IndexByte(".[:#", p.peek()) >= 0 { return c, p.errorf("::%s must end its compound", c.Element) } switch p.peek() { case '.': p.i++ name := p.ident() if name == "" { return c, p.errorf("a . with no class after it") } c.Classes = append(c.Classes, name) case '[': a, err := p.attr() if err != nil { return c, err } c.Attrs = append(c.Attrs, a) case '#': return c, p.errorf("an id selector is outside the grammar") case ':': if strings.HasPrefix(p.s[p.i:], "::") { p.i += 2 name := p.ident() if name != "before" && name != "after" { return c, p.errorf("`::%s` is outside the grammar", name) } c.Element = name continue } p.i++ ps, err := p.pseudo(inNot) if err != nil { return c, err } c.Pseudos = append(c.Pseudos, ps) default: if p.i == start { return c, p.errorf("an empty compound before %q", p.s[p.i:]) } sort.Strings(c.Classes) sort.Slice(c.Attrs, func(i, j int) bool { a, b := c.Attrs[i], c.Attrs[j] return a.Name+"\x00"+a.Op+"\x00"+a.Value < b.Name+"\x00"+b.Op+"\x00"+b.Value }) return c, nil } } } func (p *selParser) attr() (attrSel, error) { p.i++ // [ p.space() a := attrSel{Name: p.ident()} if a.Name == "" { return a, p.errorf("an attribute selector with no name") } p.space() switch { case strings.HasPrefix(p.s[p.i:], "~="): a.Op, p.i = "~=", p.i+2 case strings.HasPrefix(p.s[p.i:], "="): a.Op, p.i = "=", p.i+1 case p.peek() == ']': p.i++ return a, nil default: return a, p.errorf("the attribute operator in %q is outside the grammar", p.s[p.i:]) } p.space() if q := p.peek(); q == '"' || q == '\'' { end := strings.IndexByte(p.s[p.i+1:], q) if end < 0 { return a, p.errorf("an unclosed string in an attribute selector") } a.Value = p.s[p.i+1 : p.i+1+end] p.i += end + 2 } else { a.Value = p.ident() } p.space() if p.peek() != ']' { return a, p.errorf("%q in an attribute selector is outside the grammar", p.s[p.i:]) } p.i++ // ~= takes one token: an empty or spaced value matches nothing at // all, so the selector could only be a mistake. if a.Op == "~=" && (a.Value == "" || len(strings.Fields(a.Value)) != 1 || strings.TrimSpace(a.Value) != a.Value) { return a, p.errorf("[%s~=%q] matches nothing", a.Name, a.Value) } return a, nil } func (p *selParser) pseudo(inNot bool) (pseudo, error) { ps := pseudo{Name: strings.ToLower(p.ident())} switch ps.Name { case "hover", "active", "focus-visible", "disabled", "empty", "first-child", "last-child": if p.peek() == '(' { return ps, p.errorf("`:%s(` is outside the grammar", ps.Name) } return ps, nil case "nth-child": if p.peek() != '(' { return ps, p.errorf(":nth-child with no argument") } end := strings.IndexByte(p.s[p.i:], ')') if end < 0 { return ps, p.errorf("an unclosed :nth-child(") } ps.Arg = collapseSpace(p.s[p.i+1 : p.i+end]) if !nthArg.MatchString(ps.Arg) { return ps, p.errorf(":nth-child(%s) is outside the grammar", ps.Arg) } p.i += end + 1 return ps, nil case "has", "not": if p.peek() != '(' { return ps, p.errorf(":%s with no argument", ps.Name) } p.i++ for { c, err := p.complex(ps.Name == "has", inNot || ps.Name == "not") if err != nil { return ps, err } ps.List = append(ps.List, c) p.space() if p.peek() == ',' { p.i++ continue } if p.peek() != ')' { return ps, p.errorf("an unclosed :%s(", ps.Name) } p.i++ return ps, nil } default: if p.peek() == '(' { return ps, p.errorf("`:%s(` is outside the grammar", ps.Name) } return ps, p.errorf("`:%s` is outside the grammar", ps.Name) } } // requiresNewMarkup reports whether sel can only match markup that // carries a name baseline does not have, and says why not when it // cannot. Some compound must positively require such a name, as one // of its own classes or attribute selectors, or inside a :has() every // one of whose alternatives does. Names inside :not() never count: a // :not() only narrows, and a compound that needs nothing new narrowed // by one still matches 1.0 markup. Only rst- names count: any other // attribute or class is one a 1.0 page may carry for its own reasons. // // The alternative as a whole is enough, since every compound of it // must match some element: one compound that no 1.0 page can carry // keeps the whole selector off 1.0 pages. func requiresNewMarkup(sel string, baseline map[string]bool) (bool, string) { c, err := parseSelector(sel) if err != nil { return false, err.Error() } var partial []string if requiresNew(c, baseline, &partial) { return true, "" } why := sel + ": no compound positively requires a name 1.0 does not style" if strings.Contains(sel, ":not(") { why += " (names inside :not() never count)" } if len(partial) > 0 { why += "; " + strings.Join(partial, "; ") } return false, why } // requiresNew is requiresNewMarkup over a parsed alternative; partial // collects each :has() alternative that let its list down, for the // reason. func requiresNew(c complexSel, baseline map[string]bool, partial *[]string) bool { for _, cp := range c.Compounds { for _, class := range cp.Classes { if strings.HasPrefix(class, "rst-") && newClass(class, baseline) { return true } } for _, a := range cp.Attrs { if strings.HasPrefix(a.Name, "rst-") && newAttr(a, baseline) { return true } } for _, ps := range cp.Pseudos { if ps.Name != "has" { continue } all := true for _, alt := range ps.List { if !requiresNew(alt, baseline, partial) { all = false *partial = append(*partial, fmt.Sprintf("in :has() the alternative %q matches 1.0 markup", relativeText(alt))) } } if all { return true } } } return false } // newAttr reports whether an attribute selector needs markup 1.0 never // wrote, read by what it matches rather than how it is spelt: a bare // [a] and an empty [a=""] need only the attribute, which
// carries; a value needs every one of its tokens, so [a="v"] is new // exactly when [a~="v"] is. func newAttr(a attrSel, baseline map[string]bool) bool { tokens := strings.Fields(a.Value) if a.Op == "" || len(tokens) == 0 { return !baseline[a.Name] } for _, token := range tokens { if !baseline[a.Name+"~="+token] { return true } } return false } // newClass reports whether a class needs markup 1.0 never wrote. The // class is read as the attribute it spells (markup.Attribute), so // .rst-job and [rst-job] are one name, and a 1.0 page that writes // either carries it. func newClass(class string, baseline map[string]bool) bool { if baseline[class] || baseline["."+class] { return false } name, variant, ok := markup.Attribute(class) switch { case !ok: return true case variant == "": return !baseline[name] } return !baseline[name+"~="+variant] } // relativeText writes a parsed alternative back as a selector, for a // reason a reader can find in the CSS. func relativeText(c complexSel) string { var b strings.Builder lead := len(c.Combinators) == len(c.Compounds) for i, cp := range c.Compounds { comb := "" switch { case lead: comb = c.Combinators[i] case i > 0: comb = c.Combinators[i-1] } switch { case comb == " " && i > 0: b.WriteString(" ") case comb != "" && comb != " ": if i > 0 { b.WriteString(" ") } b.WriteString(comb + " ") } b.WriteString(cp.Type) for _, class := range cp.Classes { b.WriteString("." + class) } for _, a := range cp.Attrs { if a.Op == "" { b.WriteString("[" + a.Name + "]") } else { b.WriteString("[" + a.Name + a.Op + `"` + a.Value + `"]`) } } for _, ps := range cp.Pseudos { b.WriteString(":" + ps.Name) switch { case ps.Arg != "": b.WriteString("(" + ps.Arg + ")") case ps.List != nil: var args []string for _, alt := range ps.List { args = append(args, relativeText(alt)) } b.WriteString("(" + strings.Join(args, ", ") + ")") } } if cp.Element != "" { b.WriteString("::" + cp.Element) } } return b.String() } // TestOnlyNewMarkupPassesTheGrammar holds requiresNewMarkup to the // cases that fooled a check by mention: a new name under :not() or in // one branch of :is() matches every 1.0 page, and a :has() list passes // only if every alternative needs a new name. Each refusal's reason // must say what failed, since the gate's failure message is that // reason and a bare "false" sends the reader through the grammar. func TestOnlyNewMarkupPassesTheGrammar(t *testing.T) { baseline := map[string]bool{"rst-page": true, "rst-row": true, "rst-status": true, "rst-dropdown": true, "rst-seg-tabs": true} for _, c := range []struct { sel string want bool reason string // what a refusal's reason must name }{ {".rst-page:not(.rst-snippet)", false, "positively requires"}, {"[rst-page]:not([rst-snippet])", false, "positively requires"}, {":is([rst-page], [rst-snippet])", false, ":is("}, {":where([rst-snippet])", false, ":where("}, {"[rst-page]:not([rst-seg-tabs] > button)", false, "positively requires"}, {"* [rst-snippet]", false, "`*`"}, {"[rst-page]:has(> [rst-snippet], > [rst-row])", false, "> [rst-row]"}, {`[rst-page=""]`, false, "positively requires"}, {`[rst-page=" "]`, false, "whitespace"}, {"[ rst-page]", false, "whitespace"}, {"[rst-page ]", false, "whitespace"}, {"[rst-PAGE]", false, "lower case"}, {".rst-PAGE", false, "lower case"}, {`[rst-\70 age]`, false, "escape"}, {`.rst-\73nippet`, false, "escape"}, {`[rst-dropdown~=""]`, false, "matches nothing"}, {`.rst-page[rst-row]`, false, "positively requires"}, {"[rst-page] button:disabled", false, "positively requires"}, {"[rst-snippet]", true, ""}, {"[rst-snippet] > button:disabled", true, ""}, {"[rst-page]:empty", false, "positively requires"}, {"[rst-snippet-status]:empty", true, ""}, {"[rst-page-foot]:not(:has(> *))", true, ""}, {`[rst-row]:has(> [rst-status~="inline"]) > [rst-status]`, true, ""}, {"[rst-page-foot] [rst-dropdown]", true, ""}, {".rst-stats--pairs > .rst-stat:nth-child(odd)", true, ""}, } { got, why := requiresNewMarkup(c.sel, baseline) if got != c.want { t.Errorf("requiresNewMarkup(%q) = %v (%s), want %v", c.sel, got, why, c.want) continue } if !c.want && !strings.Contains(why, c.reason) { t.Errorf("requiresNewMarkup(%q) refused it saying %q, which does not name %q", c.sel, why, c.reason) } } }