| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "regexp" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "amadan.net/rastrillo/fiesta/internal/markup" |
| 11 | ) |
| 12 | |
| 13 | // The grammar every rule 1.x adds is written in, read by a parser |
| 14 | // rather than by pattern. A check that a new rule mentions a new name |
| 15 | // is fooled twice over: [rst-page]:not([rst-snippet]) mentions one and |
| 16 | // matches every 1.0 page because it lacks it, and |
| 17 | // :is([rst-page], [rst-snippet]) matches 1.0 pages through its first |
| 18 | // branch. So a new rule is held to a grammar narrow enough that what a |
| 19 | // selector requires can be read off it: |
| 20 | // |
| 21 | // - an alternative is compounds joined by descendant, > or +; |
| 22 | // - a compound is an optional type selector, then classes, attribute |
| 23 | // selectors ([a], [a="v"], [a~="v"]), the pseudo-classes :hover, |
| 24 | // :active, :focus-visible, :disabled, :empty, :first-child, |
| 25 | // :last-child, :nth-child(An+B), :has() and :not(), and last |
| 26 | // ::before or ::after (a state like :disabled, or :empty, only |
| 27 | // narrows, and never stands for a name); |
| 28 | // - :is(), :where(), * and anything else are outside it, except * |
| 29 | // inside a :not() argument, where it can only narrow. |
| 30 | |
| 31 | // plainSpelling refuses the spellings of a name that the gates' plain |
| 32 | // reading would take for a different name, though a browser matches |
| 33 | // the same elements: a CSS escape (\73 is s, so rst-\73pin is |
| 34 | // rst-spin), whitespace inside an attribute selector's brackets |
| 35 | // ([ rst-page] is [rst-page]), and capitals in an attribute or class |
| 36 | // name (HTML attribute names match whatever their case, so [rst-PAGE] |
| 37 | // is [rst-page] on every 1.0 page). Rather than decode each spelling, |
| 38 | // the files are held to the one spelling the gates read. It is a |
| 39 | // lexical check, so the spelling and coverage gates apply it to every |
| 40 | // selector, :where() and :is() included, and the grammar before it |
| 41 | // parses one. |
| 42 | func plainSpelling(sel string) error { |
| 43 | if strings.ContainsRune(sel, '\\') { |
| 44 | return fmt.Errorf("a CSS escape (\\) is outside the grammar") |
| 45 | } |
| 46 | for i := 0; i < len(sel); i++ { |
| 47 | switch c := sel[i]; c { |
| 48 | case '"', '\'': |
| 49 | end := strings.IndexByte(sel[i+1:], c) |
| 50 | if end < 0 { |
| 51 | return fmt.Errorf("an unclosed string") |
| 52 | } |
| 53 | i += end + 1 |
| 54 | case '[': |
| 55 | end := i + 1 |
| 56 | for end < len(sel) && sel[end] != ']' { |
| 57 | if q := sel[end]; q == '"' || q == '\'' { |
| 58 | if k := strings.IndexByte(sel[end+1:], q); k >= 0 { |
| 59 | end += k + 1 |
| 60 | } |
| 61 | } |
| 62 | end++ |
| 63 | } |
| 64 | inner := sel[i+1 : min(end, len(sel))] |
| 65 | if strings.ContainsAny(inner, " \t\n\r\f") { |
| 66 | return fmt.Errorf("whitespace inside [%s] is outside the grammar", inner) |
| 67 | } |
| 68 | name := inner |
| 69 | if k := strings.IndexAny(inner, "~|^$*="); k >= 0 { |
| 70 | name = inner[:k] |
| 71 | } |
| 72 | if name != strings.ToLower(name) { |
| 73 | return fmt.Errorf("the attribute name %q is not lower case", name) |
| 74 | } |
| 75 | i = end |
| 76 | case '.': |
| 77 | if m := identifier.FindString(sel[i+1:]); m != "" && m != strings.ToLower(m) { |
| 78 | return fmt.Errorf("the class name %q is not lower case", m) |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | return nil |
| 83 | } |
| 84 | |
| 85 | // assertPlainSpelling holds every selector of css to plainSpelling, so |
| 86 | // a rule spelt to slip past a gate's reading fails by name in the |
| 87 | // spelling and coverage gates as well as in the grammar. |
| 88 | func assertPlainSpelling(t *testing.T, name, css string) { |
| 89 | t.Helper() |
| 90 | for _, rule := range cssRules(t, css) { |
| 91 | for _, sel := range rule.selectors { |
| 92 | if strings.HasPrefix(sel, "@") { |
| 93 | if strings.ContainsRune(sel, '\\') { |
| 94 | t.Errorf("%s:%d: %q holds a CSS escape; write the name the escape spells", name, rule.line, sel) |
| 95 | } |
| 96 | continue |
| 97 | } |
| 98 | if err := plainSpelling(sel); err != nil { |
| 99 | t.Errorf("%s:%d: %q: %v", name, rule.line, sel, err) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // attrSel is one attribute selector: Op is "", "=" or "~=". |
| 106 | type attrSel struct{ Name, Op, Value string } |
| 107 | |
| 108 | // pseudo is one pseudo-class: Arg is :nth-child()'s argument, List the |
| 109 | // selectors inside :has() or :not(). |
| 110 | type pseudo struct { |
| 111 | Name, Arg string |
| 112 | List []complexSel |
| 113 | } |
| 114 | |
| 115 | // compound is one compound selector. Classes and Attrs are sorted, |
| 116 | // since their order within a compound means nothing and two spellings |
| 117 | // of one selector must compare equal. |
| 118 | type compound struct { |
| 119 | Type string |
| 120 | Classes []string |
| 121 | Attrs []attrSel |
| 122 | Pseudos []pseudo |
| 123 | Element string |
| 124 | } |
| 125 | |
| 126 | // complexSel is one alternative: Combinators[i] joins Compounds[i] and |
| 127 | // Compounds[i+1], " " for a descendant. Inside :has(), an argument is |
| 128 | // relative, and its first combinator is the one before its first |
| 129 | // compound, so it has as many combinators as compounds. |
| 130 | type complexSel struct { |
| 131 | Compounds []compound |
| 132 | Combinators []string |
| 133 | } |
| 134 | |
| 135 | // parseSelector reads one alternative of a selector list in the |
| 136 | // grammar above, or says what in it is outside the grammar. |
| 137 | func parseSelector(sel string) (complexSel, error) { |
| 138 | if err := plainSpelling(sel); err != nil { |
| 139 | return complexSel{}, fmt.Errorf("%s: %w", sel, err) |
| 140 | } |
| 141 | p := &selParser{s: strings.TrimSpace(sel)} |
| 142 | c, err := p.complex(false, false) |
| 143 | if err == nil && p.i < len(p.s) { |
| 144 | err = p.errorf("%q is outside the grammar", p.s[p.i:]) |
| 145 | } |
| 146 | if err != nil { |
| 147 | return complexSel{}, fmt.Errorf("%s: %w", sel, err) |
| 148 | } |
| 149 | return c, nil |
| 150 | } |
| 151 | |
| 152 | type selParser struct { |
| 153 | s string |
| 154 | i int |
| 155 | } |
| 156 | |
| 157 | func (p *selParser) errorf(format string, args ...any) error { return fmt.Errorf(format, args...) } |
| 158 | |
| 159 | func (p *selParser) peek() byte { |
| 160 | if p.i < len(p.s) { |
| 161 | return p.s[p.i] |
| 162 | } |
| 163 | return 0 |
| 164 | } |
| 165 | |
| 166 | func (p *selParser) space() bool { |
| 167 | start := p.i |
| 168 | for p.i < len(p.s) && strings.IndexByte(" \t\n\r", p.s[p.i]) >= 0 { |
| 169 | p.i++ |
| 170 | } |
| 171 | return p.i > start |
| 172 | } |
| 173 | |
| 174 | // complex reads compounds and the combinators between them, up to the |
| 175 | // end, a comma or a closing parenthesis. relative reads :has()'s |
| 176 | // leading combinator; inNot allows *. |
| 177 | func (p *selParser) complex(relative, inNot bool) (complexSel, error) { |
| 178 | var c complexSel |
| 179 | p.space() |
| 180 | if relative { |
| 181 | comb := " " |
| 182 | if b := p.peek(); b == '>' || b == '+' { |
| 183 | comb = string(b) |
| 184 | p.i++ |
| 185 | p.space() |
| 186 | } |
| 187 | c.Combinators = append(c.Combinators, comb) |
| 188 | } |
| 189 | for { |
| 190 | cp, err := p.compound(inNot) |
| 191 | if err != nil { |
| 192 | return complexSel{}, err |
| 193 | } |
| 194 | c.Compounds = append(c.Compounds, cp) |
| 195 | spaced := p.space() |
| 196 | switch b := p.peek(); { |
| 197 | case b == 0 || b == ',' || b == ')': |
| 198 | return c, nil |
| 199 | case b == '>' || b == '+': |
| 200 | p.i++ |
| 201 | p.space() |
| 202 | c.Combinators = append(c.Combinators, string(b)) |
| 203 | case b == '~': |
| 204 | return complexSel{}, p.errorf("the ~ combinator is outside the grammar") |
| 205 | case spaced: |
| 206 | c.Combinators = append(c.Combinators, " ") |
| 207 | default: |
| 208 | return complexSel{}, p.errorf("%q is outside the grammar", p.s[p.i:]) |
| 209 | } |
| 210 | if cp.Element != "" { |
| 211 | return complexSel{}, p.errorf("::%s must end the selector", cp.Element) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // identifier is a CSS name as these files write one. |
| 217 | var identifier = regexp.MustCompile(`^-?[A-Za-z_][A-Za-z0-9_-]*`) |
| 218 | |
| 219 | func (p *selParser) ident() string { |
| 220 | m := identifier.FindString(p.s[p.i:]) |
| 221 | p.i += len(m) |
| 222 | return m |
| 223 | } |
| 224 | |
| 225 | // nthArg is the An+B forms :nth-child() takes here; "of S" is not. |
| 226 | var nthArg = regexp.MustCompile(`^(odd|even|[+-]?[0-9]*n(\s*[+-]\s*[0-9]+)?|[+-]?[0-9]+)$`) |
| 227 | |
| 228 | func (p *selParser) compound(inNot bool) (compound, error) { |
| 229 | var c compound |
| 230 | start := p.i |
| 231 | switch b := p.peek(); { |
| 232 | case b == '*': |
| 233 | if !inNot { |
| 234 | return c, p.errorf("`*` is outside the grammar except inside a :not() argument") |
| 235 | } |
| 236 | p.i++ |
| 237 | c.Type = "*" |
| 238 | case identifier.MatchString(p.s[p.i:]): |
| 239 | c.Type = strings.ToLower(p.ident()) |
| 240 | } |
| 241 | for { |
| 242 | if c.Element != "" && strings.IndexByte(".[:#", p.peek()) >= 0 { |
| 243 | return c, p.errorf("::%s must end its compound", c.Element) |
| 244 | } |
| 245 | switch p.peek() { |
| 246 | case '.': |
| 247 | p.i++ |
| 248 | name := p.ident() |
| 249 | if name == "" { |
| 250 | return c, p.errorf("a . with no class after it") |
| 251 | } |
| 252 | c.Classes = append(c.Classes, name) |
| 253 | case '[': |
| 254 | a, err := p.attr() |
| 255 | if err != nil { |
| 256 | return c, err |
| 257 | } |
| 258 | c.Attrs = append(c.Attrs, a) |
| 259 | case '#': |
| 260 | return c, p.errorf("an id selector is outside the grammar") |
| 261 | case ':': |
| 262 | if strings.HasPrefix(p.s[p.i:], "::") { |
| 263 | p.i += 2 |
| 264 | name := p.ident() |
| 265 | if name != "before" && name != "after" { |
| 266 | return c, p.errorf("`::%s` is outside the grammar", name) |
| 267 | } |
| 268 | c.Element = name |
| 269 | continue |
| 270 | } |
| 271 | p.i++ |
| 272 | ps, err := p.pseudo(inNot) |
| 273 | if err != nil { |
| 274 | return c, err |
| 275 | } |
| 276 | c.Pseudos = append(c.Pseudos, ps) |
| 277 | default: |
| 278 | if p.i == start { |
| 279 | return c, p.errorf("an empty compound before %q", p.s[p.i:]) |
| 280 | } |
| 281 | sort.Strings(c.Classes) |
| 282 | sort.Slice(c.Attrs, func(i, j int) bool { |
| 283 | a, b := c.Attrs[i], c.Attrs[j] |
| 284 | return a.Name+"\x00"+a.Op+"\x00"+a.Value < b.Name+"\x00"+b.Op+"\x00"+b.Value |
| 285 | }) |
| 286 | return c, nil |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func (p *selParser) attr() (attrSel, error) { |
| 292 | p.i++ // [ |
| 293 | p.space() |
| 294 | a := attrSel{Name: p.ident()} |
| 295 | if a.Name == "" { |
| 296 | return a, p.errorf("an attribute selector with no name") |
| 297 | } |
| 298 | p.space() |
| 299 | switch { |
| 300 | case strings.HasPrefix(p.s[p.i:], "~="): |
| 301 | a.Op, p.i = "~=", p.i+2 |
| 302 | case strings.HasPrefix(p.s[p.i:], "="): |
| 303 | a.Op, p.i = "=", p.i+1 |
| 304 | case p.peek() == ']': |
| 305 | p.i++ |
| 306 | return a, nil |
| 307 | default: |
| 308 | return a, p.errorf("the attribute operator in %q is outside the grammar", p.s[p.i:]) |
| 309 | } |
| 310 | p.space() |
| 311 | if q := p.peek(); q == '"' || q == '\'' { |
| 312 | end := strings.IndexByte(p.s[p.i+1:], q) |
| 313 | if end < 0 { |
| 314 | return a, p.errorf("an unclosed string in an attribute selector") |
| 315 | } |
| 316 | a.Value = p.s[p.i+1 : p.i+1+end] |
| 317 | p.i += end + 2 |
| 318 | } else { |
| 319 | a.Value = p.ident() |
| 320 | } |
| 321 | p.space() |
| 322 | if p.peek() != ']' { |
| 323 | return a, p.errorf("%q in an attribute selector is outside the grammar", p.s[p.i:]) |
| 324 | } |
| 325 | p.i++ |
| 326 | // ~= takes one token: an empty or spaced value matches nothing at |
| 327 | // all, so the selector could only be a mistake. |
| 328 | if a.Op == "~=" && (a.Value == "" || len(strings.Fields(a.Value)) != 1 || strings.TrimSpace(a.Value) != a.Value) { |
| 329 | return a, p.errorf("[%s~=%q] matches nothing", a.Name, a.Value) |
| 330 | } |
| 331 | return a, nil |
| 332 | } |
| 333 | |
| 334 | func (p *selParser) pseudo(inNot bool) (pseudo, error) { |
| 335 | ps := pseudo{Name: strings.ToLower(p.ident())} |
| 336 | switch ps.Name { |
| 337 | case "hover", "active", "focus-visible", "disabled", "empty", "first-child", "last-child": |
| 338 | if p.peek() == '(' { |
| 339 | return ps, p.errorf("`:%s(` is outside the grammar", ps.Name) |
| 340 | } |
| 341 | return ps, nil |
| 342 | case "nth-child": |
| 343 | if p.peek() != '(' { |
| 344 | return ps, p.errorf(":nth-child with no argument") |
| 345 | } |
| 346 | end := strings.IndexByte(p.s[p.i:], ')') |
| 347 | if end < 0 { |
| 348 | return ps, p.errorf("an unclosed :nth-child(") |
| 349 | } |
| 350 | ps.Arg = collapseSpace(p.s[p.i+1 : p.i+end]) |
| 351 | if !nthArg.MatchString(ps.Arg) { |
| 352 | return ps, p.errorf(":nth-child(%s) is outside the grammar", ps.Arg) |
| 353 | } |
| 354 | p.i += end + 1 |
| 355 | return ps, nil |
| 356 | case "has", "not": |
| 357 | if p.peek() != '(' { |
| 358 | return ps, p.errorf(":%s with no argument", ps.Name) |
| 359 | } |
| 360 | p.i++ |
| 361 | for { |
| 362 | c, err := p.complex(ps.Name == "has", inNot || ps.Name == "not") |
| 363 | if err != nil { |
| 364 | return ps, err |
| 365 | } |
| 366 | ps.List = append(ps.List, c) |
| 367 | p.space() |
| 368 | if p.peek() == ',' { |
| 369 | p.i++ |
| 370 | continue |
| 371 | } |
| 372 | if p.peek() != ')' { |
| 373 | return ps, p.errorf("an unclosed :%s(", ps.Name) |
| 374 | } |
| 375 | p.i++ |
| 376 | return ps, nil |
| 377 | } |
| 378 | default: |
| 379 | if p.peek() == '(' { |
| 380 | return ps, p.errorf("`:%s(` is outside the grammar", ps.Name) |
| 381 | } |
| 382 | return ps, p.errorf("`:%s` is outside the grammar", ps.Name) |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | // requiresNewMarkup reports whether sel can only match markup that |
| 387 | // carries a name baseline does not have, and says why not when it |
| 388 | // cannot. Some compound must positively require such a name, as one |
| 389 | // of its own classes or attribute selectors, or inside a :has() every |
| 390 | // one of whose alternatives does. Names inside :not() never count: a |
| 391 | // :not() only narrows, and a compound that needs nothing new narrowed |
| 392 | // by one still matches 1.0 markup. Only rst- names count: any other |
| 393 | // attribute or class is one a 1.0 page may carry for its own reasons. |
| 394 | // |
| 395 | // The alternative as a whole is enough, since every compound of it |
| 396 | // must match some element: one compound that no 1.0 page can carry |
| 397 | // keeps the whole selector off 1.0 pages. |
| 398 | func requiresNewMarkup(sel string, baseline map[string]bool) (bool, string) { |
| 399 | c, err := parseSelector(sel) |
| 400 | if err != nil { |
| 401 | return false, err.Error() |
| 402 | } |
| 403 | var partial []string |
| 404 | if requiresNew(c, baseline, &partial) { |
| 405 | return true, "" |
| 406 | } |
| 407 | why := sel + ": no compound positively requires a name 1.0 does not style" |
| 408 | if strings.Contains(sel, ":not(") { |
| 409 | why += " (names inside :not() never count)" |
| 410 | } |
| 411 | if len(partial) > 0 { |
| 412 | why += "; " + strings.Join(partial, "; ") |
| 413 | } |
| 414 | return false, why |
| 415 | } |
| 416 | |
| 417 | // requiresNew is requiresNewMarkup over a parsed alternative; partial |
| 418 | // collects each :has() alternative that let its list down, for the |
| 419 | // reason. |
| 420 | func requiresNew(c complexSel, baseline map[string]bool, partial *[]string) bool { |
| 421 | for _, cp := range c.Compounds { |
| 422 | for _, class := range cp.Classes { |
| 423 | if strings.HasPrefix(class, "rst-") && newClass(class, baseline) { |
| 424 | return true |
| 425 | } |
| 426 | } |
| 427 | for _, a := range cp.Attrs { |
| 428 | if strings.HasPrefix(a.Name, "rst-") && newAttr(a, baseline) { |
| 429 | return true |
| 430 | } |
| 431 | } |
| 432 | for _, ps := range cp.Pseudos { |
| 433 | if ps.Name != "has" { |
| 434 | continue |
| 435 | } |
| 436 | all := true |
| 437 | for _, alt := range ps.List { |
| 438 | if !requiresNew(alt, baseline, partial) { |
| 439 | all = false |
| 440 | *partial = append(*partial, fmt.Sprintf("in :has() the alternative %q matches 1.0 markup", relativeText(alt))) |
| 441 | } |
| 442 | } |
| 443 | if all { |
| 444 | return true |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | return false |
| 449 | } |
| 450 | |
| 451 | // newAttr reports whether an attribute selector needs markup 1.0 never |
| 452 | // wrote, read by what it matches rather than how it is spelt: a bare |
| 453 | // [a] and an empty [a=""] need only the attribute, which <main rst-page> |
| 454 | // carries; a value needs every one of its tokens, so [a="v"] is new |
| 455 | // exactly when [a~="v"] is. |
| 456 | func newAttr(a attrSel, baseline map[string]bool) bool { |
| 457 | tokens := strings.Fields(a.Value) |
| 458 | if a.Op == "" || len(tokens) == 0 { |
| 459 | return !baseline[a.Name] |
| 460 | } |
| 461 | for _, token := range tokens { |
| 462 | if !baseline[a.Name+"~="+token] { |
| 463 | return true |
| 464 | } |
| 465 | } |
| 466 | return false |
| 467 | } |
| 468 | |
| 469 | // newClass reports whether a class needs markup 1.0 never wrote. The |
| 470 | // class is read as the attribute it spells (markup.Attribute), so |
| 471 | // .rst-job and [rst-job] are one name, and a 1.0 page that writes |
| 472 | // either carries it. |
| 473 | func newClass(class string, baseline map[string]bool) bool { |
| 474 | if baseline[class] || baseline["."+class] { |
| 475 | return false |
| 476 | } |
| 477 | name, variant, ok := markup.Attribute(class) |
| 478 | switch { |
| 479 | case !ok: |
| 480 | return true |
| 481 | case variant == "": |
| 482 | return !baseline[name] |
| 483 | } |
| 484 | return !baseline[name+"~="+variant] |
| 485 | } |
| 486 | |
| 487 | // relativeText writes a parsed alternative back as a selector, for a |
| 488 | // reason a reader can find in the CSS. |
| 489 | func relativeText(c complexSel) string { |
| 490 | var b strings.Builder |
| 491 | lead := len(c.Combinators) == len(c.Compounds) |
| 492 | for i, cp := range c.Compounds { |
| 493 | comb := "" |
| 494 | switch { |
| 495 | case lead: |
| 496 | comb = c.Combinators[i] |
| 497 | case i > 0: |
| 498 | comb = c.Combinators[i-1] |
| 499 | } |
| 500 | switch { |
| 501 | case comb == " " && i > 0: |
| 502 | b.WriteString(" ") |
| 503 | case comb != "" && comb != " ": |
| 504 | if i > 0 { |
| 505 | b.WriteString(" ") |
| 506 | } |
| 507 | b.WriteString(comb + " ") |
| 508 | } |
| 509 | b.WriteString(cp.Type) |
| 510 | for _, class := range cp.Classes { |
| 511 | b.WriteString("." + class) |
| 512 | } |
| 513 | for _, a := range cp.Attrs { |
| 514 | if a.Op == "" { |
| 515 | b.WriteString("[" + a.Name + "]") |
| 516 | } else { |
| 517 | b.WriteString("[" + a.Name + a.Op + `"` + a.Value + `"]`) |
| 518 | } |
| 519 | } |
| 520 | for _, ps := range cp.Pseudos { |
| 521 | b.WriteString(":" + ps.Name) |
| 522 | switch { |
| 523 | case ps.Arg != "": |
| 524 | b.WriteString("(" + ps.Arg + ")") |
| 525 | case ps.List != nil: |
| 526 | var args []string |
| 527 | for _, alt := range ps.List { |
| 528 | args = append(args, relativeText(alt)) |
| 529 | } |
| 530 | b.WriteString("(" + strings.Join(args, ", ") + ")") |
| 531 | } |
| 532 | } |
| 533 | if cp.Element != "" { |
| 534 | b.WriteString("::" + cp.Element) |
| 535 | } |
| 536 | } |
| 537 | return b.String() |
| 538 | } |
| 539 | |
| 540 | // TestOnlyNewMarkupPassesTheGrammar holds requiresNewMarkup to the |
| 541 | // cases that fooled a check by mention: a new name under :not() or in |
| 542 | // one branch of :is() matches every 1.0 page, and a :has() list passes |
| 543 | // only if every alternative needs a new name. Each refusal's reason |
| 544 | // must say what failed, since the gate's failure message is that |
| 545 | // reason and a bare "false" sends the reader through the grammar. |
| 546 | func TestOnlyNewMarkupPassesTheGrammar(t *testing.T) { |
| 547 | baseline := map[string]bool{"rst-page": true, "rst-row": true, "rst-status": true, "rst-dropdown": true, "rst-seg-tabs": true} |
| 548 | for _, c := range []struct { |
| 549 | sel string |
| 550 | want bool |
| 551 | reason string // what a refusal's reason must name |
| 552 | }{ |
| 553 | {".rst-page:not(.rst-snippet)", false, "positively requires"}, |
| 554 | {"[rst-page]:not([rst-snippet])", false, "positively requires"}, |
| 555 | {":is([rst-page], [rst-snippet])", false, ":is("}, |
| 556 | {":where([rst-snippet])", false, ":where("}, |
| 557 | {"[rst-page]:not([rst-seg-tabs] > button)", false, "positively requires"}, |
| 558 | {"* [rst-snippet]", false, "`*`"}, |
| 559 | {"[rst-page]:has(> [rst-snippet], > [rst-row])", false, "> [rst-row]"}, |
| 560 | {`[rst-page=""]`, false, "positively requires"}, |
| 561 | {`[rst-page=" "]`, false, "whitespace"}, |
| 562 | {"[ rst-page]", false, "whitespace"}, |
| 563 | {"[rst-page ]", false, "whitespace"}, |
| 564 | {"[rst-PAGE]", false, "lower case"}, |
| 565 | {".rst-PAGE", false, "lower case"}, |
| 566 | {`[rst-\70 age]`, false, "escape"}, |
| 567 | {`.rst-\73nippet`, false, "escape"}, |
| 568 | {`[rst-dropdown~=""]`, false, "matches nothing"}, |
| 569 | {`.rst-page[rst-row]`, false, "positively requires"}, |
| 570 | {"[rst-page] button:disabled", false, "positively requires"}, |
| 571 | {"[rst-snippet]", true, ""}, |
| 572 | {"[rst-snippet] > button:disabled", true, ""}, |
| 573 | {"[rst-page]:empty", false, "positively requires"}, |
| 574 | {"[rst-snippet-status]:empty", true, ""}, |
| 575 | {"[rst-page-foot]:not(:has(> *))", true, ""}, |
| 576 | {`[rst-row]:has(> [rst-status~="inline"]) > [rst-status]`, true, ""}, |
| 577 | {"[rst-page-foot] [rst-dropdown]", true, ""}, |
| 578 | {".rst-stats--pairs > .rst-stat:nth-child(odd)", true, ""}, |
| 579 | } { |
| 580 | got, why := requiresNewMarkup(c.sel, baseline) |
| 581 | if got != c.want { |
| 582 | t.Errorf("requiresNewMarkup(%q) = %v (%s), want %v", c.sel, got, why, c.want) |
| 583 | continue |
| 584 | } |
| 585 | if !c.want && !strings.Contains(why, c.reason) { |
| 586 | t.Errorf("requiresNewMarkup(%q) refused it saying %q, which does not name %q", c.sel, why, c.reason) |
| 587 | } |
| 588 | } |
| 589 | } |
| 590 | |