rastrillo / fiesta Public

Read rules, selectors and section headings in check with the scanner

The readers the gates share each read stylesheet text their own way,
and each could be made to pass a gate by a string or a structure they
guessed at.

- leafRules counted braces. A { inside a rule's block, a nested rule
  to a browser, became the leaf and the rule around it was dropped,
  so [rst-c] { cursor: pointer; [rst-d] { ... } } left the tap
  inventory. Leaf rules now come from the declaration reader, which
  refuses a nested rule, and carry their declarations rather than
  their text; the floors, the icon rule, the menu separators and the
  interactive-rule scan read those. A rule's value is the cascade's
  within one block: the last !important one, else the last.
- splitSelectorList and endsOnControlElement counted brackets in the
  raw text, so a ] or ( inside [title="]"] left the count off zero:
  [a="]"], [b] stayed one selector, and [title="]"] a did not end on
  an a. Both now read with cssSpan.
- cssRules walked braces without the declaration reader's refusals. A
  nested rule became a rule of its own, and a statement glued itself
  to the next selector: @import "x.css"; [ rst-PAGE] read as one
  selector starting with @, which the spelling gate skips. It now
  reads with readStylesheet first and refuses what that refuses.
- Classes and attribute selectors were found with patterns over the
  stylesheet's text, so .rst-y or [rst-z] quoted in a value counted
  as styled. styledClasses and styledAttributes read the selectors of
  the rules with selectorClasses and selectorAttributes, on cssSpan;
  every caller of the class pattern and of tokensStyle goes through
  them.
- floorBlock cut the floor block at its heading comments with
  strings.Index, so the heading's words in a string moved the cut.
  commentAt finds the comment with cssSpan.

On tokens.css and shell.css, the old and new readers find the same
interactive rules, classes, vocabulary and floor-block rules.

Red: TestTheSelectorReadersReadAStringAsText's inputs and the nested
rule and statement in TestTheRuleReaderSeesEveryRuleABrowserApplies,
fed to the old functions, each read wrongly. Mutations, each red and
reverted: the selector splitter (S1), the last-compound reader (S2)
and the class reader (S3) stepping a byte at a time; attribute
selectors taken from the text with a pattern (S4); cssRules checked
by the comment reader alone (S5); value ignoring !important (S6);
commentAt matching any piece holding the heading (S7); a rule whose
block holds a brace dropped (S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 072d79e51da17dc3260a8818ccb15ff378f2c0ee parent 94c6b3e
10 files changed, +379 −210
  • check/baseline_test.go +9 −2
  • check/coverage_test.go +54 −24
  • check/css_test.go +121 −119
  • check/markup_v3_browser_test.go +2 −2
  • check/markuphelp_test.go +61 −1
  • check/rig_browser_test.go +1 −2
  • check/sizing_browser_test.go +1 −1
  • check/sizing_test.go +39 −43
  • check/spelling_fixture_test.go +2 −2
  • check/spelling_test.go +89 −14
diff --git a/check/baseline_test.go b/check/baseline_test.go
index dc6748b..4ac8509 100644
--- a/check/baseline_test.go
+++ b/check/baseline_test.go
@@ -241,6 +241,8 @@ 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
@@ -257,7 +259,10 @@ func (r *cssReader) rules(src string, context []string) error {
if err != nil {
return err
}
- for _, it := range items {
+ 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)
@@ -268,6 +273,7 @@ func (r *cssReader) rules(src string, context []string) error {
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)
@@ -296,6 +302,7 @@ func (r *cssReader) rules(src string, context []string) error {
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)
@@ -618,7 +625,7 @@ func vocabularyOfBaseline(t *testing.T, files []sideBySide) map[string]bool {
}
names := vocabularyOf(t, css...)
for _, f := range files {
- for class := range styledClasses(stripCSSComments(t, f.wasBytes)) {
+ for class := range styledClasses(t, f.wasBytes) {
names[class] = true
}
}
diff --git a/check/coverage_test.go b/check/coverage_test.go
index 72ac44c..80b7686 100644
--- a/check/coverage_test.go
+++ b/check/coverage_test.go
@@ -145,17 +145,35 @@ func unstyledHere(name string, names map[string]bool) bool {
return ok && (u.On == "" || names[u.On])
}
-// tokensStyle reports whether css carries a selector for one entry of
-// that vocabulary.
-func tokensStyle(css, name string) bool {
+// tokensStyle reports whether a stylesheet carries a selector for one
+// entry of that vocabulary, given its attribute selectors, which
+// styledAttributes reads.
+func tokensStyle(attributes map[string]bool, name string) bool {
if kind, variant, ok := strings.Cut(name, "~="); ok {
- return strings.Contains(css, "["+kind+"~=\""+variant+"\"]")
+ return attributes["["+kind+"~=\""+variant+"\"]"]
}
- return strings.Contains(css, "["+name+"]")
+ return attributes["["+name+"]"]
}
-// styledCSS is tokens.css and shell.css together, comments out, so a
-// name mentioned in prose does not count as styled.
+// styledAttributes is every attribute selector the rules of css hold,
+// read with cssRules and selectorAttributes: searched as text, a
+// [rst-input~="primary"] in a comment or a string counted as styled.
+func styledAttributes(t testing.TB, css string) map[string]bool {
+ t.Helper()
+ out := map[string]bool{}
+ for _, rule := range cssRules(t, css) {
+ for _, sel := range rule.selectors {
+ for _, attr := range selectorAttributes(sel) {
+ out[attr] = true
+ }
+ }
+ }
+ return out
+}
+
+// styledCSS is tokens.css and shell.css together, each read as a
+// stylesheet first, so a name mentioned in prose does not count as
+// styled.
func styledCSS(t testing.TB) string {
t.Helper()
return stripCSSComments(t, string(fiesta.TokensCSS())) + "\n" + stripCSSComments(t, string(fiesta.ShellCSS()))
@@ -164,18 +182,27 @@ func styledCSS(t testing.TB) string {
// styledClasses is every rst- class the selectors of css name, whole:
// a class is styled when it is a selector's class token, not when it
// is the start of one (.rst-m-hide styles no element whose class is
-// rst-m).
-func styledClasses(css string) map[string]bool {
+// rst-m). It reads the selectors of css's rules with cssRules and
+// selectorClasses: searched as text, a .rst- inside a string or a
+// value counted as a class.
+func styledClasses(t testing.TB, css string) map[string]bool {
+ t.Helper()
out := map[string]bool{}
- for _, m := range classInSelector.FindAllStringSubmatch(css, -1) {
- out[m[1]] = true
+ for _, rule := range cssRules(t, css) {
+ for _, sel := range rule.selectors {
+ for _, class := range selectorClasses(sel) {
+ if strings.HasPrefix(class, "rst-") {
+ out[class] = true
+ }
+ }
+ }
}
return out
}
-// attrInSelector finds one rst- attribute selector, bare or with a
-// variant, wherever it sits in a selector.
-var attrInSelector = regexp.MustCompile(`\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]`)
+// attrInSelector reads one rst- attribute selector, bare or with a
+// variant, as selectorAttributes returns it.
+var attrInSelector = regexp.MustCompile(`^\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]$`)
// styledVocabulary is every entry the two stylesheets' selectors name:
// each attribute, each attribute-and-variant, and each utility class.
@@ -195,15 +222,17 @@ func vocabularyOf(t testing.TB, sheets ...string) map[string]bool {
for _, css := range sheets {
for _, rule := range cssRules(t, css) {
for _, sel := range rule.selectors {
- for _, m := range attrInSelector.FindAllStringSubmatch(sel, -1) {
- out[m[1]] = true
- if m[2] != "" {
- out[m[1]+"~="+m[2]] = true
+ for _, attr := range selectorAttributes(sel) {
+ if m := attrInSelector.FindStringSubmatch(attr); m != nil {
+ out[m[1]] = true
+ if m[2] != "" {
+ out[m[1]+"~="+m[2]] = true
+ }
}
}
- for _, m := range classInSelector.FindAllStringSubmatch(sel, -1) {
- if _, utility := markup.Utilities[m[1]]; utility {
- out["."+m[1]] = true
+ for _, class := range selectorClasses(sel) {
+ if _, utility := markup.Utilities[class]; utility {
+ out["."+class] = true
}
}
}
@@ -222,10 +251,11 @@ func vocabularyOf(t testing.TB, sheets ...string) map[string]bool {
// idea of that component.
func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) {
css := styledCSS(t)
+ attributes := styledAttributes(t, css)
// The vocabulary below is read off the literal [rst-…] and .rst-…;
// a spelling a browser matches and that read misses is refused first.
assertPlainSpelling(t, "tokens.css and shell.css", css)
- classes := styledClasses(css)
+ classes := styledClasses(t, css)
exempted := map[string]bool{}
for _, s := range everyState(t, fiesta.FS()) {
missing := map[string]bool{}
@@ -239,7 +269,7 @@ func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) {
exempted[name] = true
continue
}
- if !tokensStyle(css, name) {
+ if !tokensStyle(attributes, name) {
missing[name] = true
}
}
@@ -262,7 +292,7 @@ func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) {
switch {
case strings.TrimSpace(u.Why) == "":
t.Errorf("unstyledByDesign lists %s with no reason", name)
- case tokensStyle(css, name):
+ case tokensStyle(attributes, name):
t.Errorf("unstyledByDesign lists %s, and the stylesheets style it now; remove the entry", name)
case !exempted[name]:
t.Errorf("unstyledByDesign lists %s, and no example writes it where the entry applies; remove the entry", name)
diff --git a/check/css_test.go b/check/css_test.go
index 6806067..f8aa21d 100644
--- a/check/css_test.go
+++ b/check/css_test.go
@@ -60,54 +60,66 @@ func TestTokensCSSIsEmbedded(t *testing.T) {
// error when that flatten is missing, doubled or elsewhere. Everything
// else is searched everywhere, the print and forced-colours blocks
// included: a radius there would override a theme's geometry.
+//
+// It reads rules with the declaration reader. Read as text, the
+// flatten counted where it sat in a string at html:root, with the real
+// one gone, and the print block it had to sit in was cut by counting
+// braces.
func shapeLeaks(css string) ([]string, error) {
- css, err := cssWithoutComments(css)
+ rules, err := readLeafRules(css)
if err != nil {
return nil, err
}
- const flatten = "--rst-shadow-knob: 0 0 0 transparent; --rst-shadow-knob-hover: 0 0 0 transparent;"
- if n := strings.Count(css, flatten); n != 1 {
- return nil, fmt.Errorf("tokens.css carries print's knob-shadow flatten %d times, want once, at html:root inside @media print", n)
- }
- // Every print block (the stage art has a small one of its own before
- // the big one): the flatten must sit in an html:root rule of one.
- const header = "@media print {"
- atRoot := false
- for idx := 0; ; {
- i := strings.Index(css[idx:], header)
- if i < 0 {
- break
- }
- start := idx + i + len(header)
- end, err := braceMatchEnd(css, start)
- if err != nil {
- return nil, err
- }
- rules, err := readLeafRules(css[start:end])
- if err != nil {
- return nil, err
- }
- for _, r := range rules {
- if r.selector == "html:root" && strings.Contains(r.body, flatten) {
- atRoot = true
+ const none = "0 0 0 transparent"
+ flatten := func(r leafRule) bool {
+ return r.sets("--rst-shadow-knob", none) && r.sets("--rst-shadow-knob-hover", none)
+ }
+ var allowed *leafRule
+ flattens := 0
+ for i, r := range rules {
+ if flatten(r) {
+ flattens++
+ if r.selector == "html:root" && slices.Contains(strings.Split(r.context, " > "), "@media print") {
+ allowed = &rules[i]
}
}
- idx = end + 1
}
- if !atRoot {
+ if flattens != 1 {
+ return nil, fmt.Errorf("tokens.css carries print's knob-shadow flatten %d times, want once, at html:root inside @media print", flattens)
+ }
+ if allowed == nil {
return nil, fmt.Errorf("print's knob-shadow flatten is not at html:root inside @media print, the one place it is allowed")
}
- structural := strings.Replace(css, flatten, "", 1)
+ shape := []string{
+ "--rst-radius", "--rst-radius-sm", "--rst-radius-pill",
+ "--rst-shadow-pop", "--rst-shadow-knob", "--rst-shadow-lift", "--rst-overlay",
+ }
+ leaked := map[string]bool{}
+ for i, r := range rules {
+ scheme := strings.ReplaceAll(r.context+" "+r.selector, " ", "")
+ for _, gone := range []string{"prefers-color-scheme:dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`} {
+ if strings.Contains(scheme, gone) {
+ leaked[gone] = true
+ }
+ }
+ for _, d := range r.decls {
+ if &rules[i] == allowed && strings.HasPrefix(d[0], "--rst-shadow-knob") {
+ continue
+ }
+ leaked[d[0]+":"] = true
+ }
+ }
var out []string
- for _, gone := range []string{
- "prefers-color-scheme: dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`,
- "--rst-radius:", "--rst-radius-sm:", "--rst-radius-pill:",
- "--rst-shadow-pop:", "--rst-shadow-knob:", "--rst-shadow-lift:", "--rst-overlay:",
- } {
- if strings.Contains(structural, gone) {
+ for _, gone := range []string{"prefers-color-scheme:dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`} {
+ if leaked[gone] {
out = append(out, gone)
}
}
+ for _, p := range shape {
+ if leaked[p+":"] {
+ out = append(out, p+":")
+ }
+ }
return out, nil
}
@@ -319,47 +331,54 @@ func TestBothSchemesAreAuthoredInEveryTheme(t *testing.T) {
// as a way to silence the failure.
var reducedMotionAllowlist = map[string]bool{}
-// leafRule is one selector plus its (brace-matched, non-nested)
-// declaration body.
-type leafRule struct{ selector, body string }
+// leafRule is one style rule, or one keyframe, as declarationsOf reads
+// it: the at-rules around it, joined as a declaration's Context is;
+// top, the top-level statement it sits in, counted from 0, so source
+// order between rules can be read off it; its selector list as written,
+// whitespace collapsed; and its declarations as cssDeclarations reads
+// them. A gate reads a rule's declarations here rather than its text,
+// so a "cursor: pointer" inside a string is not one.
+type leafRule struct {
+ context string
+ top int
+ selector string
+ decls [][3]string
+}
-// readLeafRules is every innermost rule in css, a rule nested in an
-// @media block included, its selector being everything since the brace
-// before it. It finds strings with cssSpan, as the gates do, so a
-// brace inside one is text: a pattern over braces cut
-// [rst-snippet] { --rst-x: "}"; cursor: pointer; } at the quoted
-// brace, and the size gate never saw the pointer. css must have had
-// its comments taken out first, with stripCSSComments: a comment
-// between two rules would otherwise be read as part of the next one's
-// selector.
-func readLeafRules(css string) ([]leafRule, error) {
- var out []leafRule
- // open is the { of the block being read, or -1 once a block has
- // closed inside it, which makes it no leaf. prelude starts just
- // past the brace before it.
- open, prelude := -1, 0
- selector := ""
- for i := 0; i < len(css); {
- end, _, err := cssSpan(css, i)
- if err != nil {
- return nil, err
- }
- switch css[i] {
- case '{':
- open, selector, prelude = i, css[prelude:i], i+1
- case '}':
- if open >= 0 && selector != "" {
- // Fields+Join collapses the newlines and indentation
- // between rules down to single spaces, on top of
- // trimming the ends.
- sel := strings.Join(strings.Fields(selector), " ")
- out = append(out, leafRule{selector: sel, body: css[open+1 : i]})
- }
- open, prelude = -1, i+1
+// value is what the rule sets prop to, as the cascade reads one block:
+// the last !important declaration of it, else the last.
+func (r leafRule) value(prop string) (string, bool) {
+ value, found, important := "", false, false
+ for _, d := range r.decls {
+ if d[0] == prop && (d[2] != "" || !important) {
+ value, found, important = d[1], true, d[2] != ""
}
- i = end
}
- return out, nil
+ return value, found
+}
+
+// sets reports whether the rule sets prop to value.
+func (r leafRule) sets(prop, value string) bool {
+ v, ok := r.value(prop)
+ return ok && v == value
+}
+
+// readLeafRules is every style rule and keyframe in css, nested in an
+// at-rule or not, read with the declaration reader. A pattern over
+// braces cut [rst-snippet] { --rst-x: "}"; cursor: pointer; } at the
+// quoted brace, and a reader of braces alone took a { in a rule's
+// block for a rule of its own and dropped the one around it. This one
+// refuses what declarationsOf refuses, a nested rule among them.
+func readLeafRules(css string) ([]leafRule, error) {
+ r := cssReader{}
+ bare, err := cssWithoutComments(css)
+ if err == nil {
+ err = r.rules(bare, nil)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return r.leaves, nil
}
// leafRules is readLeafRules failing the test on a stylesheet it
@@ -380,12 +399,12 @@ func leafRules(t testing.TB, css string) []leafRule {
func TestLeafRulesReadABraceInAStringAsText(t *testing.T) {
const css = `[rst-snippet] { --rst-x: "}"; cursor: pointer; } [rst-a] { content: '{'; color: red } @media (x) { [rst-b] { cursor: pointer } }`
want := []leafRule{
- {selector: "[rst-snippet]", body: ` --rst-x: "}"; cursor: pointer; `},
- {selector: "[rst-a]", body: ` content: '{'; color: red `},
- {selector: "[rst-b]", body: " cursor: pointer "},
+ {top: 0, selector: "[rst-snippet]", decls: [][3]string{{"--rst-x", `"}"`, ""}, {"cursor", "pointer", ""}}},
+ {top: 1, selector: "[rst-a]", decls: [][3]string{{"content", "'{'", ""}, {"color", "red", ""}}},
+ {context: "@media (x)", top: 2, selector: "[rst-b]", decls: [][3]string{{"cursor", "pointer", ""}}},
}
if got := leafRules(t, css); !reflect.DeepEqual(got, want) {
- t.Errorf("leafRules(%q) =\n%q\nwant\n%q", css, got, want)
+ t.Errorf("leafRules(%q) =\n%+v\nwant\n%+v", css, got, want)
}
var controls []string
for _, r := range interactiveCSSRules(t, css) {
@@ -394,23 +413,19 @@ func TestLeafRulesReadABraceInAStringAsText(t *testing.T) {
if want := []string{"[rst-snippet]", "[rst-b]"}; !reflect.DeepEqual(controls, want) {
t.Errorf("interactiveCSSRules(%q) finds %q, want %q", css, controls, want)
}
-}
-
-// selectorList splits a (possibly comma-separated) selector into its
-// individual, trimmed selectors — ".a, .a::after" becomes [".a",
-// ".a::after"], each compared for exact equality elsewhere in this file,
-// never by substring: a substring check would let a new, unguarded
-// "[rst-tip]" rule pass by riding on "[rst-tip]::after" already being
-// listed in a reduce block, which is exactly the false negative this
-// gate exists to catch.
-func selectorList(sel string) []string {
- var out []string
- for _, s := range strings.Split(sel, ",") {
- if s = strings.TrimSpace(s); s != "" {
- out = append(out, s)
- }
+ // A { in a rule's block is a nested rule to a browser, which still
+ // applies the rule around it. A reader of braces took the nested
+ // one for the leaf and lost [rst-c]'s cursor: pointer, so the tap
+ // inventory never saw the control. It is refused instead.
+ if rules, err := readLeafRules(`[rst-c] { cursor: pointer; [rst-d] { color: red } }`); err == nil {
+ t.Errorf("readLeafRules read a nested rule as %+v, want an error", rules)
+ }
+ // Within one block an !important declaration wins over a later
+ // plain one.
+ r := leafRule{decls: [][3]string{{"cursor", "pointer", "!important"}, {"cursor", "auto", ""}}}
+ if v, _ := r.value("cursor"); v != "pointer" {
+ t.Errorf("value(cursor) of %+v = %q, want pointer", r.decls, v)
}
- return out
}
// TestReducedMotionDisablesEveryTransition is the motion gate:
@@ -632,7 +647,7 @@ func TestEveryTokenReferenceIsDeclared(t *testing.T) {
// field-textarea, field-check and choice-field between them can emit
// every one of these, and every one is spelled as the examples spell it.
func TestFormPartialClassesAreStyled(t *testing.T) {
- css := string(fiesta.TokensCSS())
+ attributes := styledAttributes(t, string(fiesta.TokensCSS()))
for _, name := range []string{
"rst-field", "rst-field-label", "rst-field-hint", "rst-field-help", "rst-field-error",
"rst-input", "rst-input~=short", "rst-input~=primary",
@@ -640,7 +655,7 @@ func TestFormPartialClassesAreStyled(t *testing.T) {
"rst-choice", "rst-choice-cards", "rst-choice-title", "rst-choice-desc",
"rst-seg-tabs",
} {
- if !tokensStyle(css, name) {
+ if !tokensStyle(attributes, name) {
t.Errorf("tokens.css has no selector for %q", name)
}
}
@@ -653,22 +668,22 @@ func TestFormPartialClassesAreStyled(t *testing.T) {
// partials' fixtures, and this pins
// that every class they can render resolves to a tokens.css selector.
func TestRoutesFamilyPartialClassesAreStyled(t *testing.T) {
- css := string(fiesta.TokensCSS())
+ attributes := styledAttributes(t, string(fiesta.TokensCSS()))
for _, name := range []string{
"rst-btn~=ghost", "rst-btn~=danger",
"rst-back-nav", "rst-notice", "rst-form-error",
"rst-bulkbar", "rst-bulkbar-close", "rst-bulkbar-count", "rst-bulkbar-escalate",
} {
- if !tokensStyle(css, name) {
+ if !tokensStyle(attributes, name) {
t.Errorf("tokens.css has no selector for %q", name)
}
}
}
func TestJobStatusPartialClassesAreStyled(t *testing.T) {
- css := string(fiesta.TokensCSS())
+ attributes := styledAttributes(t, string(fiesta.TokensCSS()))
for _, name := range []string{"rst-spin"} {
- if !tokensStyle(css, name) {
+ if !tokensStyle(attributes, name) {
t.Errorf("tokens.css has no selector for %q", name)
}
}
@@ -817,12 +832,13 @@ func TestSrOnlyUtilityComesAfterTheRulesItMustBeat(t *testing.T) {
// are checked, and the width is checked for being non-zero.
func TestEveryMenuSurfaceStylesItsSeparator(t *testing.T) {
styled := map[string]bool{}
- for _, rule := range leafRules(t, stripCSSComments(t, string(fiesta.TokensCSS()))) {
- decls := declarations(rule.body)
- if !resetsBorder(decls["border"]) || !drawsARule(decls["border-top"]) {
+ for _, rule := range leafRules(t, string(fiesta.TokensCSS())) {
+ border, _ := rule.value("border")
+ top, _ := rule.value("border-top")
+ if !resetsBorder(border) || !drawsARule(top) {
continue
}
- for _, sel := range selectorList(rule.selector) {
+ for _, sel := range splitSelectorList(rule.selector) {
styled[sel] = true
}
}
@@ -837,20 +853,6 @@ func TestEveryMenuSurfaceStylesItsSeparator(t *testing.T) {
}
}
-// declarations splits a rule body into property → value. Last wins,
-// as the cascade does within one block.
-func declarations(body string) map[string]string {
- out := map[string]string{}
- for _, d := range strings.Split(body, ";") {
- prop, val, ok := strings.Cut(d, ":")
- if !ok {
- continue
- }
- out[strings.ToLower(strings.TrimSpace(prop))] = strings.TrimSpace(val)
- }
- return out
-}
-
// resetsBorder reports whether a value drops the UA's inset 3D border
// altogether. Only the shorthand does that; a longhand leaves three
// sides of groove behind.
@@ -1141,12 +1143,12 @@ func TestTokensCSSHasNoColourLiterals(t *testing.T) {
// The same drift check the other partial families get: every class the
// error page emits has a rule in tokens.css.
func TestErrorPageClassesAreStyled(t *testing.T) {
- css := string(fiesta.TokensCSS())
+ attributes := styledAttributes(t, string(fiesta.TokensCSS()))
for _, name := range []string{
"rst-error", "rst-error-status", "rst-error-title",
"rst-error-body", "rst-error-cta", "rst-error-ref",
} {
- if !tokensStyle(css, name) {
+ if !tokensStyle(attributes, name) {
t.Errorf("tokens.css has no selector for %q", name)
}
}
diff --git a/check/markup_v3_browser_test.go b/check/markup_v3_browser_test.go
index 3cb6e8e..e7cec29 100644
--- a/check/markup_v3_browser_test.go
+++ b/check/markup_v3_browser_test.go
@@ -390,8 +390,8 @@ func TestBothSpellingsComputeTheSameStyles(t *testing.T) {
func assertFixtureCoversTokensCSS(t *testing.T, fixture string) {
t.Helper()
styled := map[string]bool{}
- for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
- styled[m[1]] = true
+ for class := range styledClasses(t, string(fiesta.TokensCSS())) {
+ styled[class] = true
}
used := map[string]bool{}
for _, m := range classInMarkup.FindAllStringSubmatch(fixture, -1) {
diff --git a/check/markuphelp_test.go b/check/markuphelp_test.go
index 342e632..f3663c8 100644
--- a/check/markuphelp_test.go
+++ b/check/markuphelp_test.go
@@ -90,7 +90,6 @@ func defaultMarkup(t *testing.T, name string) string {
func attributeFor(class string) (name, variant string, ok bool) { return markup.Attribute(class) }
var (
- classInSelector = regexp.MustCompile(`\.(rst-[A-Za-z0-9_-]+)`)
classInMarkup = regexp.MustCompile(`class="([^"]*)"`)
openTagInFixture = regexp.MustCompile(`<[a-zA-Z][^>]*>`)
rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`)
@@ -227,6 +226,67 @@ func unclosedCSSBracket(css string, open []int) error {
return fmt.Errorf("line %d: a %c is never closed", 1+strings.Count(css[:at], "\n"), css[at])
}
+// commentAt is the offset of the first comment in css that starts
+// with prefix, found with cssSpan, or -1. A heading comment marks
+// where a section of a stylesheet starts; found by searching the text,
+// the same words inside a string would move the cut.
+func commentAt(t testing.TB, css, prefix string) int {
+ t.Helper()
+ for i := 0; i < len(css); {
+ end, comment, err := cssSpan(css, i)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if comment && strings.HasPrefix(css[i:end], prefix) {
+ return i
+ }
+ i = end
+ }
+ return -1
+}
+
+// selectorClasses is every class sel names, read with cssSpan, so a
+// .rst- inside a quoted attribute value is not one.
+func selectorClasses(sel string) []string {
+ var out []string
+ for i := 0; i < len(sel); {
+ end, _, err := cssSpan(sel, i)
+ if err != nil {
+ return out
+ }
+ if sel[i] == '.' && end < len(sel) && isCSSNameByte(sel[end]) {
+ name, _, _ := cssSpan(sel, end)
+ out = append(out, sel[end:name])
+ end = name
+ }
+ i = end
+ }
+ return out
+}
+
+// selectorAttributes is every attribute selector sel holds, each from
+// its [ to the ] that closes it, read with cssSpan and braceMatchEnd,
+// so a [ or ] inside a quoted value neither starts nor ends one.
+func selectorAttributes(sel string) []string {
+ var out []string
+ for i := 0; i < len(sel); {
+ end, _, err := cssSpan(sel, i)
+ if err != nil {
+ return out
+ }
+ if sel[i] == '[' {
+ close, err := braceMatchEnd(sel, i+1)
+ if err != nil {
+ return out
+ }
+ out = append(out, sel[i:close+1])
+ end = close + 1
+ }
+ i = end
+ }
+ return out
+}
+
// cssSpan reads the piece of css that starts at i, as a browser's
// tokenizer would, for the readers that need to know what is a comment:
// a comment, a whole string, a run of name characters, or else one
diff --git a/check/rig_browser_test.go b/check/rig_browser_test.go
index 10cc329..70f5f57 100644
--- a/check/rig_browser_test.go
+++ b/check/rig_browser_test.go
@@ -511,8 +511,7 @@ func classSpelling(t *testing.T, html string) string {
t.Helper()
byAttr := map[string]string{}
byVariant := map[string]string{}
- for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
- class := m[1]
+ for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) {
name, variant, ok := attributeFor(class)
if !ok {
continue
diff --git a/check/sizing_browser_test.go b/check/sizing_browser_test.go
index cd54c14..7c63c2c 100644
--- a/check/sizing_browser_test.go
+++ b/check/sizing_browser_test.go
@@ -1025,7 +1025,7 @@ var mayShrink = map[string]string{
func TestTheFloorChangesNothingButSizeOnADesktop(t *testing.T) {
without, block := floorBlock(t)
var sels []string
- for _, r := range leafRules(t, stripCSSComments(t, block)) {
+ for _, r := range leafRules(t, block) {
for _, s := range splitSelectorList(r.selector) {
if !strings.HasPrefix(s, ":where(") {
sels = append(sels, s)
diff --git a/check/sizing_test.go b/check/sizing_test.go
index 80729d5..4bc6e03 100644
--- a/check/sizing_test.go
+++ b/check/sizing_test.go
@@ -21,6 +21,9 @@ import (
// floor cannot quietly scope it to a query nothing matches.
const touchQuery = "@media (pointer: coarse), (max-width: 40rem) {"
+// touchContext is the touch query as a rule's context names it.
+const touchContext = "@media (pointer: coarse), (max-width: 40rem)"
+
// zoomFloorRule finds a floor: the touch query holding exactly one rule
// whose only declaration is the floor. Read off comment-stripped CSS.
var zoomFloorRule = regexp.MustCompile(`@media \(pointer: coarse\), \(max-width: 40rem\) \{\s*([^{}]+?)\s*\{\s*font-size: max\(1rem, 1em\);\s*\}\s*\}`)
@@ -155,8 +158,20 @@ func TestTheTargetFloorIsOneToken(t *testing.T) {
// holds leaves the control at its old size on a desktop. Named
// rather than counted, so a control that loses its floor fails by
// name, and two floors merged into one rule pass.
- everyWidth := withoutTouchQueries(t, css)
- sized := regexp.MustCompile(`(?:^|;)\s*(?:min-)?(?:block|inline)-size:\s*var\(--rst-target\)`)
+ var everyWidth []leafRule
+ for _, r := range leafRules(t, css) {
+ if !slices.Contains(strings.Split(r.context, " > "), touchContext) {
+ everyWidth = append(everyWidth, r)
+ }
+ }
+ sized := func(r leafRule) bool {
+ for _, p := range []string{"min-block-size", "min-inline-size", "block-size", "inline-size"} {
+ if r.sets(p, "var(--rst-target)") {
+ return true
+ }
+ }
+ return false
+ }
for _, e := range tapInventory {
if why, ok := floorElsewhere[e.Key]; ok {
if why == "" {
@@ -165,8 +180,8 @@ func TestTheTargetFloorIsOneToken(t *testing.T) {
continue
}
found := false
- for _, r := range leafRules(t, everyWidth) {
- if !sized.MatchString(r.body) {
+ for _, r := range everyWidth {
+ if !sized(r) {
continue
}
for _, s := range splitSelectorList(r.selector) {
@@ -193,8 +208,8 @@ func TestTheTargetFloorIsOneToken(t *testing.T) {
func floorBlock(t *testing.T) (without, block string) {
t.Helper()
css := string(fiesta.TokensCSS())
- start := strings.Index(css, "/* ── Target floors, at every width")
- end := strings.Index(css, "/* ---------------------------------------------------------------------\n Utilities, last on purpose.")
+ start := commentAt(t, css, "/* ── Target floors, at every width")
+ end := commentAt(t, css, "/* ---------------------------------------------------------------------\n Utilities, last on purpose.")
if start < 0 || end < start {
t.Fatal("tokens.css has no floor block between its heading and the utilities; the markers this test cuts on have moved")
}
@@ -235,22 +250,17 @@ var floorDeclarations = map[string]struct {
// either here with its reason, or it goes in the touch query.
func TestTheFloorBlockCarriesOnlySizes(t *testing.T) {
_, block := floorBlock(t)
- rules := leafRules(t, stripCSSComments(t, block))
+ rules := leafRules(t, block)
if len(rules) < 30 {
t.Fatalf("read %d rules in the floor block; the parse no longer finds them", len(rules))
}
used := map[string]bool{}
for _, r := range rules {
- for _, d := range strings.Split(r.body, ";") {
- if d = strings.TrimSpace(d); d == "" {
- continue
- }
- prop, value, ok := strings.Cut(d, ":")
- if !ok {
- t.Errorf("%s: cannot read the declaration %q", r.selector, d)
- continue
+ for _, d := range r.decls {
+ prop, value := d[0], d[1]
+ if d[2] != "" {
+ value += " " + d[2]
}
- prop, value = strings.TrimSpace(prop), strings.TrimSpace(value)
used[prop] = true
allowed, ok := floorDeclarations[prop]
if !ok {
@@ -288,27 +298,6 @@ var floorElsewhere = map[string]string{
"[rst-shell-back] > a": "its own 2.75rem height is over the floor; only its width reads the token",
}
-// withoutTouchQueries is css with every touch query, header, braces and
-// all, cut out.
-func withoutTouchQueries(t *testing.T, css string) string {
- t.Helper()
- var b strings.Builder
- for {
- i := strings.Index(css, touchQuery)
- if i < 0 {
- break
- }
- end, err := braceMatchEnd(css, i+len(touchQuery))
- if err != nil {
- t.Fatal(err)
- }
- b.WriteString(css[:i])
- css = css[end+1:]
- }
- b.WriteString(css)
- return b.String()
-}
-
// tapEntry is one row of spec §1.4's table: Key is a substring of the
// attribute spelling of the rules that style the idiom, Marker a string
// the sizing fixture (or the modal sample) must contain for the idiom to
@@ -406,8 +395,8 @@ var iconFree = map[string]string{
// rule exists to stop.
func TestEveryControlSizesTheIconInIt(t *testing.T) {
var sel string
- for _, r := range leafRules(t, stripCSSComments(t, string(fiesta.TokensCSS()))) {
- if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && strings.Contains(r.body, "block-size: 1rem") {
+ for _, r := range leafRules(t, string(fiesta.TokensCSS())) {
+ if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && r.sets("block-size", "1rem") {
sel = collapseSpace(r.selector)
}
}
@@ -434,10 +423,16 @@ func TestEveryControlSizesTheIconInIt(t *testing.T) {
}
// endsOnControlElement reports whether a selector's last compound is an
-// a, button, summary or label: the rule styles that element.
+// a, button, summary or label: the rule styles that element. It reads
+// the selector with cssSpan, so a space or a bracket inside a quoted
+// attribute value is text, not the start of a compound.
func endsOnControlElement(sel string) bool {
depth, last := 0, 0
- for i := 0; i < len(sel); i++ {
+ for i := 0; i < len(sel); {
+ end, _, err := cssSpan(sel, i)
+ if err != nil {
+ return false
+ }
switch c := sel[i]; c {
case '(', '[':
depth++
@@ -448,6 +443,7 @@ func endsOnControlElement(sel string) bool {
last = i + 1
}
}
+ i = end
}
return regexp.MustCompile(`^(a|button|summary|label)($|[^a-zA-Z0-9_-])`).MatchString(strings.TrimSpace(sel[last:]))
}
@@ -457,8 +453,8 @@ func endsOnControlElement(sel string) bool {
func interactiveCSSRules(t testing.TB, css string) []leafRule {
t.Helper()
var out []leafRule
- for _, r := range leafRules(t, stripCSSComments(t, css)) {
- hit := strings.Contains(r.body, "cursor: pointer")
+ for _, r := range leafRules(t, css) {
+ hit := slices.Contains(r.decls, [3]string{"cursor", "pointer", ""}) || slices.Contains(r.decls, [3]string{"cursor", "pointer", "!important"})
for _, s := range splitSelectorList(r.selector) {
hit = hit || endsOnControlElement(s)
}
diff --git a/check/spelling_fixture_test.go b/check/spelling_fixture_test.go
index ba69652..6e5e818 100644
--- a/check/spelling_fixture_test.go
+++ b/check/spelling_fixture_test.go
@@ -21,8 +21,8 @@ import (
func mappedAttributes(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
- for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
- if name, _, ok := markup.Attribute(m[1]); ok {
+ for class := range styledClasses(t, string(fiesta.TokensCSS())) {
+ if name, _, ok := markup.Attribute(class); ok {
out[name] = true
}
}
diff --git a/check/spelling_test.go b/check/spelling_test.go
index b28a3cc..f41417d 100644
--- a/check/spelling_test.go
+++ b/check/spelling_test.go
@@ -38,6 +38,7 @@ package check
// match, property by property, pseudo-elements included.
import (
+ "reflect"
"slices"
"sort"
"strings"
@@ -80,7 +81,16 @@ type cssRule struct {
// declaration gates use, so a /* a browser does not take for a comment
// hides no rule from the spelling gates either, and it refuses what
// cssSpan refuses, a backslash outside a string among them.
+//
+// It reads css with readStylesheet first and refuses what that refuses.
+// A walk over braces alone takes a nested rule for one of its own and
+// a statement before a rule for part of its selector; the declaration
+// reader refuses both, and on what it reads every { outside a string
+// opens a rule or an at-rule's block.
func parseCSSRules(css string) ([]cssRule, error) {
+ if _, _, err := readStylesheet("the stylesheet", css); err != nil {
+ return nil, err
+ }
var rules []cssRule
var prelude strings.Builder
for i := 0; i < len(css); {
@@ -119,27 +129,34 @@ func cssRules(t testing.TB, css string) []cssRule {
// splitSelectorList splits on the commas that separate selectors,
// leaving alone the ones inside :is(), :where(), :not(), :has() and
-// attribute values.
+// attribute values. It reads with cssSpan, so a comma or a bracket
+// inside a quoted value is text: counting brackets in the raw text, a
+// ] inside [title="]"] ended the attribute early and a comma after it
+// split one selector in two. The preludes it is given come from a
+// reader that has already refused what cssSpan refuses; should one
+// not, the rest is read as one selector.
func splitSelectorList(prelude string) []string {
var out []string
- depth := 0
- cur := strings.Builder{}
- for _, r := range prelude {
- switch r {
+ depth, start := 0, 0
+ for i := 0; i < len(prelude); {
+ end, _, err := cssSpan(prelude, i)
+ if err != nil {
+ break
+ }
+ switch prelude[i] {
case '(', '[':
depth++
case ')', ']':
depth--
case ',':
if depth == 0 {
- out = append(out, collapseSpace(cur.String()))
- cur.Reset()
- continue
+ out = append(out, collapseSpace(prelude[start:i]))
+ start = i + 1
}
}
- cur.WriteRune(r)
+ i = end
}
- if s := collapseSpace(cur.String()); s != "" {
+ if s := collapseSpace(prelude[start:]); s != "" {
out = append(out, s)
}
return out
@@ -372,8 +389,11 @@ func assertNoOrphans(t *testing.T, name, css string) {
// class" stays true).
func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) {
css := stripCSSComments(t, string(fiesta.TokensCSS()))
+ // Present means a selector names the class: the text of the file
+ // holds it in prose and strings too.
+ styled := styledClasses(t, css)
for class, why := range classKeepsItsSpelling {
- if !strings.Contains(css, "."+class) {
+ if !styled[class] {
t.Errorf("markup.Utilities names %q (%s) and tokens.css has no such class: delete the entry", class, why)
}
flat := "rst-" + strings.ReplaceAll(strings.TrimPrefix(class, "rst-"), "__", "-")
@@ -407,10 +427,8 @@ func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) {
// Every new name has to be checked against this, not just the ones that
// existed on the day.
func TestNoTwoClassesWantTheSameAttribute(t *testing.T) {
- css := stripCSSComments(t, string(fiesta.TokensCSS()))
seen := map[string][]string{}
- for _, m := range classInSelector.FindAllStringSubmatch(css, -1) {
- class := m[1]
+ for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) {
name, variant, ok := attributeFor(class)
if !ok {
continue
@@ -460,6 +478,63 @@ func TestTheRuleReaderSeesEveryRuleABrowserApplies(t *testing.T) {
t.Errorf("parseCSSRules(%q): %v, want an error naming the backslash", css, err)
}
}
+ // A nested rule is read under the rule around it, and a statement
+ // before a rule is not part of its selector. Reading braces alone
+ // took [rst-d] for a rule of its own and glued the @import to
+ // [ rst-PAGE]; both are refused, as declarationsOf refuses them.
+ for _, css := range []string{
+ `[rst-c] { color: red; [rst-d] { color: blue } }`,
+ `@import "x.css"; [ rst-PAGE] { padding: 0; }`,
+ } {
+ if rules, err := parseCSSRules(css); err == nil {
+ t.Errorf("parseCSSRules(%q) = %+v, want an error", css, rules)
+ }
+ }
+}
+
+// The selector readers read a quoted value as text, as a browser does.
+// Read as raw text, each of these let a gate pass: a ] or ( inside a
+// string left the bracket count off zero, so [b] stayed glued to the
+// selector before it; the last compound was lost, so a rule styling an
+// a escaped the tap inventory; and a class or attribute selector
+// quoted in a value, or a heading quoted in a string, counted as real.
+func TestTheSelectorReadersReadAStringAsText(t *testing.T) {
+ for _, c := range []struct {
+ prelude string
+ want []string
+ }{
+ {`[a="]"], [b]`, []string{`[a="]"]`, "[b]"}},
+ {`[a="("], [b]`, []string{`[a="("]`, "[b]"}},
+ {`:is(a, b), [c=","]`, []string{":is(a, b)", `[c=","]`}},
+ } {
+ if got := splitSelectorList(c.prelude); !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitSelectorList(%q) = %q, want %q", c.prelude, got, c.want)
+ }
+ }
+ if !endsOnControlElement(`[title="]"] a`) {
+ t.Errorf("endsOnControlElement does not see the a in %q", `[title="]"] a`)
+ }
+ const css = `[rst-x] { content: ".rst-y [rst-z]"; } [title=".rst-w [rst-v]"] { color: red; } .rst-u { --rst-a: "/* Utilities */"; }`
+ classes, attributes := styledClasses(t, css), styledAttributes(t, css)
+ for _, name := range []string{"rst-y", "rst-w"} {
+ if classes[name] {
+ t.Errorf("styledClasses reads %s, which only a string names, as styled", name)
+ }
+ }
+ if !classes["rst-u"] {
+ t.Error("styledClasses misses .rst-u")
+ }
+ for _, name := range []string{"rst-z", "rst-v"} {
+ if tokensStyle(attributes, name) {
+ t.Errorf("tokensStyle reads [%s], which only a string names, as styled", name)
+ }
+ }
+ if !tokensStyle(attributes, "rst-x") {
+ t.Error("tokensStyle misses [rst-x]")
+ }
+ if at := commentAt(t, css+" /* Utilities */", "/* Utilities"); at != len(css)+1 {
+ t.Errorf("commentAt finds the heading at %d, want %d, the comment rather than the string", at, len(css)+1)
+ }
}
// TestSpecificityReadsSelectorsTheWayTheSpecDoes is the gate's own