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>
10 files changed,
+379
−210
check/baseline_test.go+9 −2check/coverage_test.go+54 −24check/css_test.go+121 −119check/markup_v3_browser_test.go+2 −2check/markuphelp_test.go+61 −1check/rig_browser_test.go+1 −2check/sizing_browser_test.go+1 −1check/sizing_test.go+39 −43check/spelling_fixture_test.go+2 −2check/spelling_test.go+89 −14
Intent
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 |