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
3import (
4 "fmt"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "reflect"
9 "regexp"
10 "sort"
11 "strings"
12 "testing"
13
14 xhtml "golang.org/x/net/html"
15
16 "amadan.net/rastrillo/fiesta/internal/markup"
17)
18
19// testdata/seed/ is 1.0.0's CSS and examples, copied from the tag
20// (TestTheBaselineIsOneOhOh in the root package holds it there). The
21// CSS under css/ moves in 1.x, so it is no longer held to those bytes:
22// a byte freeze cannot survive the first release that changes the
23// CSS, and the names it styles surviving is not its styles surviving
24// (deleting the base [rst-ftok] rule loses every declaration a chip
25// has and no name at all). What is held instead is what 1.x promises:
26// every declaration 1.0.0 makes still applies, in the same context and
27// order (TestEveryOneOhDeclarationStillApplies); everything 1.x adds
28// needs markup 1.0 never wrote (TestEveryNewRuleRequiresNewMarkup);
29// and 1.0's own markup renders as it did (the photographs in
30// parity_browser_test.go and oneoh_browser_test.go).
31//
32// The five are named here rather than walked, so an empty css/ or an
33// empty baseline fails by name instead of passing as a zero-length
34// match.
35
36// baseline maps each stylesheet under css/ to its twin under
37// testdata/seed/. The themes lose their directory, since the baseline
38// is flat.
39var baseline = map[string]string{
40 "tokens.css": "tokens.css",
41 "shell.css": "shell.css",
42 "themes/day.css": "day.css",
43 "themes/plain.css": "plain.css",
44 "themes/signal.css": "signal.css",
45}
46
47// widening is a 1.0 selector 1.x widens in place, the way the focus
48// scope's :where() gains a new component: Now is the selector as 1.x
49// writes it, Added the arguments it gained, Why the reason. The 1.0
50// declarations under the old selector are found under Now, and taking
51// Added out of Now must give the old selector back exactly, so an
52// entry cannot quietly drop an argument while it adds one.
53type widening struct {
54 Now string
55 Added []string
56 Why string
57}
58
59// The footer row's menus are flat, as a shell tail's are, so each of the
60// four summary-face rules, in each spelling, excludes them in its :not().
61// The new argument weighs what the shell tail's does, so no summary
62// the rule already styles changes weight.
63const footFlat = "the footer row's menus are flat, as a shell tail's are"
64
65// A seg-tabs button is a control as a seg-tabs link is, and a side
66// nav link is a menu row, so the rule that sizes an icon inside a
67// control names both. :where() weighs nothing, so no icon 1.0 already
68// sizes changes.
69const newControlIcons = "a seg-tabs button and a side nav link are controls, so an icon in one is sized as in a control"
70
71// A side nav may sit outside an rst-page, as the landing page's sample
72// does, so the focus scope names it, or its links would show the
73// browser's thin ring there. :where() weighs nothing, so no focus ring
74// 1.0 already draws changes.
75const sideFocus = "a side nav outside an rst-page still shows the focus ring"
76
77// widenedSelectors is keyed by the 1.0 selector. Each entry must be
78// used: an entry for a selector the CSS no longer widens is a promise
79// about nothing.
80var widenedSelectors = map[string]widening{
81 ":where(.rst-btn, .rst-search, .rst-search__clear, .rst-ftok a, .rst-help, .rst-dropdown > summary, .rst-dropdown__menu a, .rst-dropdown__menu button, .rst-dropdown__menu .rst-menu-group > summary, .rst-row-menu__panel a, .rst-row-menu__panel button, .rst-locale button, .rst-scheme button, .rst-combo__option, .rst-dtp__row, .rst-dtp__pick, .rst-cal__nav, .rst-row__action, .rst-row-menu > summary, .rst-pagination > a, .rst-seg-tabs a, .rst-switch, .rst-choice__cards label, .rst-tblock__head, .rst-bulkbar__close, .rst-bulkbar__escalate, .rst-modal-close, .rst-modal-panel > nav a, .rst-back-nav a, .rst-signin__providers > a, .rst-shell__brand, .rst-shell__nav a, .rst-shell__menu > summary, .rst-shell__chrome > summary, .rst-shell__account > summary, .rst-shell__back > a) .icon": {
82 Now: ":where(.rst-btn, .rst-search, .rst-search__clear, .rst-ftok a, .rst-help, .rst-dropdown > summary, .rst-dropdown__menu a, .rst-dropdown__menu button, .rst-dropdown__menu .rst-menu-group > summary, .rst-row-menu__panel a, .rst-row-menu__panel button, .rst-locale button, .rst-scheme button, .rst-combo__option, .rst-dtp__row, .rst-dtp__pick, .rst-cal__nav, .rst-row__action, .rst-row-menu > summary, .rst-pagination > a, .rst-seg-tabs a, .rst-seg-tabs > button, .rst-side__nav a, .rst-switch, .rst-choice__cards label, .rst-tblock__head, .rst-bulkbar__close, .rst-bulkbar__escalate, .rst-modal-close, .rst-modal-panel > nav a, .rst-back-nav a, .rst-signin__providers > a, .rst-shell__brand, .rst-shell__nav a, .rst-shell__menu > summary, .rst-shell__chrome > summary, .rst-shell__account > summary, .rst-shell__back > a) .icon",
83 Added: []string{".rst-seg-tabs > button", ".rst-side__nav a"},
84 Why: newControlIcons,
85 },
86 ":where([rst-btn], [rst-search], [rst-search-clear], [rst-ftok] a, [rst-help], [rst-dropdown] > summary, [rst-dropdown-menu] a, [rst-dropdown-menu] button, [rst-dropdown-menu] [rst-menu-group] > summary, [rst-row-menu-panel] a, [rst-row-menu-panel] button, [rst-locale] button, [rst-scheme] button, [rst-combo-option], [rst-dtp-row], [rst-dtp-pick], [rst-cal-nav], [rst-row-action], [rst-row-menu] > summary, [rst-pagination] > a, [rst-seg-tabs] a, [rst-switch], [rst-choice-cards] label, [rst-tblock-head], [rst-bulkbar-close], [rst-bulkbar-escalate], [rst-modal-close], [rst-modal-panel] > nav a, [rst-back-nav] a, [rst-signin-providers] > a, [rst-shell-brand], [rst-shell-nav] a, [rst-shell-menu] > summary, [rst-shell-chrome] > summary, [rst-shell-account] > summary, [rst-shell-back] > a) .icon": {
87 Now: ":where([rst-btn], [rst-search], [rst-search-clear], [rst-ftok] a, [rst-help], [rst-dropdown] > summary, [rst-dropdown-menu] a, [rst-dropdown-menu] button, [rst-dropdown-menu] [rst-menu-group] > summary, [rst-row-menu-panel] a, [rst-row-menu-panel] button, [rst-locale] button, [rst-scheme] button, [rst-combo-option], [rst-dtp-row], [rst-dtp-pick], [rst-cal-nav], [rst-row-action], [rst-row-menu] > summary, [rst-pagination] > a, [rst-seg-tabs] a, [rst-seg-tabs] > button, [rst-side-nav] a, [rst-switch], [rst-choice-cards] label, [rst-tblock-head], [rst-bulkbar-close], [rst-bulkbar-escalate], [rst-modal-close], [rst-modal-panel] > nav a, [rst-back-nav] a, [rst-signin-providers] > a, [rst-shell-brand], [rst-shell-nav] a, [rst-shell-menu] > summary, [rst-shell-chrome] > summary, [rst-shell-account] > summary, [rst-shell-back] > a) .icon",
88 Added: []string{"[rst-seg-tabs] > button", "[rst-side-nav] a"},
89 Why: newControlIcons,
90 },
91 ":where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination) :focus-visible": {
92 Now: ":where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination, .rst-side) :focus-visible",
93 Added: []string{".rst-side"},
94 Why: sideFocus,
95 },
96 ":where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination]) :focus-visible": {
97 Now: ":where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination], [rst-side]) :focus-visible",
98 Added: []string{"[rst-side]"},
99 Why: sideFocus,
100 },
101 ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn)": {
102 Now: ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-page__foot .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn)",
103 Added: []string{".rst-page__foot .rst-dropdown"},
104 Why: footFlat,
105 },
106 "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn])": {
107 Now: "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-page-foot] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn])",
108 Added: []string{"[rst-page-foot] [rst-dropdown]"},
109 Why: footFlat,
110 },
111 ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):active": {
112 Now: ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-page__foot .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):active",
113 Added: []string{".rst-page__foot .rst-dropdown"},
114 Why: footFlat,
115 },
116 "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):active": {
117 Now: "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-page-foot] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):active",
118 Added: []string{"[rst-page-foot] [rst-dropdown]"},
119 Why: footFlat,
120 },
121 ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):hover": {
122 Now: ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-page__foot .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):hover",
123 Added: []string{".rst-page__foot .rst-dropdown"},
124 Why: footFlat,
125 },
126 "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):hover": {
127 Now: "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-page-foot] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):hover",
128 Added: []string{"[rst-page-foot] [rst-dropdown]"},
129 Why: footFlat,
130 },
131 ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):hover:active": {
132 Now: ".rst-dropdown:not(.rst-shell__account, .rst-shell__profile, .rst-shell__tail .rst-dropdown, .rst-page__foot .rst-dropdown, .rst-bulkbar .rst-dropdown, .rst-dropdown__menu .rst-dropdown) > summary:not(.rst-btn):hover:active",
133 Added: []string{".rst-page__foot .rst-dropdown"},
134 Why: footFlat,
135 },
136 "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):hover:active": {
137 Now: "[rst-dropdown]:not([rst-shell-account], [rst-shell-profile], [rst-shell-tail] [rst-dropdown], [rst-page-foot] [rst-dropdown], [rst-bulkbar] [rst-dropdown], [rst-dropdown-menu] [rst-dropdown]) > summary:not([rst-btn]):hover:active",
138 Added: []string{"[rst-page-foot] [rst-dropdown]"},
139 Why: footFlat,
140 },
141}
142
143// newOnOldMarkup is the few new rules that do style markup 1.0 could
144// already carry, each an exact selector with why: a button in a
145// seg-tabs track, which 1.0 documents only as links and leaves
146// unstyled. Compared after parsing, compound by compound, never as a
147// substring, so a selector that merely contains an entry is not it.
148var newOnOldMarkup = map[string]string{
149 "[rst-seg-tabs] > button": segTabButton,
150 "[rst-seg-tabs] > button:hover": segTabButton,
151 `[rst-seg-tabs] > button[aria-pressed="true"]`: segTabButton,
152 ".rst-seg-tabs > button": segTabButton,
153 ".rst-seg-tabs > button:hover": segTabButton,
154 `.rst-seg-tabs > button[aria-pressed="true"]`: segTabButton,
155 "[rst-seg-tabs] > button:disabled": segTabButton,
156 ".rst-seg-tabs > button:disabled": segTabButton,
157}
158
159// segTabButton is each entry's why. Every entry uses the child
160// combinator, so it reaches only a track's own buttons: a button nested
161// deeper, inside some other control a 1.0 page put in a track, is not
162// a tab and keeps the look 1.0 gave it.
163const segTabButton = "1.0 documents only links in a seg-tabs track and leaves a button in one unstyled"
164
165// refinement is one declaration under a 1.0 selector that a 1.x release
166// changes or adds on purpose, to fix how 1.0 markup draws: its file,
167// context, selector and property, the value 1.0.0 gave it (Was, empty
168// for an addition), the value the tree gives it (Now), and why. The /1/
169// promise allows a fix the changelog announces; this list is where each
170// is named, value for value, so nothing else passes for one. Every entry
171// must be used: a 1.0 declaration with Was changed to exactly Now, or a
172// new record that is exactly Now.
173type refinement struct {
174 File, Context, Selector, Property, Was, Now string
175 Why string
176}
177
178// The console rail's nav in its two spellings, under the media query
179// that puts the rail beside the page.
180const (
181 wideRail = "@media (min-width: 800px)"
182 railNavClasses = ".rst-shell-console > .rst-shell__rail > .rst-shell__nav"
183 railNavAttributes = "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav]"
184)
185
186// railRingReason is why each of the console rail's refinements is there.
187const railRingReason = "1.2.0 takes Rastrillo cc7c9c69: a nav that " +
188 "scrolls clips at its padding box, so 1.0's console rail cut its " +
189 "current item's ring and contact shadow off at its edges. 5px of " +
190 "padding gives them room, and the negative margins, the smaller " +
191 "sticky inset and the larger cap give it back, so no item moves " +
192 "(consoleRailRing in rendering_exception_browser_test.go " +
193 "photographs it)"
194
195// railRingChange is one declaration the ring fix changes or adds,
196// written once and refined in both spellings.
197type railRingChange struct{ Property, Was, Now string }
198
199var railRingChanges = []railRingChange{
200 {
201 Property: "inset-block-start",
202 Was: "var(--rst-sp-4)",
203 Now: "calc(var(--rst-sp-4) - 5px)",
204 },
205 {Property: "margin-block", Now: "-5px"},
206 {Property: "margin-inline", Now: "-5px"},
207 {
208 Property: "max-block-size",
209 Was: "calc(100dvh - 2 * var(--rst-sp-4))",
210 Now: "calc(100dvh - 2 * var(--rst-sp-4) + 10px)",
211 },
212 {Property: "padding-block", Now: "5px"},
213 {Property: "padding-inline", Now: "5px"},
214}
215
216// refinedDeclarations is every refinement 1.x makes, in file order.
217var refinedDeclarations = railRingRefinements()
218
219func railRingRefinements() []refinement {
220 var refinements []refinement
221 for _, selector := range []string{railNavClasses, railNavAttributes} {
222 for _, change := range railRingChanges {
223 refinements = append(refinements, refinement{
224 File: "css/tokens.css",
225 Context: wideRail,
226 Selector: selector,
227 Property: change.Property,
228 Was: change.Was,
229 Now: change.Now,
230 Why: railRingReason,
231 })
232 }
233 }
234 return refinements
235}
236
237// refines reports whether entry names record's file, context, selector
238// and property, with record not !important.
239func refines(entry refinement, record declaration) bool {
240 return record.Context == entry.Context &&
241 record.Selector == entry.Selector &&
242 record.Property == entry.Property && !record.Important
243}
244
245// refined reports which refinement turns was into is, if one does:
246// was is 1.0.0's declaration with the entry's Was, and is the tree's
247// with its Now, both where the entry says.
248func refined(was, is declaration) (int, bool) {
249 for index, entry := range refinedDeclarations {
250 seed := "check/testdata/seed/" +
251 baseline[strings.TrimPrefix(entry.File, "css/")]
252 if entry.Was != "" && was.File == seed && is.File == entry.File &&
253 refines(entry, was) && refines(entry, is) &&
254 was.Value == entry.Was && is.Value == entry.Now {
255 return index, true
256 }
257 }
258 return 0, false
259}
260
261// addedByRefinement reports which refinement adds record, if one does.
262func addedByRefinement(record declaration) (int, bool) {
263 for index, entry := range refinedDeclarations {
264 if entry.Was == "" && record.File == entry.File &&
265 refines(entry, record) && record.Value == entry.Now {
266 return index, true
267 }
268 }
269 return 0, false
270}
271
272// declaration is one thing a stylesheet says, read in the context that
273// decides when it applies: the chain of grouping rules around it, one
274// selector of its rule's list, the property, the value and whether it
275// is !important. A descriptor at-rule (@property, @view-transition,
276// @font-face) gives one per descriptor with no selector, the at-rule
277// and its name ending the context; a @keyframes gives one per keyframe
278// selector (from, to, 50%) per declaration, the context ending in the
279// animation's name.
280type declaration struct {
281 File, Context, Selector, Property, Value string
282 Important bool
283}
284
285// String is the declaration as a failure names it.
286func (d declaration) String() string {
287 var b strings.Builder
288 if d.Context != "" {
289 b.WriteString(d.Context + " { ")
290 }
291 if d.Selector != "" {
292 b.WriteString(d.Selector + " { ")
293 }
294 b.WriteString(d.Property + ": " + d.Value)
295 if d.Important {
296 b.WriteString(" !important")
297 }
298 if d.Selector != "" {
299 b.WriteString(" }")
300 }
301 if d.Context != "" {
302 b.WriteString(" }")
303 }
304 return b.String()
305}
306
307// The at-rules declarationsOf reads. A grouping rule only adds to the
308// context of what it holds; a descriptor rule holds declarations with
309// no selector. Anything else is refused by name: @layer would reorder
310// the cascade, @import would bring rules from elsewhere, and reading
311// either as if it were not there would compare declarations that no
312// longer apply the way the comparison assumes.
313var (
314 groupingRules = map[string]bool{"media": true, "supports": true, "container": true}
315 descriptorRules = map[string]bool{"property": true, "view-transition": true, "font-face": true}
316)
317
318// declarationsOf reads css, the contents of file, into its records in
319// source order: for a style rule one per selector of its list per
320// declaration, selector by selector. Comments are read out first. It
321// is a reader for these hand-written files, not a CSS parser: a nested
322// style rule, a statement at-rule or an at-rule it does not know is an
323// error naming it, so a file that grows one fails here rather than
324// being half read.
325func declarationsOf(file, css string) ([]declaration, error) {
326 decls, _, err := readStylesheet(file, css)
327 return decls, err
328}
329
330// readStylesheet is declarationsOf with the identity of every
331// descriptor and keyframes at-rule as well, in source order
332// ("@keyframes rst-spin", "@property --rst-press"), empty ones
333// included: an empty @keyframes rst-spin {} says nothing a declaration
334// could record, and still replaces 1.0's spinner on every page.
335func readStylesheet(file, css string) ([]declaration, []string, error) {
336 r := cssReader{file: file}
337 bare, err := cssWithoutComments(css)
338 if err == nil {
339 err = r.rules(bare, nil)
340 }
341 if err != nil {
342 return nil, nil, fmt.Errorf("%s: %w", file, err)
343 }
344 return r.out, r.atRules, nil
345}
346
347type cssReader struct {
348 file string
349 out []declaration
350 atRules []string
351 leaves []leafRule
352 top int // the top-level statement being read
353}
354
355// cssItem is one statement of a rule list: its prelude, and its block
356// when it has one.
357type cssItem struct {
358 prelude, block string
359 hasBlock bool
360}
361
362// rules reads one rule list, the file's top level or a grouping rule's
363// block, with the contexts around it.
364func (r *cssReader) rules(src string, context []string) error {
365 items, err := cssItems(src)
366 if err != nil {
367 return err
368 }
369 for k, it := range items {
370 if len(context) == 0 {
371 r.top = k
372 }
373 prelude := collapseSpace(it.prelude)
374 if !it.hasBlock {
375 return fmt.Errorf("%q is a statement outside any rule, which this reader does not read", prelude)
376 }
377 if !strings.HasPrefix(prelude, "@") {
378 decls, err := cssDeclarations(it.block)
379 if err != nil {
380 return fmt.Errorf("%s: %w", prelude, err)
381 }
382 r.add(context, splitSelectorList(prelude), decls)
383 r.leaves = append(r.leaves, leafRule{context: strings.Join(context, " > "), top: r.top, selector: prelude, decls: decls})
384 continue
385 }
386 name := atRuleName(prelude)
387 inner := append(append([]string(nil), context...), prelude)
388 switch {
389 case groupingRules[name]:
390 if err := r.rules(it.block, inner); err != nil {
391 return err
392 }
393 case descriptorRules[name]:
394 r.atRules = append(r.atRules, prelude)
395 decls, err := cssDeclarations(it.block)
396 if err != nil {
397 return fmt.Errorf("%s: %w", prelude, err)
398 }
399 r.add(inner, []string{""}, decls)
400 case name == "keyframes":
401 r.atRules = append(r.atRules, prelude)
402 frames, err := cssItems(it.block)
403 if err != nil {
404 return err
405 }
406 for _, f := range frames {
407 decls, err := cssDeclarations(f.block)
408 if err != nil {
409 return fmt.Errorf("%s: %w", prelude, err)
410 }
411 r.add(inner, splitSelectorList(f.prelude), decls)
412 r.leaves = append(r.leaves, leafRule{context: strings.Join(inner, " > "), top: r.top, selector: collapseSpace(f.prelude), decls: decls})
413 }
414 default:
415 return fmt.Errorf("the at-rule @%s is not one this reader knows; teach declarationsOf what it means before a stylesheet uses it", name)
416 }
417 }
418 return nil
419}
420
421// add records each declaration under each selector, selector by
422// selector, as one record each.
423func (r *cssReader) add(context, selectors []string, decls [][3]string) {
424 ctx := strings.Join(context, " > ")
425 for _, sel := range selectors {
426 for _, d := range decls {
427 r.out = append(r.out, declaration{File: r.file, Context: ctx, Selector: sel, Property: d[0], Value: d[1], Important: d[2] != ""})
428 }
429 }
430}
431
432// atRuleName is the lower-case name of the at-rule a prelude opens.
433func atRuleName(prelude string) string {
434 name := strings.TrimPrefix(prelude, "@")
435 if i := strings.IndexAny(name, " (\t\n"); i >= 0 {
436 name = name[:i]
437 }
438 return strings.ToLower(name)
439}
440
441// cssItems splits a rule list into its statements, each up to its ;
442// or through its {} block. It reads with cssSpan, so a bracket inside
443// content: "{" is text, and steps over a () or [] group whole with
444// braceMatchEnd, so a ; or { inside url() or an attribute selector is
445// not taken for structure. A bracket that closes nothing is an error,
446// not a count below zero.
447func cssItems(src string) ([]cssItem, error) {
448 var items []cssItem
449 start := 0
450 for i := 0; i < len(src); {
451 end, _, err := cssSpan(src, i)
452 if err != nil {
453 return nil, err
454 }
455 switch c := src[i]; c {
456 case '(', '[':
457 close, err := braceMatchEnd(src, i+1)
458 if err != nil {
459 return nil, err
460 }
461 end = close + 1
462 case '{':
463 close, err := braceMatchEnd(src, i+1)
464 if err != nil {
465 return nil, fmt.Errorf("the block opened after %q: %w", collapseSpace(src[start:i]), err)
466 }
467 items = append(items, cssItem{prelude: src[start:i], block: src[i+1 : close], hasBlock: true})
468 end, start = close+1, close+1
469 case ')', ']', '}':
470 return nil, fmt.Errorf("a %c closes nothing after %q", c, collapseSpace(src[start:i]))
471 case ';':
472 if p := strings.TrimSpace(src[start:i]); p != "" {
473 items = append(items, cssItem{prelude: p})
474 }
475 start = i + 1
476 }
477 i = end
478 }
479 if rest := strings.TrimSpace(src[start:]); rest != "" {
480 items = append(items, cssItem{prelude: rest})
481 }
482 return items, nil
483}
484
485// cssDeclarations reads a block's declarations as property, value and
486// a non-empty third field when !important, the value's whitespace
487// collapsed. It reads brackets as cssItems does: a ; inside a string
488// or a () or [] group ends no declaration, and a ) that closes nothing
489// is an error, where a count below zero read every declaration after
490// it as part of the one before. A { in the block is a nested rule,
491// which these files do not use and this reader does not read.
492//
493// A property other than a custom property is read in lower case, as a
494// browser reads it: TRANSITION is transition, and a gate looking for
495// one must not miss the other.
496func cssDeclarations(block string) ([][3]string, error) {
497 var out [][3]string
498 start := 0
499 flush := func(end int) error {
500 piece := strings.TrimSpace(block[start:end])
501 if piece == "" {
502 return nil
503 }
504 prop, value, ok := strings.Cut(piece, ":")
505 if !ok {
506 return fmt.Errorf("%q is not a declaration", collapseSpace(piece))
507 }
508 if prop = strings.TrimSpace(prop); !strings.HasPrefix(prop, "--") {
509 prop = strings.ToLower(prop)
510 }
511 value = collapseSpace(value)
512 important := ""
513 if v, ok := cutImportant(value); ok {
514 value, important = v, "!important"
515 }
516 out = append(out, [3]string{prop, value, important})
517 return nil
518 }
519 for i := 0; i < len(block); {
520 end, _, err := cssSpan(block, i)
521 if err != nil {
522 return nil, err
523 }
524 switch c := block[i]; c {
525 case '(', '[':
526 close, err := braceMatchEnd(block, i+1)
527 if err != nil {
528 return nil, err
529 }
530 end = close + 1
531 case '{':
532 return nil, fmt.Errorf("a nested rule after %q, which this reader does not read", collapseSpace(block[start:i]))
533 case ')', ']', '}':
534 return nil, fmt.Errorf("a %c closes nothing after %q", c, collapseSpace(block[start:i]))
535 case ';':
536 if err := flush(i); err != nil {
537 return nil, err
538 }
539 start = i + 1
540 }
541 i = end
542 }
543 if err := flush(len(block)); err != nil {
544 return nil, err
545 }
546 return out, nil
547}
548
549// cutImportant takes a trailing !important (any case, any space after
550// the !) off a value.
551func cutImportant(value string) (string, bool) {
552 i := strings.LastIndex(value, "!")
553 if i < 0 || !strings.EqualFold(strings.TrimSpace(value[i+1:]), "important") {
554 return value, false
555 }
556 return strings.TrimSpace(value[:i]), true
557}
558
559// ── The two declaration gates ────────────────────────────────────────
560
561// readDeclarations reads one stylesheet from disk, failing the test if
562// it is missing, empty or unreadable as declarations.
563func readDeclarations(t *testing.T, path, name string) ([]declaration, []string, string) {
564 t.Helper()
565 b, err := os.ReadFile(path)
566 if err != nil {
567 t.Fatalf("%s: %v", name, err)
568 }
569 if len(b) == 0 {
570 t.Fatalf("%s is empty; an empty stylesheet holds nothing", name)
571 }
572 decls, atRules := parseDeclarations(t, name, string(b))
573 return decls, atRules, string(b)
574}
575
576// parseDeclarations is declarationsOf failing the test on an error or
577// on a stylesheet that reads as nothing.
578func parseDeclarations(t *testing.T, name, css string) ([]declaration, []string) {
579 t.Helper()
580 decls, atRules, err := readStylesheet(name, css)
581 if err != nil {
582 t.Fatal(err)
583 }
584 if len(decls) == 0 {
585 t.Fatalf("%s reads as no declarations at all", name)
586 }
587 return decls, atRules
588}
589
590// sideBySide is one stylesheet as 1.0.0 shipped it and as the tree has
591// it, for each of the five, in a fixed order.
592type sideBySide struct {
593 src string // the path under css/
594 was, is []declaration
595 wasAt, isAt []string // readStylesheet's at-rule identities
596 wasBytes string
597}
598
599func everyStylesheet(t *testing.T) []sideBySide {
600 t.Helper()
601 var out []sideBySide
602 for _, src := range sortedKeys(baseline) {
603 seed := baseline[src]
604 was, wasAt, wasBytes := readDeclarations(t, filepath.Join("testdata", "seed", seed), "check/testdata/seed/"+seed)
605 is, isAt, _ := readDeclarations(t, filepath.Join("..", "css", filepath.FromSlash(src)), "css/"+src)
606 out = append(out, sideBySide{src: src, was: was, is: is, wasAt: wasAt, isAt: isAt, wasBytes: wasBytes})
607 }
608 return out
609}
610
611func sortedKeys[V any](m map[string]V) []string {
612 keys := make([]string, 0, len(m))
613 for k := range m {
614 keys = append(keys, k)
615 }
616 sort.Strings(keys)
617 return keys
618}
619
620// carries reports whether is says what was said: the same context,
621// property, value and weight, under the same selector or under the
622// selector a planned widening turned it into.
623func carries(was, is declaration) bool {
624 if _, ok := refined(was, is); ok {
625 return true
626 }
627 if was.Context != is.Context || was.Property != is.Property || was.Value != is.Value || was.Important != is.Important {
628 return false
629 }
630 if was.Selector == is.Selector {
631 return true
632 }
633 w, ok := widenedSelectors[was.Selector]
634 return ok && w.Now == is.Selector
635}
636
637// TestEveryOneOhDeclarationStillApplies: in each of the five
638// stylesheets, every declaration 1.0.0 makes is still made, with the
639// same context, property, value and weight, under the same selector or
640// under its planned widening, and 1.0.0's declarations come in the same
641// relative order. A declaration lost is a 1.0 page drawn without it; a
642// declaration moved can be overtaken by one it used to follow, which
643// is a 1.0 page drawn differently with every name still there. A
644// transition, a print rule, a forced-colours rule, a @property
645// descriptor and a keyframe are declarations like any other. Every
646// widenedSelectors entry is used and says exactly what it adds; and
647// every file under css/ has a baseline twin, or a stylesheet added
648// beside the five would ship held to nothing.
649func TestEveryOneOhDeclarationStillApplies(t *testing.T) {
650 used, refinedUsed := map[string]bool{}, map[int]bool{}
651 for _, f := range everyStylesheet(t) {
652 t.Logf("css/%s: 1.0.0 makes %d declarations, the tree %d", f.src, len(f.was), len(f.is))
653 next := 0
654 taken := make([]bool, len(f.is))
655 for _, was := range f.was {
656 found := -1
657 for j := next; j < len(f.is); j++ {
658 if !taken[j] && carries(was, f.is[j]) {
659 found = j
660 break
661 }
662 }
663 if found >= 0 {
664 taken[found], next = true, found+1
665 if was.Selector != f.is[found].Selector {
666 used[was.Selector] = true
667 }
668 if index, ok := refined(was, f.is[found]); ok {
669 refinedUsed[index] = true
670 }
671 continue
672 }
673 moved := false
674 for j := 0; j < next; j++ {
675 if !taken[j] && carries(was, f.is[j]) {
676 taken[j], moved = true, true
677 break
678 }
679 }
680 if moved {
681 t.Errorf("css/%s: 1.0.0's %s now comes before a 1.0.0 declaration it followed; a 1.0 declaration moved can be overtaken by one it used to beat", f.src, was)
682 } else {
683 t.Errorf("css/%s: 1.0.0's %s is gone; a 1.0 page would render without it", f.src, was)
684 }
685 }
686 }
687 for _, old := range sortedKeys(widenedSelectors) {
688 w := widenedSelectors[old]
689 if !used[old] {
690 t.Errorf("widenedSelectors[%q] is used by no declaration: no 1.0 declaration under it is found under %q", old, w.Now)
691 }
692 back := w.Now
693 for _, arg := range w.Added {
694 if !strings.Contains(back, ", "+arg) {
695 t.Errorf("widenedSelectors[%q]: %q does not hold the added argument %q", old, w.Now, arg)
696 continue
697 }
698 back = strings.Replace(back, ", "+arg, "", 1)
699 }
700 if back != old {
701 t.Errorf("widenedSelectors[%q]: taking %q out of %q gives %q, not the 1.0 selector; the widening changes more than it says", old, w.Added, w.Now, back)
702 }
703 }
704
705 for index, entry := range refinedDeclarations {
706 if entry.Was != "" && !refinedUsed[index] {
707 t.Errorf("refinedDeclarations: no 1.0 declaration "+
708 "%s { %s { %s: %s } } became %s in %s; a refinement of "+
709 "nothing is a hole waiting to be used", entry.Context,
710 entry.Selector, entry.Property, entry.Was, entry.Now,
711 entry.File)
712 }
713 }
714
715 // Every file under css/ is one of the five.
716 root := filepath.Join("..", "css")
717 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
718 if err != nil || d.IsDir() {
719 return err
720 }
721 rel, err := filepath.Rel(root, path)
722 if err != nil {
723 return err
724 }
725 if _, ok := baseline[filepath.ToSlash(rel)]; !ok {
726 t.Errorf("css/%s has no baseline twin under check/testdata/seed/; a stylesheet 1.0.0 did not ship is a new link, not part of the one-link promise", filepath.ToSlash(rel))
727 }
728 return nil
729 })
730 if err != nil {
731 t.Fatalf("walking css/: %v", err)
732 }
733}
734
735// vocabularyOfBaseline is every name 1.0 markup may carry: each rst-
736// attribute, attribute and value token, and class that 1.0.0's five
737// stylesheets style, that 1.0.0's frozen examples write (an attribute
738// can be in 1.0 markup with no rule of its own, as rst-job is around
739// every progress bar), and that the coverage gate names as hooks or
740// qualifiers. A new rule needs a name outside it to be sure no 1.0
741// page carries it; a name only 1.0's CSS was missing would let
742// [rst-job] [rst-job-bar] restyle every 1.0 job.
743func vocabularyOfBaseline(t *testing.T, files []sideBySide) map[string]bool {
744 t.Helper()
745 var css []string
746 for _, f := range files {
747 css = append(css, f.wasBytes)
748 }
749 names := vocabularyOf(t, css...)
750 for _, f := range files {
751 for class := range styledClasses(t, f.wasBytes) {
752 names[class] = true
753 }
754 }
755 for name := range unstyledByDesign {
756 names[name] = true
757 }
758 for name := range qualifiedOnly {
759 names[name] = true
760 }
761 root := filepath.Join("testdata", "seed", "examples")
762 pages := 0
763 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
764 if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
765 return err
766 }
767 b, err := os.ReadFile(path)
768 if err != nil {
769 return err
770 }
771 pages++
772 markupNames(string(b), names)
773 return nil
774 })
775 if err != nil {
776 t.Fatal(err)
777 }
778 if pages < 163 {
779 t.Fatalf("read %d of 1.0.0's example documents under %s, want 163", pages, root)
780 }
781 return names
782}
783
784// markupNames adds every rst- name one document's tags carry: each
785// attribute, each token of its value, and each rst- class, read also
786// as the attribute it spells.
787func markupNames(doc string, names map[string]bool) {
788 z := xhtml.NewTokenizer(strings.NewReader(doc))
789 for {
790 switch z.Next() {
791 case xhtml.ErrorToken:
792 return
793 case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
794 _, more := z.TagName()
795 for more {
796 var k, v []byte
797 k, v, more = z.TagAttr()
798 key, value := string(k), string(v)
799 switch {
800 case strings.HasPrefix(key, "rst-"):
801 names[key] = true
802 for _, token := range strings.Fields(value) {
803 names[key+"~="+token] = true
804 }
805 case key == "class":
806 for _, class := range strings.Fields(value) {
807 if !strings.HasPrefix(class, "rst-") {
808 continue
809 }
810 names[class] = true
811 if name, variant, ok := markup.Attribute(class); ok {
812 names[name] = true
813 if variant != "" {
814 names[name+"~="+variant] = true
815 }
816 }
817 }
818 }
819 }
820 }
821 }
822}
823
824// customProperty is a custom property's name wherever it is written:
825// declared, read through var(), or registered.
826var customProperty = regexp.MustCompile(`--[A-Za-z0-9_-]+`)
827
828// TestEveryNewRuleRequiresNewMarkup: everything 1.x adds to the five
829// stylesheets styles only markup 1.0 never wrote, so a 1.0 page is
830// drawn as it was. The 1.0 declarations are matched first, under
831// their widenings too; every other record must be one of:
832// - a declaration whose selector positively requires a name 1.0.0
833// never styles (requiresNewMarkup), in the narrow grammar a small
834// parser reads, so a new name mentioned under :not() or in one
835// branch of :is() does not pass for one required;
836// - one of newOnOldMarkup's exact selectors;
837// - a new --rst-* custom property under exactly :root, which no 1.0
838// rule reads;
839// - a descriptor or keyframe of a new @property --rst-… or
840// @keyframes rst-…, which nothing 1.0 names.
841//
842// Every newOnOldMarkup entry is used.
843func TestEveryNewRuleRequiresNewMarkup(t *testing.T) {
844 for _, why := range refusedAdditions(t, everyStylesheet(t)) {
845 t.Error(why)
846 }
847}
848
849// refusedAdditions is TestEveryNewRuleRequiresNewMarkup over any five
850// stylesheets side by side: one message for each record the gate
851// refuses, and for each newOnOldMarkup entry no record used.
852func refusedAdditions(t *testing.T, files []sideBySide) []string {
853 t.Helper()
854 var refused []string
855 names := vocabularyOfBaseline(t, files)
856 // oldProps is every custom property 1.0.0 declares, reads or
857 // registers: a 1.x registration or :root value for one changes
858 // what 1.0's own rules compute (an inherits: false on --rst-sp-4
859 // would reset 1.0's spacing), so only a name 1.0 never wrote is new.
860 oldProps, oldAtRules := map[string]bool{}, map[string]int{}
861 for _, f := range files {
862 for _, p := range customProperty.FindAllString(stripCSSComments(t, f.wasBytes), -1) {
863 oldProps[p] = true
864 }
865 for _, at := range f.wasAt {
866 oldAtRules[at]++
867 }
868 }
869 newAtRules := map[string]int{}
870 for _, f := range files {
871 for _, at := range f.isAt {
872 if newAtRules[at]++; newAtRules[at] <= oldAtRules[at] {
873 continue
874 }
875 if why := newAtRuleRefused(at, oldProps, oldAtRules); why != "" {
876 refused = append(refused, fmt.Sprintf("css/%s: %s %s", f.src, at, why))
877 }
878 }
879 }
880 exceptions := map[string]complexSel{}
881 for sel := range newOnOldMarkup {
882 parsed, err := parseSelector(sel)
883 if err != nil {
884 t.Fatalf("newOnOldMarkup[%q] is outside the grammar: %v", sel, err)
885 }
886 exceptions[sel] = parsed
887 }
888 used, added := map[string]bool{}, map[int]bool{}
889 for _, f := range files {
890 taken := make([]bool, len(f.is))
891 matched := make([]bool, len(f.was))
892 // The widenings first, so a 1.0 declaration the tree also still
893 // writes under its old selector does not take the widened
894 // record away from the declaration that moved into it.
895 for _, widened := range []bool{true, false} {
896 for i, was := range f.was {
897 if matched[i] {
898 continue
899 }
900 w, ok := widenedSelectors[was.Selector]
901 if widened && !ok {
902 continue
903 }
904 for j, is := range f.is {
905 if !taken[j] && carries(was, is) && (!widened || is.Selector == w.Now) {
906 taken[j], matched[i] = true, true
907 break
908 }
909 }
910 }
911 }
912 for j, d := range f.is {
913 if taken[j] {
914 continue
915 }
916 if index, ok := addedByRefinement(d); ok {
917 added[index] = true
918 continue
919 }
920 if why := newRecordRefused(d, names, oldProps, oldAtRules, exceptions, used); why != "" {
921 refused = append(refused, fmt.Sprintf("css/%s: %s is new and %s", f.src, d, why))
922 }
923 }
924 }
925 for _, sel := range sortedKeys(newOnOldMarkup) {
926 if !used[sel] {
927 refused = append(refused, fmt.Sprintf("newOnOldMarkup[%q] is used by no new declaration; an exception for nothing is a hole waiting to be used", sel))
928 }
929 }
930 for index, entry := range refinedDeclarations {
931 if entry.Was == "" && !added[index] {
932 refused = append(refused, fmt.Sprintf("refinedDeclarations: "+
933 "nothing adds %s { %s { %s: %s } } to %s; an exception "+
934 "for nothing is a hole waiting to be used", entry.Context,
935 entry.Selector, entry.Property, entry.Now, entry.File))
936 }
937 }
938 return refused
939}
940
941// TestTheNewRuleGateHasNoKnownHoles holds refusedAdditions to the ways
942// past it a review found, each a rule appended to 1.0.0's own
943// tokens.css that changes 1.0 pages where no photograph looks: a value
944// selector that matches 1.0's bare attribute, a registration that
945// changes a property 1.0 already uses, an empty redefinition of a 1.0
946// animation (which still() hides from every photograph), a new name
947// that only an ancestor 1.0 markup already carries supplies, and a
948// rule hidden from the reading between two comment markers a browser
949// does not read as a comment. The cases marked false are additions the
950// gate must let through, so the test cannot pass by refusing
951// everything.
952func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) {
953 const outsideAString = "a backslash outside a string"
954 // The spelling rules that refuse escaped, spaced and capitalised
955 // names must not refuse 1.0.0's own CSS, or the grammar would be
956 // a rule 1.0 itself breaks.
957 for _, f := range everyStylesheet(t) {
958 assertPlainSpelling(t, "check/testdata/seed/"+baseline[f.src], f.wasBytes)
959 }
960 for _, c := range []struct {
961 css, names string // the rule appended, and what a refusal names
962 refused bool
963 }{
964 {`@media (min-width: 700px) and (max-width: 750px) { [rst-page=""] { padding-inline: 3rem; } }`, `[rst-page=""]`, true},
965 {`@property --rst-sp-4 { syntax: "<length>"; inherits: false; initial-value: 48px; }`, "@property --rst-sp-4", true},
966 {`@keyframes rst-spin {}`, "@keyframes rst-spin", true},
967 {`@media (min-width: 700px) and (max-width: 750px) { [rst-job] [rst-job-bar] { inline-size: 1px; } }`, "[rst-job] [rst-job-bar]", true},
968 {`@keyframes rst-gate-control { to { opacity: 1; } }`, "@keyframes rst-gate-control", false},
969 {`@property --rst-gate-control { syntax: "<length>"; inherits: false; initial-value: 0px; }`, "@property --rst-gate-control", false},
970 {`:root { --rst-gate-control: 1px; }`, "--rst-gate-control", false},
971 {`@media (width: 731px) { [ rst-PAGE] { padding-inline: 100px; } }`, "[ rst-PAGE]", true},
972 {`@media (width: 731px) { @property --rst-\73p-4 { syntax: "<length>"; inherits: false; initial-value: 48px; } }`, outsideAString, true},
973 {`@media (width: 731px) { @keyframes rst-\73pin {} }`, outsideAString, true},
974 {`[rst-page-foot] [rst-dropdown] { margin: 0; }`, "[rst-page-foot] [rst-dropdown]", false},
975 // A ) that closes nothing ends no declaration for a browser, so
976 // the ; after it does, and color: red reaches every 1.0 page. A
977 // reader whose bracket count went below zero read the rest of
978 // the block as one new custom property.
979 {`:root { --rst-review-probe: ); color: red; }`, "a ) closes", true},
980 // A comment marker the reader takes for one and a browser does
981 // not, either side of a rule: inside a string of either quote,
982 // after an escaped quote inside the string, or after a
983 // backslash. The rule between still applies at those widths,
984 // where no photograph looks.
985 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet]::before { content: "/*"; } [rst-page] { padding-inline: 3rem; } [rst-snippet]::after { content: "*/"; } }`, "[rst-page] { padding-inline: 3rem", true},
986 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet]::before { content: '/*'; } [rst-page] { padding-inline: 3rem; } [rst-snippet]::after { content: '*/'; } }`, "[rst-page] { padding-inline: 3rem", true},
987 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet]::before { content: "\"/*"; } [rst-page] { padding-inline: 3rem; } [rst-snippet]::after { content: "*/\""; } }`, "[rst-page] { padding-inline: 3rem", true},
988 // A backslash outside a string is refused by the reader, before
989 // the gate: an escaped quote opens no string and an escaped
990 // url( is still url(, so either can hide a rule.
991 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \/*; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: */; } }`, outsideAString, true},
992 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \"; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: "\""; } }`, outsideAString, true},
993 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: \url(*/ ); } }`, outsideAString, true},
994 {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: u\72 l(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: u\72 l(*/ ); } }`, outsideAString, true},
995 // A snippet whose content is a comment marker adds nothing to a
996 // 1.0 page, so the reading that refuses the rules above must
997 // still let it through.
998 {`[rst-snippet]::before { content: "/* */"; }`, "[rst-snippet]::before", false},
999 // A refinement is its value and nothing near it: the console
1000 // rail's 5px of padding passes, 6px under the same selector does
1001 // not, and neither does 5px under the same selector outside the
1002 // media query that puts the rail beside the page.
1003 {`@media (min-width: 800px) { [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px; } }`, "[rst-shell-nav] { padding-block: 5px } } is new", false},
1004 {`@media (min-width: 800px) { [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 6px; } }`, "[rst-shell-nav] { padding-block: 6px } } is new", true},
1005 {`[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px; }`, "css/tokens.css: [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px } is new", true},
1006 } {
1007 // A stylesheet the reader refuses fails the gate before it
1008 // compares anything, so the refusal is the reader's error.
1009 var refusals []string
1010 files := everyStylesheet(t)
1011 for i := range files {
1012 if files[i].src == "tokens.css" {
1013 var err error
1014 files[i].is, files[i].isAt, err = readStylesheet("css/tokens.css", files[i].wasBytes+"\n"+c.css+"\n")
1015 if err != nil {
1016 refusals = []string{err.Error()}
1017 }
1018 }
1019 }
1020 if refusals == nil {
1021 refusals = refusedAdditions(t, files)
1022 }
1023 named := false
1024 for _, why := range refusals {
1025 if strings.Contains(why, c.names) {
1026 named = true
1027 t.Logf("refused: %s", why)
1028 }
1029 }
1030 switch {
1031 case c.refused && !named:
1032 t.Errorf("appending %s to 1.0.0's tokens.css passes the gate; it changes 1.0 pages", c.css)
1033 case !c.refused && named:
1034 t.Errorf("appending %s to 1.0.0's tokens.css is refused; it styles only markup 1.0 never wrote", c.css)
1035 }
1036 }
1037}
1038
1039// newAtRuleRefused says why one descriptor or keyframes at-rule 1.0.0
1040// does not define is not allowed, or "" when it is. Only a new
1041// @keyframes rst-… or a registration of a --rst-… property 1.0 never
1042// wrote can add nothing to a 1.0 page; a second definition of one 1.0
1043// has replaces it, empty or not.
1044func newAtRuleRefused(at string, oldProps map[string]bool, oldAtRules map[string]int) string {
1045 switch {
1046 case strings.ContainsRune(at, '\\'):
1047 return "holds a CSS escape, outside the grammar: @keyframes rst-\\73pin is rst-spin to a browser and a new name to a plain reading"
1048 case oldAtRules[at] > 0:
1049 return "is a second definition of one 1.0.0 has; the later one wins on every 1.0 page"
1050 case strings.HasPrefix(at, "@keyframes rst-"):
1051 return ""
1052 case strings.HasPrefix(at, "@property --rst-"):
1053 if p := strings.TrimPrefix(at, "@property "); oldProps[p] {
1054 return "is a registration of " + p + ", which 1.0.0 already uses; registering it changes how 1.0's rules compute it"
1055 }
1056 return ""
1057 }
1058 return "is not a new @keyframes rst-… or @property --rst-…, the only at-rules that add nothing to a 1.0 page"
1059}
1060
1061// newRecordRefused says why one record 1.0.0 did not make is not
1062// allowed, or "" when it is.
1063func newRecordRefused(d declaration, names, oldProps map[string]bool, oldAtRules map[string]int, exceptions map[string]complexSel, used map[string]bool) string {
1064 // An escape in any name a record carries spells a name the plain
1065 // reading below would not recognise (--rst-\73p-4 is --rst-sp-4),
1066 // so none is read at all: the CSS writes names out.
1067 if strings.ContainsRune(d.Context+d.Property, '\\') {
1068 return "holds a CSS escape in its at-rules or property, outside the grammar"
1069 }
1070 contexts := strings.Split(d.Context, " > ")
1071 last := contexts[len(contexts)-1]
1072 if d.Selector == "" || strings.HasPrefix(last, "@keyframes ") {
1073 if oldAtRules[last] > 0 {
1074 return fmt.Sprintf("belongs to %s, which 1.0.0 already has; a 1.0 page using it would animate or register differently", last)
1075 }
1076 if why := newAtRuleRefused(last, oldProps, oldAtRules); why != "" {
1077 return "belongs to " + last + ", and that at-rule " + why
1078 }
1079 return ""
1080 }
1081 if d.Selector == ":root" && strings.HasPrefix(d.Property, "--rst-") && !oldProps[d.Property] {
1082 return ""
1083 }
1084 parsed, err := parseSelector(d.Selector)
1085 if err != nil {
1086 return err.Error()
1087 }
1088 for _, sel := range sortedKeys(exceptions) {
1089 if reflect.DeepEqual(parsed, exceptions[sel]) {
1090 used[sel] = true
1091 return ""
1092 }
1093 }
1094 if ok, why := requiresNewMarkup(d.Selector, names); !ok {
1095 return why + "; no newOnOldMarkup entry is this selector"
1096 }
1097 return ""
1098}
1099
1100func TestDeclarationsAreReadInTheirContext(t *testing.T) {
1101 const css = `a, [b] { color: red; margin: 0 !important } @media (x) { @supports (y) { c { d: e } } } @container (max-width: 34rem) { f { g: h } }`
1102 got, err := declarationsOf("t.css", css)
1103 if err != nil {
1104 t.Fatal(err)
1105 }
1106 want := []declaration{
1107 {File: "t.css", Selector: "a", Property: "color", Value: "red"},
1108 {File: "t.css", Selector: "a", Property: "margin", Value: "0", Important: true},
1109 {File: "t.css", Selector: "[b]", Property: "color", Value: "red"},
1110 {File: "t.css", Selector: "[b]", Property: "margin", Value: "0", Important: true},
1111 {File: "t.css", Context: "@media (x) > @supports (y)", Selector: "c", Property: "d", Value: "e"},
1112 {File: "t.css", Context: "@container (max-width: 34rem)", Selector: "f", Property: "g", Value: "h"},
1113 }
1114 sameDeclarations(t, got, want)
1115}
1116
1117// The reader takes comments out and refuses what it cannot read the
1118// way a browser does: a string a newline or the end of the file cuts
1119// off, a comment never closed or wedged between two tokens, an
1120// unquoted url(), whose contents a browser reads as neither a string
1121// nor a comment, so url(/*) opens none, and a backslash outside a
1122// string, since \url( and u\72 l( are url( to a browser and \" opens
1123// no string. A backslash inside a string is read as a browser reads
1124// it.
1125func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) {
1126 brackets := []struct{ css, refusal string }{
1127 // Brackets must nest. One that closes nothing, closes another
1128 // kind or is never closed leaves a browser and a reader
1129 // disagreeing about where a declaration or a rule ends.
1130 {`a { --b: ); color: red }`, "a ) closes"},
1131 {`a { --b: ]; color: red }`, "a ] closes"},
1132 {`) a { b: c }`, "a ) closes nothing"},
1133 {`a { b: ( ] ) }`, "a ] closes the ("},
1134 {`a { --b: (; color: red }`, "a } closes the ("},
1135 {`a { --b: [; color: red }`, "a } closes the ["},
1136 {`a { b: c`, "a { is never closed"},
1137 {`a { b: f(`, "a ( is never closed"},
1138 {`a[b`, "a [ is never closed"},
1139 // A { inside brackets opens a block a browser reads inside the
1140 // value or the prelude, where these readers would take it for
1141 // a rule.
1142 {`a { b: ({}) }`, "a { opens inside the ("},
1143 {`@media (x { a { b: c } }) { d { e: f } }`, "a { opens inside the ("},
1144 {`a[b { c: d }`, "a { opens inside the ["},
1145 }
1146 for _, c := range append([]struct{ css, refusal string }{
1147 {`[rst-snippet] { --rst-gate-a: url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: url(*/); }`, "url("},
1148 {`a { b: URL(c) }`, "url("},
1149 // An escaped url( hides the rule between as an unescaped one
1150 // would, however the escape is spelt.
1151 {`[rst-snippet] { --rst-a: \url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: \url(*/ ); }`, "backslash"},
1152 {`[rst-snippet] { --rst-a: u\72 l(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: u\72 l(*/ ); }`, "backslash"},
1153 {`a { b: u\72l(c) }`, "backslash"},
1154 {`i\"j { k: l }`, "backslash"},
1155 {`a { m: \"; }`, "backslash"},
1156 {`a { b: \/* }`, "backslash"},
1157 // Two strings cut off by their lines pair up into one if the
1158 // newline is not read as cutting the first one off.
1159 {"a { content: \"b\n; } c { content: \"d\n; }", "string"},
1160 {`a { content: 'b`, "string"},
1161 {`a { content: "b\`, "string"},
1162 {`a { b: c } /* d`, "comment"},
1163 {`a { padding: 1/**/2px }`, "between two tokens"},
1164 }, brackets...) {
1165 if _, err := declarationsOf("t.css", c.css); err == nil || !strings.Contains(err.Error(), c.refusal) {
1166 t.Errorf("declarationsOf(%q): %v, want an error naming the %s", c.css, err, c.refusal)
1167 }
1168 }
1169 // The comment reader the other readers start from refuses them
1170 // too, and the declaration reader does on a block read alone.
1171 for _, c := range brackets {
1172 if _, err := cssWithoutComments(c.css); err == nil || !strings.Contains(err.Error(), c.refusal) {
1173 t.Errorf("cssWithoutComments(%q): %v, want an error naming the %s", c.css, err, c.refusal)
1174 }
1175 }
1176 for _, block := range []string{" --rst-review-probe: ); color: red; ", " --b: ]; c: d ", " --b: (; c: d "} {
1177 if decls, err := cssDeclarations(block); err == nil {
1178 t.Errorf("cssDeclarations(%q) = %q, want an error naming the bracket", block, decls)
1179 }
1180 }
1181 const css = "a { content: \"/*\"; b: url(\"*/\"); c: url( '/*' ) } d { content: \"e\\\nf\"; g: x-url( /**/ h) } i { k: \"\\\"/*\\\"\"; m: '\\'}'; n: o }"
1182 got, err := declarationsOf("t.css", css)
1183 if err != nil {
1184 t.Fatal(err)
1185 }
1186 want := []declaration{
1187 {File: "t.css", Selector: "a", Property: "content", Value: `"/*"`},
1188 {File: "t.css", Selector: "a", Property: "b", Value: `url("*/")`},
1189 {File: "t.css", Selector: "a", Property: "c", Value: `url( '/*' )`},
1190 {File: "t.css", Selector: "d", Property: "content", Value: "\"e\\ f\""},
1191 {File: "t.css", Selector: "d", Property: "g", Value: "x-url( h)"},
1192 {File: "t.css", Selector: "i", Property: "k", Value: `"\"/*\""`},
1193 {File: "t.css", Selector: "i", Property: "m", Value: `'\'}'`},
1194 {File: "t.css", Selector: "i", Property: "n", Value: "o"},
1195 }
1196 sameDeclarations(t, got, want)
1197
1198 // A bracket inside a string is text, in a selector or a value.
1199 got, err = declarationsOf("t.css", `a[title="]"] { content: ")"; b: f("(") [g] }`)
1200 if err != nil {
1201 t.Fatal(err)
1202 }
1203 sameDeclarations(t, got, []declaration{
1204 {File: "t.css", Selector: `a[title="]"]`, Property: "content", Value: `")"`},
1205 {File: "t.css", Selector: `a[title="]"]`, Property: "b", Value: `f("(") [g]`},
1206 })
1207}
1208
1209func TestAtRulesAreReadAsRecords(t *testing.T) {
1210 const css = `@property --p { syntax: "<number>"; inherits: false; initial-value: 0; } @media (m) { @view-transition { navigation: auto; } } @keyframes k { from { opacity: 0 } to { opacity: 1 } }`
1211 got, err := declarationsOf("t.css", css)
1212 if err != nil {
1213 t.Fatal(err)
1214 }
1215 want := []declaration{
1216 {File: "t.css", Context: "@property --p", Property: "syntax", Value: `"<number>"`},
1217 {File: "t.css", Context: "@property --p", Property: "inherits", Value: "false"},
1218 {File: "t.css", Context: "@property --p", Property: "initial-value", Value: "0"},
1219 {File: "t.css", Context: "@media (m) > @view-transition", Property: "navigation", Value: "auto"},
1220 {File: "t.css", Context: "@keyframes k", Selector: "from", Property: "opacity", Value: "0"},
1221 {File: "t.css", Context: "@keyframes k", Selector: "to", Property: "opacity", Value: "1"},
1222 }
1223 sameDeclarations(t, got, want)
1224
1225 // An at-rule the reader does not know is refused by name: @layer
1226 // would reorder the cascade, and reading its rules as if it were
1227 // not there would compare declarations that no longer win.
1228 _, err = declarationsOf("t.css", `@layer x { a { b: c } }`)
1229 if err == nil || !strings.Contains(err.Error(), "@layer") {
1230 t.Errorf("declarationsOf of a @layer block: %v, want an error naming @layer", err)
1231 }
1232}
1233
1234func sameDeclarations(t *testing.T, got, want []declaration) {
1235 t.Helper()
1236 if !reflect.DeepEqual(got, want) {
1237 t.Errorf("declarationsOf read %d records:", len(got))
1238 for _, d := range got {
1239 t.Errorf(" %+v", d)
1240 }
1241 t.Errorf("want %d:", len(want))
1242 for _, d := range want {
1243 t.Errorf(" %+v", d)
1244 }
1245 }
1246}
1247