package check import ( "fmt" "io/fs" "os" "path/filepath" "reflect" "regexp" "sort" "strings" "testing" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta/internal/markup" ) // testdata/seed/ is 1.0.0's CSS and examples, copied from the tag // (TestTheBaselineIsOneOhOh in the root package holds it there). The // CSS under css/ moves in 1.x, so it is no longer held to those bytes: // a byte freeze cannot survive the first release that changes the // CSS, and the names it styles surviving is not its styles surviving // (deleting the base [rst-ftok] rule loses every declaration a chip // has and no name at all). What is held instead is what 1.x promises: // every declaration 1.0.0 makes still applies, in the same context and // order (TestEveryOneOhDeclarationStillApplies); everything 1.x adds // needs markup 1.0 never wrote (TestEveryNewRuleRequiresNewMarkup); // and 1.0's own markup renders as it did (the photographs in // parity_browser_test.go and oneoh_browser_test.go). // // The five are named here rather than walked, so an empty css/ or an // empty baseline fails by name instead of passing as a zero-length // match. // baseline maps each stylesheet under css/ to its twin under // testdata/seed/. The themes lose their directory, since the baseline // is flat. var baseline = map[string]string{ "tokens.css": "tokens.css", "shell.css": "shell.css", "themes/day.css": "day.css", "themes/plain.css": "plain.css", "themes/signal.css": "signal.css", } // widening is a 1.0 selector 1.x widens in place, the way the focus // scope's :where() gains a new component: Now is the selector as 1.x // writes it, Added the arguments it gained, Why the reason. The 1.0 // declarations under the old selector are found under Now, and taking // Added out of Now must give the old selector back exactly, so an // entry cannot quietly drop an argument while it adds one. type widening struct { Now string Added []string Why string } // The footer row's menus are flat, as a shell tail's are, so each of the // four summary-face rules, in each spelling, excludes them in its :not(). // The new argument weighs what the shell tail's does, so no summary // the rule already styles changes weight. const footFlat = "the footer row's menus are flat, as a shell tail's are" // A seg-tabs button is a control as a seg-tabs link is, and a side // nav link is a menu row, so the rule that sizes an icon inside a // control names both. :where() weighs nothing, so no icon 1.0 already // sizes changes. const newControlIcons = "a seg-tabs button and a side nav link are controls, so an icon in one is sized as in a control" // A side nav may sit outside an rst-page, as the landing page's sample // does, so the focus scope names it, or its links would show the // browser's thin ring there. :where() weighs nothing, so no focus ring // 1.0 already draws changes. const sideFocus = "a side nav outside an rst-page still shows the focus ring" // widenedSelectors is keyed by the 1.0 selector. Each entry must be // used: an entry for a selector the CSS no longer widens is a promise // about nothing. var widenedSelectors = map[string]widening{ ":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": { 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", Added: []string{".rst-seg-tabs > button", ".rst-side__nav a"}, Why: newControlIcons, }, ":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": { 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", Added: []string{"[rst-seg-tabs] > button", "[rst-side-nav] a"}, Why: newControlIcons, }, ":where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination) :focus-visible": { Now: ":where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination, .rst-side) :focus-visible", Added: []string{".rst-side"}, Why: sideFocus, }, ":where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination]) :focus-visible": { Now: ":where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination], [rst-side]) :focus-visible", Added: []string{"[rst-side]"}, Why: sideFocus, }, ".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)": { 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)", Added: []string{".rst-page__foot .rst-dropdown"}, Why: footFlat, }, "[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])": { 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])", Added: []string{"[rst-page-foot] [rst-dropdown]"}, Why: footFlat, }, ".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": { 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", Added: []string{".rst-page__foot .rst-dropdown"}, Why: footFlat, }, "[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": { 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", Added: []string{"[rst-page-foot] [rst-dropdown]"}, Why: footFlat, }, ".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": { 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", Added: []string{".rst-page__foot .rst-dropdown"}, Why: footFlat, }, "[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": { 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", Added: []string{"[rst-page-foot] [rst-dropdown]"}, Why: footFlat, }, ".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": { 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", Added: []string{".rst-page__foot .rst-dropdown"}, Why: footFlat, }, "[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": { 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", Added: []string{"[rst-page-foot] [rst-dropdown]"}, Why: footFlat, }, } // newOnOldMarkup is the few new rules that do style markup 1.0 could // already carry, each an exact selector with why: a button in a // seg-tabs track, which 1.0 documents only as links and leaves // unstyled. Compared after parsing, compound by compound, never as a // substring, so a selector that merely contains an entry is not it. var newOnOldMarkup = map[string]string{ "[rst-seg-tabs] > button": segTabButton, "[rst-seg-tabs] > button:hover": segTabButton, `[rst-seg-tabs] > button[aria-pressed="true"]`: segTabButton, ".rst-seg-tabs > button": segTabButton, ".rst-seg-tabs > button:hover": segTabButton, `.rst-seg-tabs > button[aria-pressed="true"]`: segTabButton, "[rst-seg-tabs] > button:disabled": segTabButton, ".rst-seg-tabs > button:disabled": segTabButton, } // segTabButton is each entry's why. Every entry uses the child // combinator, so it reaches only a track's own buttons: a button nested // deeper, inside some other control a 1.0 page put in a track, is not // a tab and keeps the look 1.0 gave it. const segTabButton = "1.0 documents only links in a seg-tabs track and leaves a button in one unstyled" // refinement is one declaration under a 1.0 selector that a 1.x release // changes or adds on purpose, to fix how 1.0 markup draws: its file, // context, selector and property, the value 1.0.0 gave it (Was, empty // for an addition), the value the tree gives it (Now), and why. The /1/ // promise allows a fix the changelog announces; this list is where each // is named, value for value, so nothing else passes for one. Every entry // must be used: a 1.0 declaration with Was changed to exactly Now, or a // new record that is exactly Now. type refinement struct { File, Context, Selector, Property, Was, Now string Why string } // The console rail's nav in its two spellings, under the media query // that puts the rail beside the page. const ( wideRail = "@media (min-width: 800px)" railNavClasses = ".rst-shell-console > .rst-shell__rail > .rst-shell__nav" railNavAttributes = "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav]" ) // railRingReason is why each of the console rail's refinements is there. const railRingReason = "1.2.0 takes Rastrillo cc7c9c69: a nav that " + "scrolls clips at its padding box, so 1.0's console rail cut its " + "current item's ring and contact shadow off at its edges. 5px of " + "padding gives them room, and the negative margins, the smaller " + "sticky inset and the larger cap give it back, so no item moves " + "(consoleRailRing in rendering_exception_browser_test.go " + "photographs it)" // railRingChange is one declaration the ring fix changes or adds, // written once and refined in both spellings. type railRingChange struct{ Property, Was, Now string } var railRingChanges = []railRingChange{ { Property: "inset-block-start", Was: "var(--rst-sp-4)", Now: "calc(var(--rst-sp-4) - 5px)", }, {Property: "margin-block", Now: "-5px"}, {Property: "margin-inline", Now: "-5px"}, { Property: "max-block-size", Was: "calc(100dvh - 2 * var(--rst-sp-4))", Now: "calc(100dvh - 2 * var(--rst-sp-4) + 10px)", }, {Property: "padding-block", Now: "5px"}, {Property: "padding-inline", Now: "5px"}, } // refinedDeclarations is every refinement 1.x makes, in file order. var refinedDeclarations = railRingRefinements() func railRingRefinements() []refinement { var refinements []refinement for _, selector := range []string{railNavClasses, railNavAttributes} { for _, change := range railRingChanges { refinements = append(refinements, refinement{ File: "css/tokens.css", Context: wideRail, Selector: selector, Property: change.Property, Was: change.Was, Now: change.Now, Why: railRingReason, }) } } return refinements } // refines reports whether entry names record's file, context, selector // and property, with record not !important. func refines(entry refinement, record declaration) bool { return record.Context == entry.Context && record.Selector == entry.Selector && record.Property == entry.Property && !record.Important } // refined reports which refinement turns was into is, if one does: // was is 1.0.0's declaration with the entry's Was, and is the tree's // with its Now, both where the entry says. func refined(was, is declaration) (int, bool) { for index, entry := range refinedDeclarations { seed := "check/testdata/seed/" + baseline[strings.TrimPrefix(entry.File, "css/")] if entry.Was != "" && was.File == seed && is.File == entry.File && refines(entry, was) && refines(entry, is) && was.Value == entry.Was && is.Value == entry.Now { return index, true } } return 0, false } // addedByRefinement reports which refinement adds record, if one does. func addedByRefinement(record declaration) (int, bool) { for index, entry := range refinedDeclarations { if entry.Was == "" && record.File == entry.File && refines(entry, record) && record.Value == entry.Now { return index, true } } return 0, false } // declaration is one thing a stylesheet says, read in the context that // decides when it applies: the chain of grouping rules around it, one // selector of its rule's list, the property, the value and whether it // is !important. A descriptor at-rule (@property, @view-transition, // @font-face) gives one per descriptor with no selector, the at-rule // and its name ending the context; a @keyframes gives one per keyframe // selector (from, to, 50%) per declaration, the context ending in the // animation's name. type declaration struct { File, Context, Selector, Property, Value string Important bool } // String is the declaration as a failure names it. func (d declaration) String() string { var b strings.Builder if d.Context != "" { b.WriteString(d.Context + " { ") } if d.Selector != "" { b.WriteString(d.Selector + " { ") } b.WriteString(d.Property + ": " + d.Value) if d.Important { b.WriteString(" !important") } if d.Selector != "" { b.WriteString(" }") } if d.Context != "" { b.WriteString(" }") } return b.String() } // The at-rules declarationsOf reads. A grouping rule only adds to the // context of what it holds; a descriptor rule holds declarations with // no selector. Anything else is refused by name: @layer would reorder // the cascade, @import would bring rules from elsewhere, and reading // either as if it were not there would compare declarations that no // longer apply the way the comparison assumes. var ( groupingRules = map[string]bool{"media": true, "supports": true, "container": true} descriptorRules = map[string]bool{"property": true, "view-transition": true, "font-face": true} ) // declarationsOf reads css, the contents of file, into its records in // source order: for a style rule one per selector of its list per // declaration, selector by selector. Comments are read out first. It // is a reader for these hand-written files, not a CSS parser: a nested // style rule, a statement at-rule or an at-rule it does not know is an // error naming it, so a file that grows one fails here rather than // being half read. func declarationsOf(file, css string) ([]declaration, error) { decls, _, err := readStylesheet(file, css) return decls, err } // readStylesheet is declarationsOf with the identity of every // descriptor and keyframes at-rule as well, in source order // ("@keyframes rst-spin", "@property --rst-press"), empty ones // included: an empty @keyframes rst-spin {} says nothing a declaration // could record, and still replaces 1.0's spinner on every page. func readStylesheet(file, css string) ([]declaration, []string, error) { r := cssReader{file: file} bare, err := cssWithoutComments(css) if err == nil { err = r.rules(bare, nil) } if err != nil { return nil, nil, fmt.Errorf("%s: %w", file, err) } return r.out, r.atRules, nil } type cssReader struct { file string out []declaration atRules []string leaves []leafRule top int // the top-level statement being read } // cssItem is one statement of a rule list: its prelude, and its block // when it has one. type cssItem struct { prelude, block string hasBlock bool } // rules reads one rule list, the file's top level or a grouping rule's // block, with the contexts around it. func (r *cssReader) rules(src string, context []string) error { items, err := cssItems(src) if err != nil { return err } for k, it := range items { if len(context) == 0 { r.top = k } prelude := collapseSpace(it.prelude) if !it.hasBlock { return fmt.Errorf("%q is a statement outside any rule, which this reader does not read", prelude) } if !strings.HasPrefix(prelude, "@") { decls, err := cssDeclarations(it.block) if err != nil { return fmt.Errorf("%s: %w", prelude, err) } r.add(context, splitSelectorList(prelude), decls) r.leaves = append(r.leaves, leafRule{context: strings.Join(context, " > "), top: r.top, selector: prelude, decls: decls}) continue } name := atRuleName(prelude) inner := append(append([]string(nil), context...), prelude) switch { case groupingRules[name]: if err := r.rules(it.block, inner); err != nil { return err } case descriptorRules[name]: r.atRules = append(r.atRules, prelude) decls, err := cssDeclarations(it.block) if err != nil { return fmt.Errorf("%s: %w", prelude, err) } r.add(inner, []string{""}, decls) case name == "keyframes": r.atRules = append(r.atRules, prelude) frames, err := cssItems(it.block) if err != nil { return err } for _, f := range frames { decls, err := cssDeclarations(f.block) if err != nil { return fmt.Errorf("%s: %w", prelude, err) } r.add(inner, splitSelectorList(f.prelude), decls) r.leaves = append(r.leaves, leafRule{context: strings.Join(inner, " > "), top: r.top, selector: collapseSpace(f.prelude), decls: decls}) } default: return fmt.Errorf("the at-rule @%s is not one this reader knows; teach declarationsOf what it means before a stylesheet uses it", name) } } return nil } // add records each declaration under each selector, selector by // selector, as one record each. func (r *cssReader) add(context, selectors []string, decls [][3]string) { ctx := strings.Join(context, " > ") for _, sel := range selectors { for _, d := range decls { r.out = append(r.out, declaration{File: r.file, Context: ctx, Selector: sel, Property: d[0], Value: d[1], Important: d[2] != ""}) } } } // atRuleName is the lower-case name of the at-rule a prelude opens. func atRuleName(prelude string) string { name := strings.TrimPrefix(prelude, "@") if i := strings.IndexAny(name, " (\t\n"); i >= 0 { name = name[:i] } return strings.ToLower(name) } // cssItems splits a rule list into its statements, each up to its ; // or through its {} block. It reads with cssSpan, so a bracket inside // content: "{" is text, and steps over a () or [] group whole with // braceMatchEnd, so a ; or { inside url() or an attribute selector is // not taken for structure. A bracket that closes nothing is an error, // not a count below zero. func cssItems(src string) ([]cssItem, error) { var items []cssItem start := 0 for i := 0; i < len(src); { end, _, err := cssSpan(src, i) if err != nil { return nil, err } switch c := src[i]; c { case '(', '[': close, err := braceMatchEnd(src, i+1) if err != nil { return nil, err } end = close + 1 case '{': close, err := braceMatchEnd(src, i+1) if err != nil { return nil, fmt.Errorf("the block opened after %q: %w", collapseSpace(src[start:i]), err) } items = append(items, cssItem{prelude: src[start:i], block: src[i+1 : close], hasBlock: true}) end, start = close+1, close+1 case ')', ']', '}': return nil, fmt.Errorf("a %c closes nothing after %q", c, collapseSpace(src[start:i])) case ';': if p := strings.TrimSpace(src[start:i]); p != "" { items = append(items, cssItem{prelude: p}) } start = i + 1 } i = end } if rest := strings.TrimSpace(src[start:]); rest != "" { items = append(items, cssItem{prelude: rest}) } return items, nil } // cssDeclarations reads a block's declarations as property, value and // a non-empty third field when !important, the value's whitespace // collapsed. It reads brackets as cssItems does: a ; inside a string // or a () or [] group ends no declaration, and a ) that closes nothing // is an error, where a count below zero read every declaration after // it as part of the one before. A { in the block is a nested rule, // which these files do not use and this reader does not read. // // A property other than a custom property is read in lower case, as a // browser reads it: TRANSITION is transition, and a gate looking for // one must not miss the other. func cssDeclarations(block string) ([][3]string, error) { var out [][3]string start := 0 flush := func(end int) error { piece := strings.TrimSpace(block[start:end]) if piece == "" { return nil } prop, value, ok := strings.Cut(piece, ":") if !ok { return fmt.Errorf("%q is not a declaration", collapseSpace(piece)) } if prop = strings.TrimSpace(prop); !strings.HasPrefix(prop, "--") { prop = strings.ToLower(prop) } value = collapseSpace(value) important := "" if v, ok := cutImportant(value); ok { value, important = v, "!important" } out = append(out, [3]string{prop, value, important}) return nil } for i := 0; i < len(block); { end, _, err := cssSpan(block, i) if err != nil { return nil, err } switch c := block[i]; c { case '(', '[': close, err := braceMatchEnd(block, i+1) if err != nil { return nil, err } end = close + 1 case '{': return nil, fmt.Errorf("a nested rule after %q, which this reader does not read", collapseSpace(block[start:i])) case ')', ']', '}': return nil, fmt.Errorf("a %c closes nothing after %q", c, collapseSpace(block[start:i])) case ';': if err := flush(i); err != nil { return nil, err } start = i + 1 } i = end } if err := flush(len(block)); err != nil { return nil, err } return out, nil } // cutImportant takes a trailing !important (any case, any space after // the !) off a value. func cutImportant(value string) (string, bool) { i := strings.LastIndex(value, "!") if i < 0 || !strings.EqualFold(strings.TrimSpace(value[i+1:]), "important") { return value, false } return strings.TrimSpace(value[:i]), true } // ── The two declaration gates ──────────────────────────────────────── // readDeclarations reads one stylesheet from disk, failing the test if // it is missing, empty or unreadable as declarations. func readDeclarations(t *testing.T, path, name string) ([]declaration, []string, string) { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("%s: %v", name, err) } if len(b) == 0 { t.Fatalf("%s is empty; an empty stylesheet holds nothing", name) } decls, atRules := parseDeclarations(t, name, string(b)) return decls, atRules, string(b) } // parseDeclarations is declarationsOf failing the test on an error or // on a stylesheet that reads as nothing. func parseDeclarations(t *testing.T, name, css string) ([]declaration, []string) { t.Helper() decls, atRules, err := readStylesheet(name, css) if err != nil { t.Fatal(err) } if len(decls) == 0 { t.Fatalf("%s reads as no declarations at all", name) } return decls, atRules } // sideBySide is one stylesheet as 1.0.0 shipped it and as the tree has // it, for each of the five, in a fixed order. type sideBySide struct { src string // the path under css/ was, is []declaration wasAt, isAt []string // readStylesheet's at-rule identities wasBytes string } func everyStylesheet(t *testing.T) []sideBySide { t.Helper() var out []sideBySide for _, src := range sortedKeys(baseline) { seed := baseline[src] was, wasAt, wasBytes := readDeclarations(t, filepath.Join("testdata", "seed", seed), "check/testdata/seed/"+seed) is, isAt, _ := readDeclarations(t, filepath.Join("..", "css", filepath.FromSlash(src)), "css/"+src) out = append(out, sideBySide{src: src, was: was, is: is, wasAt: wasAt, isAt: isAt, wasBytes: wasBytes}) } return out } func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // carries reports whether is says what was said: the same context, // property, value and weight, under the same selector or under the // selector a planned widening turned it into. func carries(was, is declaration) bool { if _, ok := refined(was, is); ok { return true } if was.Context != is.Context || was.Property != is.Property || was.Value != is.Value || was.Important != is.Important { return false } if was.Selector == is.Selector { return true } w, ok := widenedSelectors[was.Selector] return ok && w.Now == is.Selector } // TestEveryOneOhDeclarationStillApplies: in each of the five // stylesheets, every declaration 1.0.0 makes is still made, with the // same context, property, value and weight, under the same selector or // under its planned widening, and 1.0.0's declarations come in the same // relative order. A declaration lost is a 1.0 page drawn without it; a // declaration moved can be overtaken by one it used to follow, which // is a 1.0 page drawn differently with every name still there. A // transition, a print rule, a forced-colours rule, a @property // descriptor and a keyframe are declarations like any other. Every // widenedSelectors entry is used and says exactly what it adds; and // every file under css/ has a baseline twin, or a stylesheet added // beside the five would ship held to nothing. func TestEveryOneOhDeclarationStillApplies(t *testing.T) { used, refinedUsed := map[string]bool{}, map[int]bool{} for _, f := range everyStylesheet(t) { t.Logf("css/%s: 1.0.0 makes %d declarations, the tree %d", f.src, len(f.was), len(f.is)) next := 0 taken := make([]bool, len(f.is)) for _, was := range f.was { found := -1 for j := next; j < len(f.is); j++ { if !taken[j] && carries(was, f.is[j]) { found = j break } } if found >= 0 { taken[found], next = true, found+1 if was.Selector != f.is[found].Selector { used[was.Selector] = true } if index, ok := refined(was, f.is[found]); ok { refinedUsed[index] = true } continue } moved := false for j := 0; j < next; j++ { if !taken[j] && carries(was, f.is[j]) { taken[j], moved = true, true break } } if moved { 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) } else { t.Errorf("css/%s: 1.0.0's %s is gone; a 1.0 page would render without it", f.src, was) } } } for _, old := range sortedKeys(widenedSelectors) { w := widenedSelectors[old] if !used[old] { t.Errorf("widenedSelectors[%q] is used by no declaration: no 1.0 declaration under it is found under %q", old, w.Now) } back := w.Now for _, arg := range w.Added { if !strings.Contains(back, ", "+arg) { t.Errorf("widenedSelectors[%q]: %q does not hold the added argument %q", old, w.Now, arg) continue } back = strings.Replace(back, ", "+arg, "", 1) } if back != old { 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) } } for index, entry := range refinedDeclarations { if entry.Was != "" && !refinedUsed[index] { t.Errorf("refinedDeclarations: no 1.0 declaration "+ "%s { %s { %s: %s } } became %s in %s; a refinement of "+ "nothing is a hole waiting to be used", entry.Context, entry.Selector, entry.Property, entry.Was, entry.Now, entry.File) } } // Every file under css/ is one of the five. root := filepath.Join("..", "css") err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel, err := filepath.Rel(root, path) if err != nil { return err } if _, ok := baseline[filepath.ToSlash(rel)]; !ok { 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)) } return nil }) if err != nil { t.Fatalf("walking css/: %v", err) } } // vocabularyOfBaseline is every name 1.0 markup may carry: each rst- // attribute, attribute and value token, and class that 1.0.0's five // stylesheets style, that 1.0.0's frozen examples write (an attribute // can be in 1.0 markup with no rule of its own, as rst-job is around // every progress bar), and that the coverage gate names as hooks or // qualifiers. A new rule needs a name outside it to be sure no 1.0 // page carries it; a name only 1.0's CSS was missing would let // [rst-job] [rst-job-bar] restyle every 1.0 job. func vocabularyOfBaseline(t *testing.T, files []sideBySide) map[string]bool { t.Helper() var css []string for _, f := range files { css = append(css, f.wasBytes) } names := vocabularyOf(t, css...) for _, f := range files { for class := range styledClasses(t, f.wasBytes) { names[class] = true } } for name := range unstyledByDesign { names[name] = true } for name := range qualifiedOnly { names[name] = true } root := filepath.Join("testdata", "seed", "examples") pages := 0 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(path) != ".html" { return err } b, err := os.ReadFile(path) if err != nil { return err } pages++ markupNames(string(b), names) return nil }) if err != nil { t.Fatal(err) } if pages < 163 { t.Fatalf("read %d of 1.0.0's example documents under %s, want 163", pages, root) } return names } // markupNames adds every rst- name one document's tags carry: each // attribute, each token of its value, and each rst- class, read also // as the attribute it spells. func markupNames(doc string, names map[string]bool) { z := xhtml.NewTokenizer(strings.NewReader(doc)) for { switch z.Next() { case xhtml.ErrorToken: return case xhtml.StartTagToken, xhtml.SelfClosingTagToken: _, more := z.TagName() for more { var k, v []byte k, v, more = z.TagAttr() key, value := string(k), string(v) switch { case strings.HasPrefix(key, "rst-"): names[key] = true for _, token := range strings.Fields(value) { names[key+"~="+token] = true } case key == "class": for _, class := range strings.Fields(value) { if !strings.HasPrefix(class, "rst-") { continue } names[class] = true if name, variant, ok := markup.Attribute(class); ok { names[name] = true if variant != "" { names[name+"~="+variant] = true } } } } } } } } // customProperty is a custom property's name wherever it is written: // declared, read through var(), or registered. var customProperty = regexp.MustCompile(`--[A-Za-z0-9_-]+`) // TestEveryNewRuleRequiresNewMarkup: everything 1.x adds to the five // stylesheets styles only markup 1.0 never wrote, so a 1.0 page is // drawn as it was. The 1.0 declarations are matched first, under // their widenings too; every other record must be one of: // - a declaration whose selector positively requires a name 1.0.0 // never styles (requiresNewMarkup), in the narrow grammar a small // parser reads, so a new name mentioned under :not() or in one // branch of :is() does not pass for one required; // - one of newOnOldMarkup's exact selectors; // - a new --rst-* custom property under exactly :root, which no 1.0 // rule reads; // - a descriptor or keyframe of a new @property --rst-… or // @keyframes rst-…, which nothing 1.0 names. // // Every newOnOldMarkup entry is used. func TestEveryNewRuleRequiresNewMarkup(t *testing.T) { for _, why := range refusedAdditions(t, everyStylesheet(t)) { t.Error(why) } } // refusedAdditions is TestEveryNewRuleRequiresNewMarkup over any five // stylesheets side by side: one message for each record the gate // refuses, and for each newOnOldMarkup entry no record used. func refusedAdditions(t *testing.T, files []sideBySide) []string { t.Helper() var refused []string names := vocabularyOfBaseline(t, files) // oldProps is every custom property 1.0.0 declares, reads or // registers: a 1.x registration or :root value for one changes // what 1.0's own rules compute (an inherits: false on --rst-sp-4 // would reset 1.0's spacing), so only a name 1.0 never wrote is new. oldProps, oldAtRules := map[string]bool{}, map[string]int{} for _, f := range files { for _, p := range customProperty.FindAllString(stripCSSComments(t, f.wasBytes), -1) { oldProps[p] = true } for _, at := range f.wasAt { oldAtRules[at]++ } } newAtRules := map[string]int{} for _, f := range files { for _, at := range f.isAt { if newAtRules[at]++; newAtRules[at] <= oldAtRules[at] { continue } if why := newAtRuleRefused(at, oldProps, oldAtRules); why != "" { refused = append(refused, fmt.Sprintf("css/%s: %s %s", f.src, at, why)) } } } exceptions := map[string]complexSel{} for sel := range newOnOldMarkup { parsed, err := parseSelector(sel) if err != nil { t.Fatalf("newOnOldMarkup[%q] is outside the grammar: %v", sel, err) } exceptions[sel] = parsed } used, added := map[string]bool{}, map[int]bool{} for _, f := range files { taken := make([]bool, len(f.is)) matched := make([]bool, len(f.was)) // The widenings first, so a 1.0 declaration the tree also still // writes under its old selector does not take the widened // record away from the declaration that moved into it. for _, widened := range []bool{true, false} { for i, was := range f.was { if matched[i] { continue } w, ok := widenedSelectors[was.Selector] if widened && !ok { continue } for j, is := range f.is { if !taken[j] && carries(was, is) && (!widened || is.Selector == w.Now) { taken[j], matched[i] = true, true break } } } } for j, d := range f.is { if taken[j] { continue } if index, ok := addedByRefinement(d); ok { added[index] = true continue } if why := newRecordRefused(d, names, oldProps, oldAtRules, exceptions, used); why != "" { refused = append(refused, fmt.Sprintf("css/%s: %s is new and %s", f.src, d, why)) } } } for _, sel := range sortedKeys(newOnOldMarkup) { if !used[sel] { 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)) } } for index, entry := range refinedDeclarations { if entry.Was == "" && !added[index] { refused = append(refused, fmt.Sprintf("refinedDeclarations: "+ "nothing adds %s { %s { %s: %s } } to %s; an exception "+ "for nothing is a hole waiting to be used", entry.Context, entry.Selector, entry.Property, entry.Now, entry.File)) } } return refused } // TestTheNewRuleGateHasNoKnownHoles holds refusedAdditions to the ways // past it a review found, each a rule appended to 1.0.0's own // tokens.css that changes 1.0 pages where no photograph looks: a value // selector that matches 1.0's bare attribute, a registration that // changes a property 1.0 already uses, an empty redefinition of a 1.0 // animation (which still() hides from every photograph), a new name // that only an ancestor 1.0 markup already carries supplies, and a // rule hidden from the reading between two comment markers a browser // does not read as a comment. The cases marked false are additions the // gate must let through, so the test cannot pass by refusing // everything. func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) { const outsideAString = "a backslash outside a string" // The spelling rules that refuse escaped, spaced and capitalised // names must not refuse 1.0.0's own CSS, or the grammar would be // a rule 1.0 itself breaks. for _, f := range everyStylesheet(t) { assertPlainSpelling(t, "check/testdata/seed/"+baseline[f.src], f.wasBytes) } for _, c := range []struct { css, names string // the rule appended, and what a refusal names refused bool }{ {`@media (min-width: 700px) and (max-width: 750px) { [rst-page=""] { padding-inline: 3rem; } }`, `[rst-page=""]`, true}, {`@property --rst-sp-4 { syntax: ""; inherits: false; initial-value: 48px; }`, "@property --rst-sp-4", true}, {`@keyframes rst-spin {}`, "@keyframes rst-spin", true}, {`@media (min-width: 700px) and (max-width: 750px) { [rst-job] [rst-job-bar] { inline-size: 1px; } }`, "[rst-job] [rst-job-bar]", true}, {`@keyframes rst-gate-control { to { opacity: 1; } }`, "@keyframes rst-gate-control", false}, {`@property --rst-gate-control { syntax: ""; inherits: false; initial-value: 0px; }`, "@property --rst-gate-control", false}, {`:root { --rst-gate-control: 1px; }`, "--rst-gate-control", false}, {`@media (width: 731px) { [ rst-PAGE] { padding-inline: 100px; } }`, "[ rst-PAGE]", true}, {`@media (width: 731px) { @property --rst-\73p-4 { syntax: ""; inherits: false; initial-value: 48px; } }`, outsideAString, true}, {`@media (width: 731px) { @keyframes rst-\73pin {} }`, outsideAString, true}, {`[rst-page-foot] [rst-dropdown] { margin: 0; }`, "[rst-page-foot] [rst-dropdown]", false}, // A ) that closes nothing ends no declaration for a browser, so // the ; after it does, and color: red reaches every 1.0 page. A // reader whose bracket count went below zero read the rest of // the block as one new custom property. {`:root { --rst-review-probe: ); color: red; }`, "a ) closes", true}, // A comment marker the reader takes for one and a browser does // not, either side of a rule: inside a string of either quote, // after an escaped quote inside the string, or after a // backslash. The rule between still applies at those widths, // where no photograph looks. {`@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}, {`@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}, {`@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}, // A backslash outside a string is refused by the reader, before // the gate: an escaped quote opens no string and an escaped // url( is still url(, so either can hide a rule. {`@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}, {`@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}, {`@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}, {`@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}, // A snippet whose content is a comment marker adds nothing to a // 1.0 page, so the reading that refuses the rules above must // still let it through. {`[rst-snippet]::before { content: "/* */"; }`, "[rst-snippet]::before", false}, // A refinement is its value and nothing near it: the console // rail's 5px of padding passes, 6px under the same selector does // not, and neither does 5px under the same selector outside the // media query that puts the rail beside the page. {`@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}, {`@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}, {`[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}, } { // A stylesheet the reader refuses fails the gate before it // compares anything, so the refusal is the reader's error. var refusals []string files := everyStylesheet(t) for i := range files { if files[i].src == "tokens.css" { var err error files[i].is, files[i].isAt, err = readStylesheet("css/tokens.css", files[i].wasBytes+"\n"+c.css+"\n") if err != nil { refusals = []string{err.Error()} } } } if refusals == nil { refusals = refusedAdditions(t, files) } named := false for _, why := range refusals { if strings.Contains(why, c.names) { named = true t.Logf("refused: %s", why) } } switch { case c.refused && !named: t.Errorf("appending %s to 1.0.0's tokens.css passes the gate; it changes 1.0 pages", c.css) case !c.refused && named: t.Errorf("appending %s to 1.0.0's tokens.css is refused; it styles only markup 1.0 never wrote", c.css) } } } // newAtRuleRefused says why one descriptor or keyframes at-rule 1.0.0 // does not define is not allowed, or "" when it is. Only a new // @keyframes rst-… or a registration of a --rst-… property 1.0 never // wrote can add nothing to a 1.0 page; a second definition of one 1.0 // has replaces it, empty or not. func newAtRuleRefused(at string, oldProps map[string]bool, oldAtRules map[string]int) string { switch { case strings.ContainsRune(at, '\\'): 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" case oldAtRules[at] > 0: return "is a second definition of one 1.0.0 has; the later one wins on every 1.0 page" case strings.HasPrefix(at, "@keyframes rst-"): return "" case strings.HasPrefix(at, "@property --rst-"): if p := strings.TrimPrefix(at, "@property "); oldProps[p] { return "is a registration of " + p + ", which 1.0.0 already uses; registering it changes how 1.0's rules compute it" } return "" } return "is not a new @keyframes rst-… or @property --rst-…, the only at-rules that add nothing to a 1.0 page" } // newRecordRefused says why one record 1.0.0 did not make is not // allowed, or "" when it is. func newRecordRefused(d declaration, names, oldProps map[string]bool, oldAtRules map[string]int, exceptions map[string]complexSel, used map[string]bool) string { // An escape in any name a record carries spells a name the plain // reading below would not recognise (--rst-\73p-4 is --rst-sp-4), // so none is read at all: the CSS writes names out. if strings.ContainsRune(d.Context+d.Property, '\\') { return "holds a CSS escape in its at-rules or property, outside the grammar" } contexts := strings.Split(d.Context, " > ") last := contexts[len(contexts)-1] if d.Selector == "" || strings.HasPrefix(last, "@keyframes ") { if oldAtRules[last] > 0 { return fmt.Sprintf("belongs to %s, which 1.0.0 already has; a 1.0 page using it would animate or register differently", last) } if why := newAtRuleRefused(last, oldProps, oldAtRules); why != "" { return "belongs to " + last + ", and that at-rule " + why } return "" } if d.Selector == ":root" && strings.HasPrefix(d.Property, "--rst-") && !oldProps[d.Property] { return "" } parsed, err := parseSelector(d.Selector) if err != nil { return err.Error() } for _, sel := range sortedKeys(exceptions) { if reflect.DeepEqual(parsed, exceptions[sel]) { used[sel] = true return "" } } if ok, why := requiresNewMarkup(d.Selector, names); !ok { return why + "; no newOnOldMarkup entry is this selector" } return "" } func TestDeclarationsAreReadInTheirContext(t *testing.T) { const css = `a, [b] { color: red; margin: 0 !important } @media (x) { @supports (y) { c { d: e } } } @container (max-width: 34rem) { f { g: h } }` got, err := declarationsOf("t.css", css) if err != nil { t.Fatal(err) } want := []declaration{ {File: "t.css", Selector: "a", Property: "color", Value: "red"}, {File: "t.css", Selector: "a", Property: "margin", Value: "0", Important: true}, {File: "t.css", Selector: "[b]", Property: "color", Value: "red"}, {File: "t.css", Selector: "[b]", Property: "margin", Value: "0", Important: true}, {File: "t.css", Context: "@media (x) > @supports (y)", Selector: "c", Property: "d", Value: "e"}, {File: "t.css", Context: "@container (max-width: 34rem)", Selector: "f", Property: "g", Value: "h"}, } sameDeclarations(t, got, want) } // The reader takes comments out and refuses what it cannot read the // way a browser does: a string a newline or the end of the file cuts // off, a comment never closed or wedged between two tokens, an // unquoted url(), whose contents a browser reads as neither a string // nor a comment, so url(/*) opens none, and a backslash outside a // string, since \url( and u\72 l( are url( to a browser and \" opens // no string. A backslash inside a string is read as a browser reads // it. func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) { brackets := []struct{ css, refusal string }{ // Brackets must nest. One that closes nothing, closes another // kind or is never closed leaves a browser and a reader // disagreeing about where a declaration or a rule ends. {`a { --b: ); color: red }`, "a ) closes"}, {`a { --b: ]; color: red }`, "a ] closes"}, {`) a { b: c }`, "a ) closes nothing"}, {`a { b: ( ] ) }`, "a ] closes the ("}, {`a { --b: (; color: red }`, "a } closes the ("}, {`a { --b: [; color: red }`, "a } closes the ["}, {`a { b: c`, "a { is never closed"}, {`a { b: f(`, "a ( is never closed"}, {`a[b`, "a [ is never closed"}, // A { inside brackets opens a block a browser reads inside the // value or the prelude, where these readers would take it for // a rule. {`a { b: ({}) }`, "a { opens inside the ("}, {`@media (x { a { b: c } }) { d { e: f } }`, "a { opens inside the ("}, {`a[b { c: d }`, "a { opens inside the ["}, } for _, c := range append([]struct{ css, refusal string }{ {`[rst-snippet] { --rst-gate-a: url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: url(*/); }`, "url("}, {`a { b: URL(c) }`, "url("}, // An escaped url( hides the rule between as an unescaped one // would, however the escape is spelt. {`[rst-snippet] { --rst-a: \url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: \url(*/ ); }`, "backslash"}, {`[rst-snippet] { --rst-a: u\72 l(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: u\72 l(*/ ); }`, "backslash"}, {`a { b: u\72l(c) }`, "backslash"}, {`i\"j { k: l }`, "backslash"}, {`a { m: \"; }`, "backslash"}, {`a { b: \/* }`, "backslash"}, // Two strings cut off by their lines pair up into one if the // newline is not read as cutting the first one off. {"a { content: \"b\n; } c { content: \"d\n; }", "string"}, {`a { content: 'b`, "string"}, {`a { content: "b\`, "string"}, {`a { b: c } /* d`, "comment"}, {`a { padding: 1/**/2px }`, "between two tokens"}, }, brackets...) { if _, err := declarationsOf("t.css", c.css); err == nil || !strings.Contains(err.Error(), c.refusal) { t.Errorf("declarationsOf(%q): %v, want an error naming the %s", c.css, err, c.refusal) } } // The comment reader the other readers start from refuses them // too, and the declaration reader does on a block read alone. for _, c := range brackets { if _, err := cssWithoutComments(c.css); err == nil || !strings.Contains(err.Error(), c.refusal) { t.Errorf("cssWithoutComments(%q): %v, want an error naming the %s", c.css, err, c.refusal) } } for _, block := range []string{" --rst-review-probe: ); color: red; ", " --b: ]; c: d ", " --b: (; c: d "} { if decls, err := cssDeclarations(block); err == nil { t.Errorf("cssDeclarations(%q) = %q, want an error naming the bracket", block, decls) } } const css = "a { content: \"/*\"; b: url(\"*/\"); c: url( '/*' ) } d { content: \"e\\\nf\"; g: x-url( /**/ h) } i { k: \"\\\"/*\\\"\"; m: '\\'}'; n: o }" got, err := declarationsOf("t.css", css) if err != nil { t.Fatal(err) } want := []declaration{ {File: "t.css", Selector: "a", Property: "content", Value: `"/*"`}, {File: "t.css", Selector: "a", Property: "b", Value: `url("*/")`}, {File: "t.css", Selector: "a", Property: "c", Value: `url( '/*' )`}, {File: "t.css", Selector: "d", Property: "content", Value: "\"e\\ f\""}, {File: "t.css", Selector: "d", Property: "g", Value: "x-url( h)"}, {File: "t.css", Selector: "i", Property: "k", Value: `"\"/*\""`}, {File: "t.css", Selector: "i", Property: "m", Value: `'\'}'`}, {File: "t.css", Selector: "i", Property: "n", Value: "o"}, } sameDeclarations(t, got, want) // A bracket inside a string is text, in a selector or a value. got, err = declarationsOf("t.css", `a[title="]"] { content: ")"; b: f("(") [g] }`) if err != nil { t.Fatal(err) } sameDeclarations(t, got, []declaration{ {File: "t.css", Selector: `a[title="]"]`, Property: "content", Value: `")"`}, {File: "t.css", Selector: `a[title="]"]`, Property: "b", Value: `f("(") [g]`}, }) } func TestAtRulesAreReadAsRecords(t *testing.T) { const css = `@property --p { syntax: ""; inherits: false; initial-value: 0; } @media (m) { @view-transition { navigation: auto; } } @keyframes k { from { opacity: 0 } to { opacity: 1 } }` got, err := declarationsOf("t.css", css) if err != nil { t.Fatal(err) } want := []declaration{ {File: "t.css", Context: "@property --p", Property: "syntax", Value: `""`}, {File: "t.css", Context: "@property --p", Property: "inherits", Value: "false"}, {File: "t.css", Context: "@property --p", Property: "initial-value", Value: "0"}, {File: "t.css", Context: "@media (m) > @view-transition", Property: "navigation", Value: "auto"}, {File: "t.css", Context: "@keyframes k", Selector: "from", Property: "opacity", Value: "0"}, {File: "t.css", Context: "@keyframes k", Selector: "to", Property: "opacity", Value: "1"}, } sameDeclarations(t, got, want) // An at-rule the reader does not know is refused by name: @layer // would reorder the cascade, and reading its rules as if it were // not there would compare declarations that no longer win. _, err = declarationsOf("t.css", `@layer x { a { b: c } }`) if err == nil || !strings.Contains(err.Error(), "@layer") { t.Errorf("declarationsOf of a @layer block: %v, want an error naming @layer", err) } } func sameDeclarations(t *testing.T, got, want []declaration) { t.Helper() if !reflect.DeepEqual(got, want) { t.Errorf("declarationsOf read %d records:", len(got)) for _, d := range got { t.Errorf(" %+v", d) } t.Errorf("want %d:", len(want)) for _, d := range want { t.Errorf(" %+v", d) } } }