rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package 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
40import (
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
61var classKeepsItsSpelling = markup.Utilities
62
63func 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.
69type 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.
90func 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.
121func 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.
133func 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.
150func 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
177func 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.
187func 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
254func 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
263func isIdentByte(c byte) bool {
264 return c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80
265}
266
267func 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.
277func 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.
308func 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.
317func 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.
325func 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.
333func 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
372func 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).
402func 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.
441func 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.
474func 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.
513func 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.
558func 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