| 1 | package check |
| 2 | |
| 3 | // markup-spelling: old-spelling begin — this whole file is about the |
| 4 | // two spellings and has to be able to write both. It ends at the file's |
| 5 | // end; there is no closing marker. |
| 6 | |
| 7 | // The stage-1 gate for the markup migration (design spec §6-v3). |
| 8 | // |
| 9 | // tokens.css is moving from a class vocabulary to an attribute one: |
| 10 | // `<div rst-box>` where an app writes `class="rst-box"` today. Stage 1 |
| 11 | // is the non-breaking half — every rule names BOTH spellings, so an app |
| 12 | // can begin writing attributes while every app and every shipped |
| 13 | // partial writing classes keeps styling. Stage 2 flips the partials, |
| 14 | // the scaffold, the docs and the examples in one commit; stage 3 drops |
| 15 | // the class selectors before 1.0. |
| 16 | // |
| 17 | // The thing that has to be enforced rather than done once is the |
| 18 | // pairing. A class added to tokens.css in stage 2's absence would |
| 19 | // otherwise arrive with no attribute twin, and nobody would find out |
| 20 | // until an app wrote the attribute and got nothing. So this file parses |
| 21 | // tokens.css and holds the invariant in both directions: every class |
| 22 | // selector has its twin in the same rule, and every attribute selector |
| 23 | // is some class selector's twin — no orphans in either column. |
| 24 | // |
| 25 | // It also checks the part that could go wrong silently. A class and an |
| 26 | // attribute selector both weigh (0,1,0), so a pair is specificity- |
| 27 | // neutral and no cascade moves. That is a fact about CSS, and this file |
| 28 | // computes it per selector rather than trusting it, because the file |
| 29 | // has rules wrapped in :where() (weightless) and rules using :not() and |
| 30 | // :has() (weighted by their heaviest argument), which is exactly where |
| 31 | // a careless twin would shift the cascade and change how an app that |
| 32 | // changed nothing renders. |
| 33 | // |
| 34 | // The string check is necessary and not sufficient: two selectors can |
| 35 | // pair perfectly on paper and still paint differently. The browser |
| 36 | // drive in markup_v3_browser_test.go is the evidence — it renders the |
| 37 | // same fixture in both spellings and requires the computed styles to |
| 38 | // match, property by property, pseudo-elements included. |
| 39 | |
| 40 | import ( |
| 41 | "reflect" |
| 42 | "slices" |
| 43 | "sort" |
| 44 | "strings" |
| 45 | "testing" |
| 46 | |
| 47 | "amadan.net/rastrillo/fiesta" |
| 48 | "amadan.net/rastrillo/fiesta/internal/markup" |
| 49 | ) |
| 50 | |
| 51 | // ── The grammar ────────────────────────────────────────────────────── |
| 52 | // |
| 53 | // It lives in internal/markup, not here. The stage-2 codemod |
| 54 | // (`rastrillo markup`) translates markup with the same rules this file |
| 55 | // checks tokens.css against, and two implementations of one grammar |
| 56 | // agree until the day they do not. attributeTwin is that package's |
| 57 | // Selector; the exemption list is its Utilities. attributeFor, the |
| 58 | // class-to-attribute lookup, and the two regexps that find a class in |
| 59 | // a selector and in markup are in markuphelp_test.go. |
| 60 | |
| 61 | var classKeepsItsSpelling = markup.Utilities |
| 62 | |
| 63 | func attributeTwin(selector string) string { return markup.Selector(selector) } |
| 64 | |
| 65 | // ── A small CSS reader ─────────────────────────────────────────────── |
| 66 | |
| 67 | // cssRule is one rule's selector list, split on top-level commas, and |
| 68 | // the line its prelude ends on so a failure can be walked to. |
| 69 | type cssRule struct { |
| 70 | selectors []string |
| 71 | line int |
| 72 | } |
| 73 | |
| 74 | // parseCSSRules reads tokens.css into its rules. It is deliberately |
| 75 | // small — comment-aware, string-aware, brace-counting — because |
| 76 | // tokens.css is a hand-written file with no preprocessor, and a |
| 77 | // full CSS parser would be a dependency this file has no need of. The |
| 78 | // preludes it returns include at-rules (@media, @supports); those carry |
| 79 | // no rst- selector and fall out of every check below on their own. It |
| 80 | // reads comments and strings with cssSpan, the comment reader the |
| 81 | // declaration gates use, so a /* a browser does not take for a comment |
| 82 | // hides no rule from the spelling gates either, and it refuses what |
| 83 | // cssSpan refuses, a backslash outside a string among them. |
| 84 | // |
| 85 | // It reads css with readStylesheet first and refuses what that refuses. |
| 86 | // A walk over braces alone takes a nested rule for one of its own and |
| 87 | // a statement before a rule for part of its selector; the declaration |
| 88 | // reader refuses both, and on what it reads every { outside a string |
| 89 | // opens a rule or an at-rule's block. |
| 90 | func parseCSSRules(css string) ([]cssRule, error) { |
| 91 | if _, _, err := readStylesheet("the stylesheet", css); err != nil { |
| 92 | return nil, err |
| 93 | } |
| 94 | var rules []cssRule |
| 95 | var prelude strings.Builder |
| 96 | for i := 0; i < len(css); { |
| 97 | end, comment, err := cssSpan(css, i) |
| 98 | if err != nil { |
| 99 | return nil, err |
| 100 | } |
| 101 | switch { |
| 102 | case comment: |
| 103 | case css[i] == '{': |
| 104 | rules = append(rules, cssRule{ |
| 105 | selectors: splitSelectorList(prelude.String()), |
| 106 | line: 1 + strings.Count(css[:i], "\n"), |
| 107 | }) |
| 108 | prelude.Reset() |
| 109 | case css[i] == '}': |
| 110 | prelude.Reset() |
| 111 | default: |
| 112 | prelude.WriteString(css[i:end]) |
| 113 | } |
| 114 | i = end |
| 115 | } |
| 116 | return rules, nil |
| 117 | } |
| 118 | |
| 119 | // cssRules is parseCSSRules failing the test on a stylesheet it |
| 120 | // refuses. |
| 121 | func cssRules(t testing.TB, css string) []cssRule { |
| 122 | t.Helper() |
| 123 | rules, err := parseCSSRules(css) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | return rules |
| 128 | } |
| 129 | |
| 130 | // ruleSelectors is every selector of every rule in css, read with |
| 131 | // cssRules: a gate that asks whether a selector is in the file asks |
| 132 | // it here, where prose and strings are not selectors. |
| 133 | func ruleSelectors(t testing.TB, css string) []string { |
| 134 | t.Helper() |
| 135 | var out []string |
| 136 | for _, rule := range cssRules(t, css) { |
| 137 | out = append(out, rule.selectors...) |
| 138 | } |
| 139 | return out |
| 140 | } |
| 141 | |
| 142 | // splitSelectorList splits on the commas that separate selectors, |
| 143 | // leaving alone the ones inside :is(), :where(), :not(), :has() and |
| 144 | // attribute values. It reads with cssSpan, so a comma or a bracket |
| 145 | // inside a quoted value is text: counting brackets in the raw text, a |
| 146 | // ] inside [title="]"] ended the attribute early and a comma after it |
| 147 | // split one selector in two. The preludes it is given come from a |
| 148 | // reader that has already refused what cssSpan refuses; should one |
| 149 | // not, the rest is read as one selector. |
| 150 | func splitSelectorList(prelude string) []string { |
| 151 | var out []string |
| 152 | depth, start := 0, 0 |
| 153 | for i := 0; i < len(prelude); { |
| 154 | end, _, err := cssSpan(prelude, i) |
| 155 | if err != nil { |
| 156 | break |
| 157 | } |
| 158 | switch prelude[i] { |
| 159 | case '(', '[': |
| 160 | depth++ |
| 161 | case ')', ']': |
| 162 | depth-- |
| 163 | case ',': |
| 164 | if depth == 0 { |
| 165 | out = append(out, collapseSpace(prelude[start:i])) |
| 166 | start = i + 1 |
| 167 | } |
| 168 | } |
| 169 | i = end |
| 170 | } |
| 171 | if s := collapseSpace(prelude[start:]); s != "" { |
| 172 | out = append(out, s) |
| 173 | } |
| 174 | return out |
| 175 | } |
| 176 | |
| 177 | func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") } |
| 178 | |
| 179 | // ── Specificity ────────────────────────────────────────────────────── |
| 180 | |
| 181 | // specificity computes (ids, classes, elements) for one selector, the |
| 182 | // way the selectors spec does: an attribute selector and a class weigh |
| 183 | // the same, :where() weighs nothing at all, and :is()/:not()/:has() |
| 184 | // weigh as much as their heaviest argument. It exists so the gate can |
| 185 | // say "this pair is specificity-neutral" as a measurement rather than |
| 186 | // as a belief. |
| 187 | func specificity(selector string) [3]int { |
| 188 | var s [3]int |
| 189 | i, n := 0, len(selector) |
| 190 | for i < n { |
| 191 | c := selector[i] |
| 192 | switch { |
| 193 | case c == '#': |
| 194 | s[0]++ |
| 195 | i += 1 + identLen(selector[i+1:]) |
| 196 | case c == '.': |
| 197 | s[1]++ |
| 198 | i += 1 + identLen(selector[i+1:]) |
| 199 | case c == '[': |
| 200 | s[1]++ |
| 201 | i = skipTo(selector, i, '[', ']') |
| 202 | case c == ':': |
| 203 | if i+1 < n && selector[i+1] == ':' { |
| 204 | // A pseudo-element. Never functional in this file. |
| 205 | i += 2 |
| 206 | i += identLen(selector[i:]) |
| 207 | s[2]++ |
| 208 | continue |
| 209 | } |
| 210 | i++ |
| 211 | name := selector[i : i+identLen(selector[i:])] |
| 212 | i += len(name) |
| 213 | if i < n && selector[i] == '(' { |
| 214 | end := skipTo(selector, i, '(', ')') |
| 215 | args := splitSelectorList(selector[i+1 : end-1]) |
| 216 | switch strings.ToLower(name) { |
| 217 | case "where": |
| 218 | // Contributes nothing, by definition. |
| 219 | case "is", "not", "has", "matches", "any": |
| 220 | var best [3]int |
| 221 | for _, a := range args { |
| 222 | if v := specificity(a); heavier(v, best) { |
| 223 | best = v |
| 224 | } |
| 225 | } |
| 226 | s[0] += best[0] |
| 227 | s[1] += best[1] |
| 228 | s[2] += best[2] |
| 229 | default: |
| 230 | // :nth-child(2) and friends: an ordinary pseudo-class. |
| 231 | s[1]++ |
| 232 | } |
| 233 | i = end |
| 234 | continue |
| 235 | } |
| 236 | switch strings.ToLower(name) { |
| 237 | case "before", "after", "first-line", "first-letter": |
| 238 | s[2]++ // the four legacy one-colon pseudo-elements |
| 239 | default: |
| 240 | s[1]++ |
| 241 | } |
| 242 | case c == '*': |
| 243 | i++ |
| 244 | case isIdentByte(c): |
| 245 | i += identLen(selector[i:]) |
| 246 | s[2]++ |
| 247 | default: // whitespace, > + ~ | and anything else that weighs nothing |
| 248 | i++ |
| 249 | } |
| 250 | } |
| 251 | return s |
| 252 | } |
| 253 | |
| 254 | func heavier(a, b [3]int) bool { |
| 255 | for k := 0; k < 3; k++ { |
| 256 | if a[k] != b[k] { |
| 257 | return a[k] > b[k] |
| 258 | } |
| 259 | } |
| 260 | return false |
| 261 | } |
| 262 | |
| 263 | func isIdentByte(c byte) bool { |
| 264 | return c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 |
| 265 | } |
| 266 | |
| 267 | func identLen(s string) int { |
| 268 | i := 0 |
| 269 | for i < len(s) && isIdentByte(s[i]) { |
| 270 | i++ |
| 271 | } |
| 272 | return i |
| 273 | } |
| 274 | |
| 275 | // skipTo returns the index just past the bracket that closes the one at |
| 276 | // i, minding nesting and quoted attribute values. |
| 277 | func skipTo(s string, i int, open, close byte) int { |
| 278 | depth := 0 |
| 279 | for ; i < len(s); i++ { |
| 280 | switch c := s[i]; c { |
| 281 | case '"', '\'': |
| 282 | i++ |
| 283 | for i < len(s) && s[i] != c { |
| 284 | if s[i] == '\\' { |
| 285 | i++ |
| 286 | } |
| 287 | i++ |
| 288 | } |
| 289 | case open: |
| 290 | depth++ |
| 291 | case close: |
| 292 | depth-- |
| 293 | if depth == 0 { |
| 294 | return i + 1 |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | return len(s) |
| 299 | } |
| 300 | |
| 301 | // ── The gates ──────────────────────────────────────────────────────── |
| 302 | |
| 303 | // TestEveryClassSelectorHasAnAttributeTwin is the stage-1 invariant. |
| 304 | // For every selector in tokens.css that names an rst- class, the same |
| 305 | // rule must also carry the attribute spelling of that selector — and |
| 306 | // the two must weigh the same, or an app that changed nothing would |
| 307 | // start rendering differently. |
| 308 | func TestEveryClassSelectorHasAnAttributeTwin(t *testing.T) { |
| 309 | assertTwins(t, "tokens.css", string(fiesta.TokensCSS()), 300) |
| 310 | } |
| 311 | |
| 312 | // TestNoAttributeSelectorIsAnOrphan is the same invariant from the |
| 313 | // other end: every rst- attribute selector in tokens.css must be some |
| 314 | // class selector's twin, in its own rule. An attribute rule with no |
| 315 | // class beside it would style the new spelling and not the old one, |
| 316 | // which is the breakage stage 1 exists to avoid. |
| 317 | func TestNoAttributeSelectorIsAnOrphan(t *testing.T) { |
| 318 | assertNoOrphans(t, "tokens.css", string(fiesta.TokensCSS())) |
| 319 | } |
| 320 | |
| 321 | // shell.css is written with exactly one pair, the console bar's |
| 322 | // view-transition-name, and its floor is that one, so the gate cannot |
| 323 | // pass on a shell.css that lost it: the 300 floor tokens.css carries |
| 324 | // would make any small file fail. |
| 325 | func TestShellCSSPairsBothSpellings(t *testing.T) { |
| 326 | assertTwins(t, "shell.css", string(fiesta.ShellCSS()), 1) |
| 327 | assertNoOrphans(t, "shell.css", string(fiesta.ShellCSS())) |
| 328 | } |
| 329 | |
| 330 | // assertTwins holds every rule of css to the pairing rule, and floor is |
| 331 | // the fewest pairs the file may have, so a bulk deletion cannot pass by |
| 332 | // leaving nothing to check. |
| 333 | func assertTwins(t *testing.T, name, css string, floor int) { |
| 334 | t.Helper() |
| 335 | rules := cssRules(t, css) |
| 336 | if len(rules) == 0 { |
| 337 | t.Fatal(name + " parsed to no rules at all — the reader is broken, not the file") |
| 338 | } |
| 339 | |
| 340 | paired := 0 |
| 341 | for _, rule := range rules { |
| 342 | present := make(map[string]bool, len(rule.selectors)) |
| 343 | for _, sel := range rule.selectors { |
| 344 | present[sel] = true |
| 345 | } |
| 346 | for _, sel := range rule.selectors { |
| 347 | twin := attributeTwin(sel) |
| 348 | if twin == sel { |
| 349 | continue // nothing to translate: a twin, or a utility, or not ours |
| 350 | } |
| 351 | if !present[twin] { |
| 352 | t.Errorf(name+":%d: %q has no attribute twin in its rule.\n"+ |
| 353 | "\tadd %q beside it, or the attribute spelling of this rule styles nothing.\n"+ |
| 354 | "\tthe rule reads: %s", |
| 355 | rule.line, sel, twin, strings.Join(rule.selectors, ", ")) |
| 356 | continue |
| 357 | } |
| 358 | paired++ |
| 359 | if a, b := specificity(sel), specificity(twin); a != b { |
| 360 | t.Errorf(name+":%d: the pair does not weigh the same — %q is %v but %q is %v.\n"+ |
| 361 | "\ta twin that shifts the cascade changes how an app that changed nothing renders.", |
| 362 | rule.line, sel, a, twin, b) |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | if paired < floor { |
| 367 | t.Errorf("only %d selectors are paired in %s, the floor is %d, so this is a wholesale loss rather than an edit", paired, name, floor) |
| 368 | } |
| 369 | t.Logf("%d class selectors paired with an attribute twin", paired) |
| 370 | } |
| 371 | |
| 372 | func assertNoOrphans(t *testing.T, name, css string) { |
| 373 | t.Helper() |
| 374 | // A selector spelt to slip past the literal "[rst-" read below |
| 375 | // ([ rst-page], [rst-PAGE], an escape) is refused by name first. |
| 376 | assertPlainSpelling(t, name, css) |
| 377 | for _, rule := range cssRules(t, css) { |
| 378 | twins := make(map[string]bool, len(rule.selectors)) |
| 379 | for _, sel := range rule.selectors { |
| 380 | if twin := attributeTwin(sel); twin != sel { |
| 381 | twins[twin] = true |
| 382 | } |
| 383 | } |
| 384 | for _, sel := range rule.selectors { |
| 385 | if !strings.Contains(sel, "[rst-") { |
| 386 | continue |
| 387 | } |
| 388 | if !twins[sel] { |
| 389 | t.Errorf(name+":%d: %q is an attribute selector with no class selector it is the twin of.\n"+ |
| 390 | "\tuntil stage 3 retires the class spelling, every rule styles both.\n"+ |
| 391 | "\tthe rule reads: %s", rule.line, sel, strings.Join(rule.selectors, ", ")) |
| 392 | } |
| 393 | } |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | // TestClassesThatKeepTheirSpellingAreStillReal keeps the exemption list |
| 398 | // honest. Every entry must still be a class in tokens.css (so a stale |
| 399 | // name cannot sit there excusing something that no longer exists), and |
| 400 | // no entry may have quietly grown an attribute form (so "this stays a |
| 401 | // class" stays true). |
| 402 | func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) { |
| 403 | css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| 404 | // Present means a selector names the class: the text of the file |
| 405 | // holds it in prose and strings too. |
| 406 | styled := styledClasses(t, css) |
| 407 | for class, why := range classKeepsItsSpelling { |
| 408 | if !styled[class] { |
| 409 | t.Errorf("markup.Utilities names %q (%s) and tokens.css has no such class: delete the entry", class, why) |
| 410 | } |
| 411 | flat := "rst-" + strings.ReplaceAll(strings.TrimPrefix(class, "rst-"), "__", "-") |
| 412 | if strings.Contains(css, "["+flat+"]") { |
| 413 | t.Errorf("markup.Utilities names %q (%s) but tokens.css styles [%s]: it is being migrated after all, so drop the exemption", class, why, flat) |
| 414 | } |
| 415 | } |
| 416 | // Stage 1 left one class unpaired for a reason that is not the |
| 417 | // utilities': rst-form__foot and rst-form-foot both existed and |
| 418 | // wanted one attribute. Stage 2 resolved it — the partial's row |
| 419 | // took rst-form-foot, the save bar became rst-form-bar — so the |
| 420 | // exemption is gone and neither old spelling may come back. |
| 421 | for _, gone := range []string{".rst-form__foot", ".rst-form-foot__note"} { |
| 422 | if strings.Contains(css, gone) { |
| 423 | t.Errorf("%s is back in tokens.css: the flip retired it, and it collides with the attribute rst-form-foot already carries", gone) |
| 424 | } |
| 425 | } |
| 426 | // Every name the flip deletes outright must really style nothing, |
| 427 | // or the codemod is removing markup that was doing a job. |
| 428 | for class, why := range markup.Dropped { |
| 429 | if strings.Contains(css, "."+class) { |
| 430 | t.Errorf("markup.Dropped names %q (%s) but tokens.css styles it: deleting it from markup would change what renders", class, why) |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // TestNoTwoClassesWantTheSameAttribute is the check that found the |
| 436 | // rst-form__foot/rst-form-foot collision in the first place. BEM's __ |
| 437 | // flattens to a hyphen, so a __ name and a flat name can land on the |
| 438 | // same attribute — and one attribute cannot carry two different rules. |
| 439 | // Every new name has to be checked against this, not just the ones that |
| 440 | // existed on the day. |
| 441 | func TestNoTwoClassesWantTheSameAttribute(t *testing.T) { |
| 442 | seen := map[string][]string{} |
| 443 | for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) { |
| 444 | name, variant, ok := attributeFor(class) |
| 445 | if !ok { |
| 446 | continue |
| 447 | } |
| 448 | key := name |
| 449 | if variant != "" { |
| 450 | key = name + "~=" + variant |
| 451 | } |
| 452 | known := false |
| 453 | for _, had := range seen[key] { |
| 454 | known = known || had == class |
| 455 | } |
| 456 | if !known { |
| 457 | seen[key] = append(seen[key], class) |
| 458 | } |
| 459 | } |
| 460 | for key, classes := range seen { |
| 461 | if len(classes) > 1 { |
| 462 | sort.Strings(classes) |
| 463 | t.Errorf("%v all translate to %q: one attribute cannot style two different rules — rename one, or add it to classKeepsItsSpelling with the reason", classes, key) |
| 464 | } |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | // The selector reader finds every rule a browser applies. A comment |
| 469 | // marker a browser does not take for one, beside a rule, must not hide |
| 470 | // that rule from the spelling gates: [ rst-PAGE] is [rst-page] on |
| 471 | // every 1.0 page. One inside a string is read past; one an escape |
| 472 | // makes is refused with the backslash, since an escape outside a |
| 473 | // string is one more spelling the reader would have to decode. |
| 474 | func TestTheRuleReaderSeesEveryRuleABrowserApplies(t *testing.T) { |
| 475 | const css = `[rst-snippet]::before { content: "/*"; } [ rst-PAGE] { padding: 0; } [rst-snippet]::after { content: "*/"; }` |
| 476 | seen := false |
| 477 | for _, rule := range cssRules(t, css) { |
| 478 | seen = seen || slices.Contains(rule.selectors, "[ rst-PAGE]") |
| 479 | } |
| 480 | if !seen { |
| 481 | t.Errorf("parseCSSRules(%q) does not read the rule [ rst-PAGE], which a browser applies", css) |
| 482 | } |
| 483 | for _, css := range []string{ |
| 484 | `[rst-snippet] { --rst-gate-a: \/*; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: */; }`, |
| 485 | `[rst-snippet] { --rst-gate-a: \"; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: "\""; }`, |
| 486 | `[rst-snippet] { --rst-gate-a: \url(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: \url(*/ ); }`, |
| 487 | `[rst-snippet] { --rst-gate-a: u\72 l(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: u\72 l(*/ ); }`, |
| 488 | } { |
| 489 | if _, err := parseCSSRules(css); err == nil || !strings.Contains(err.Error(), "backslash") { |
| 490 | t.Errorf("parseCSSRules(%q): %v, want an error naming the backslash", css, err) |
| 491 | } |
| 492 | } |
| 493 | // A nested rule is read under the rule around it, and a statement |
| 494 | // before a rule is not part of its selector. Reading braces alone |
| 495 | // took [rst-d] for a rule of its own and glued the @import to |
| 496 | // [ rst-PAGE]; both are refused, as declarationsOf refuses them. |
| 497 | for _, css := range []string{ |
| 498 | `[rst-c] { color: red; [rst-d] { color: blue } }`, |
| 499 | `@import "x.css"; [ rst-PAGE] { padding: 0; }`, |
| 500 | } { |
| 501 | if rules, err := parseCSSRules(css); err == nil { |
| 502 | t.Errorf("parseCSSRules(%q) = %+v, want an error", css, rules) |
| 503 | } |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // The selector readers read a quoted value as text, as a browser does. |
| 508 | // Read as raw text, each of these let a gate pass: a ] or ( inside a |
| 509 | // string left the bracket count off zero, so [b] stayed glued to the |
| 510 | // selector before it; the last compound was lost, so a rule styling an |
| 511 | // a escaped the tap inventory; and a class or attribute selector |
| 512 | // quoted in a value, or a heading quoted in a string, counted as real. |
| 513 | func TestTheSelectorReadersReadAStringAsText(t *testing.T) { |
| 514 | for _, c := range []struct { |
| 515 | prelude string |
| 516 | want []string |
| 517 | }{ |
| 518 | {`[a="]"], [b]`, []string{`[a="]"]`, "[b]"}}, |
| 519 | {`[a="("], [b]`, []string{`[a="("]`, "[b]"}}, |
| 520 | {`:is(a, b), [c=","]`, []string{":is(a, b)", `[c=","]`}}, |
| 521 | } { |
| 522 | if got := splitSelectorList(c.prelude); !reflect.DeepEqual(got, c.want) { |
| 523 | t.Errorf("splitSelectorList(%q) = %q, want %q", c.prelude, got, c.want) |
| 524 | } |
| 525 | } |
| 526 | if !endsOnControlElement(`[title="]"] a`) { |
| 527 | t.Errorf("endsOnControlElement does not see the a in %q", `[title="]"] a`) |
| 528 | } |
| 529 | const css = `[rst-x] { content: ".rst-y [rst-z]"; } [title=".rst-w [rst-v]"] { color: red; } .rst-u { --rst-a: "/* Utilities */"; }` |
| 530 | classes, attributes := styledClasses(t, css), styledAttributes(t, css) |
| 531 | for _, name := range []string{"rst-y", "rst-w"} { |
| 532 | if classes[name] { |
| 533 | t.Errorf("styledClasses reads %s, which only a string names, as styled", name) |
| 534 | } |
| 535 | } |
| 536 | if !classes["rst-u"] { |
| 537 | t.Error("styledClasses misses .rst-u") |
| 538 | } |
| 539 | for _, name := range []string{"rst-z", "rst-v"} { |
| 540 | if tokensStyle(attributes, name) { |
| 541 | t.Errorf("tokensStyle reads [%s], which only a string names, as styled", name) |
| 542 | } |
| 543 | } |
| 544 | if !tokensStyle(attributes, "rst-x") { |
| 545 | t.Error("tokensStyle misses [rst-x]") |
| 546 | } |
| 547 | if at := commentAt(t, css+" /* Utilities */", "/* Utilities"); at != len(css)+1 { |
| 548 | t.Errorf("commentAt finds the heading at %d, want %d, the comment rather than the string", at, len(css)+1) |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | // TestSpecificityReadsSelectorsTheWayTheSpecDoes is the gate's own |
| 553 | // gate. The pairing check leans on specificity() being right about |
| 554 | // :where() (weightless), :not()/:has() (their heaviest argument) and |
| 555 | // attributes (a class's weight); if it quietly returned zero for |
| 556 | // everything, every pair would look neutral and the check would be |
| 557 | // worthless. |
| 558 | func TestSpecificityReadsSelectorsTheWayTheSpecDoes(t *testing.T) { |
| 559 | for _, c := range []struct { |
| 560 | sel string |
| 561 | want [3]int |
| 562 | }{ |
| 563 | {".rst-btn", [3]int{0, 1, 0}}, |
| 564 | {"[rst-btn]", [3]int{0, 1, 0}}, |
| 565 | {`[rst-btn~="primary"]`, [3]int{0, 1, 0}}, |
| 566 | {".rst-btn:hover", [3]int{0, 2, 0}}, |
| 567 | {"dialog.rst-modal-panel", [3]int{0, 1, 1}}, |
| 568 | {"dialog[rst-modal-panel]", [3]int{0, 1, 1}}, |
| 569 | {".rst-page-header::after", [3]int{0, 1, 1}}, |
| 570 | {"[rst-page-header]::after", [3]int{0, 1, 1}}, |
| 571 | {":where(.rst-list) > :where(:first-child)", [3]int{0, 0, 0}}, |
| 572 | {":where([rst-list]) > :where(:first-child)", [3]int{0, 0, 0}}, |
| 573 | {":where(.rst-page, .rst-page-header) :focus-visible", [3]int{0, 1, 0}}, |
| 574 | {".rst-lrow:not(.rst-lrow--head):has(> .rst-nm, > .rst-person):hover", [3]int{0, 4, 0}}, |
| 575 | {`[rst-lrow]:not([rst-lrow~="head"]):has(> .rst-nm, > [rst-person]):hover`, [3]int{0, 4, 0}}, |
| 576 | {".rst-form > *:not(.rst-form__foot, .rst-form-foot)", [3]int{0, 2, 0}}, |
| 577 | {"[rst-form] > *:not(.rst-form__foot, [rst-form-foot])", [3]int{0, 2, 0}}, |
| 578 | {".rst-search input[type=\"search\"]::-webkit-search-cancel-button", [3]int{0, 2, 2}}, |
| 579 | {"body:has(.rst-backdrop)", [3]int{0, 1, 1}}, |
| 580 | {"#id .c e", [3]int{1, 1, 1}}, |
| 581 | } { |
| 582 | if got := specificity(c.sel); got != c.want { |
| 583 | t.Errorf("specificity(%q) = %v, want %v", c.sel, got, c.want) |
| 584 | } |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | // The translation itself — both halves of the table, selectors and |
| 589 | // markup — is pinned in internal/markup's own tests, where the grammar |
| 590 | // now lives. It used to be duplicated here, and a duplicated table is |
| 591 | // two things that agree until one of them is edited. |
| 592 | |