rastrillo / fiesta Public

Find CSS comments the way a browser does in every check that reads them

The compatibility gates took every /* for the start of a comment. A
rule between two new snippet rules, one with content: "/*" and one
with content: "*/", dropped out of the reading, so both gates saw only
a valid snippet addition, while a browser applied the rule between.
Inside a 700-750px media query, [rst-page] { padding-inline: 3rem }
changed every 1.0 page at widths no photograph covers.

The gates, the selector reader and the other checks now share one
scanner. It reads strings of either quote, a backslash with what it
escapes inside or outside a string, and comments, as a browser's
tokenizer does. The structure readers behind the gate also skip an
escaped character, since --a: \"; opens no string and reading one
there hides every rule up to the next quote.

Where agreeing with a browser would mean guessing, the scanner refuses
the stylesheet instead: a string cut off by a newline or the end of
the file, a comment never closed, a comment with no space either side
(1/**/2px is neither 12px nor 1 2px), an unquoted url(), which holds
neither a string nor a comment so url(/*) opens none, and a function
name spelt with an escape, since u\72l( is url(. No stylesheet, 1.0's
or the tree's, writes any of these. Reading url() as a browser does
was the alternative to refusing it; quoting the address costs nothing.

The regular-expression strippers in the token, dialog, separator,
reduced-motion, print-shape, scheme-number and :has() checks go too.
They had the same flaw, and leafRules re-stripped its input with one,
so the size gate's stripped CSS lost its strings' markers a second
time.

Red: Astra's mutation and four variants (single quotes, an escaped
quote inside the string, \/*, an escaped quote outside a string) pass
TestTheNewRuleGateHasNoKnownHoles on the old reader;
TestTheReaderRefusesWhatItCannotReadAsABrowserDoes and
TestTheRuleReaderSeesEveryRuleABrowserApplies fail. Ten mutations of
the new scanner each fail one of the three. Green: the check package,
plain and the browser tests that read CSS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6222049ae927904122f236125a29da9a60993d02 parent 36aa579
12 files changed, +306 −103
  • check/baseline_test.go +83 −9
  • check/console_shell_browser_test.go +5 −4
  • check/coverage_test.go +11 −8
  • check/css_test.go +21 −23
  • check/markup_v3_browser_test.go +5 −4
  • check/markuphelp_test.go +112 −10
  • check/rig_browser_test.go +1 −1
  • check/selectors_test.go +1 −1
  • check/sizing_browser_test.go +1 −1
  • check/sizing_test.go +9 −8
  • check/spelling_fixture_test.go +1 −1
  • check/spelling_test.go +56 −33
diff --git a/check/baseline_test.go b/check/baseline_test.go
index 7cc661e..ed6a3a5 100644
--- a/check/baseline_test.go
+++ b/check/baseline_test.go
@@ -227,7 +227,11 @@ func declarationsOf(file, css string) ([]declaration, error) {
// could record, and still replaces 1.0's spinner on every page.
func readStylesheet(file, css string) ([]declaration, []string, error) {
r := cssReader{file: file}
- if err := r.rules(stripCSSComments(css), nil); err != nil {
+ 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
@@ -322,12 +326,17 @@ func atRuleName(prelude string) string {
// cssItems splits a rule list into its statements, each up to its ;
// or through its {} block, with strings and parentheses respected so a
-// brace inside content: "{" or a url() is not taken for structure.
+// brace inside content: "{" or a url() is not taken for structure. A
+// backslash takes the next character with it here and in the two
+// readers below, as it does for a browser: --a: \"; opens no string,
+// and reading one there would hide every rule up to the next quote.
func cssItems(src string) ([]cssItem, error) {
var items []cssItem
start, depth := 0, 0
for i := 0; i < len(src); i++ {
switch c := src[i]; c {
+ case '\\':
+ i++
case '"', '\'':
i = skipString(src, i)
case '(':
@@ -378,6 +387,8 @@ func matchingBrace(src string, open int) (int, bool) {
depth := 0
for i := open; i < len(src); i++ {
switch src[i] {
+ case '\\':
+ i++
case '"', '\'':
i = skipString(src, i)
case '{':
@@ -417,6 +428,8 @@ func cssDeclarations(block string) ([][3]string, error) {
}
for i := 0; i < len(block); i++ {
switch block[i] {
+ case '\\':
+ i++
case '"', '\'':
i = skipString(block, i)
case '(':
@@ -624,9 +637,9 @@ func vocabularyOfBaseline(t *testing.T, files []sideBySide) map[string]bool {
for _, f := range files {
css = append(css, f.wasBytes)
}
- names := vocabularyOf(css...)
+ names := vocabularyOf(t, css...)
for _, f := range files {
- for class := range styledClasses(stripCSSComments(f.wasBytes)) {
+ for class := range styledClasses(stripCSSComments(t, f.wasBytes)) {
names[class] = true
}
}
@@ -737,7 +750,7 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string {
// 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(f.wasBytes), -1) {
+ for _, p := range customProperty.FindAllString(stripCSSComments(t, f.wasBytes), -1) {
oldProps[p] = true
}
for _, at := range f.wasAt {
@@ -809,10 +822,12 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string {
// 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), and a new
-// name that only an ancestor 1.0 markup already carries supplies. The
-// last four are additions the gate must let through, so the test
-// cannot pass by refusing everything.
+// 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) {
// The spelling rules that refuse escaped, spaced and capitalised
// names must not refuse 1.0.0's own CSS, or the grammar would be
@@ -835,6 +850,22 @@ func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) {
{`@media (width: 731px) { @property --rst-\73p-4 { syntax: "<length>"; inherits: false; initial-value: 48px; } }`, `--rst-\73p-4`, true},
{`@media (width: 731px) { @keyframes rst-\73pin {} }`, `rst-\73pin`, true},
{`[rst-page-foot] [rst-dropdown] { margin: 0; }`, "[rst-page-foot] [rst-dropdown]", false},
+ // 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},
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \/*; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: */; } }`, "[rst-page] { padding-inline: 3rem", true},
+ // An escaped quote opens no string, so it cannot hide a rule
+ // inside one either.
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \"; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: "\""; } }`, "[rst-page] { padding-inline: 3rem", 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},
} {
files := everyStylesheet(t)
for i := range files {
@@ -936,6 +967,49 @@ func TestDeclarationsAreReadInTheirContext(t *testing.T) {
sameDeclarations(t, got, want)
}
+// The reader takes comments out and reads a backslash as a browser
+// does, and refuses what it cannot read the same way: a string a
+// newline or the end of the file cuts off, a comment never closed or
+// wedged between two tokens, and an unquoted url(), whose contents a
+// browser reads as neither a string nor a comment, so url(/*) opens
+// none. A function name spelt
+// with an escape is refused with it, since u\72l( is url( to a
+// browser.
+func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) {
+ for _, c := range []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("},
+ {`a { b: u\72l(c) }`, "escape"},
+ // 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"},
+ } {
+ 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)
+ }
+ }
+ const css = "a { content: \"/*\"; b: url(\"*/\"); c: url( '/*' ) } d { content: \"e\\\nf\"; g: x-url( /**/ h) } i\\\"j { k: l; 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\"j`, Property: "k", Value: "l"},
+ {File: "t.css", Selector: `i\"j`, Property: "m", Value: `\"`},
+ {File: "t.css", Selector: `i\"j`, Property: "n", Value: "o"},
+ }
+ sameDeclarations(t, got, want)
+}
+
func TestAtRulesAreReadAsRecords(t *testing.T) {
const css = `@property --p { syntax: "<number>"; inherits: false; initial-value: 0; } @media (m) { @view-transition { navigation: auto; } } @keyframes k { from { opacity: 0 } to { opacity: 1 } }`
got, err := declarationsOf("t.css", css)
diff --git a/check/console_shell_browser_test.go b/check/console_shell_browser_test.go
index 4908006..28ed480 100644
--- a/check/console_shell_browser_test.go
+++ b/check/console_shell_browser_test.go
@@ -622,9 +622,10 @@ var hasRulePattern = regexp.MustCompile(`(?s)[^{}]*\{[^{}]*\}`)
// having about `display`, and the rail landed in the right cell only
// because grid auto-placement happened to agree with the declared area.
// It looked correct. It was luck.
-func stripHas(css string) (string, int) {
+func stripHas(t testing.TB, css string) (string, int) {
+ t.Helper()
dropped := 0
- // Comments come out FIRST, using css_test.go's own cssComment. A
+ // Comments come out FIRST, with the comment reader the gates use. A
// browser tokenises them away before it ever looks at a selector,
// and the first version of this function did not: the wide
// layout's comment explains why the :has() spelling is repeated
@@ -633,7 +634,7 @@ func stripHas(css string) (string, int) {
// rule. The wide legs of the drive below caught it, which is the
// useful thing to say about them — they are not only a gate on the
// stylesheet, they are the control on this function.
- css = cssComment.ReplaceAllString(css, "")
+ css = stripCSSComments(t, css)
out := hasRulePattern.ReplaceAllStringFunc(css, func(rule string) string {
prelude, _, ok := strings.Cut(rule, "{")
if !ok || !strings.Contains(prelude, ":has(") {
@@ -677,7 +678,7 @@ func stripHas(css string) (string, int) {
// fixture's and not the degradation's.
func TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas(t *testing.T) {
real := string(fiesta.TokensCSS())
- legacy, dropped := stripHas(real)
+ legacy, dropped := stripHas(t, real)
// Control on the instrument before anything is measured with it.
if dropped == 0 {
diff --git a/check/coverage_test.go b/check/coverage_test.go
index a91087c..72ac44c 100644
--- a/check/coverage_test.go
+++ b/check/coverage_test.go
@@ -156,8 +156,9 @@ func tokensStyle(css, name string) bool {
// styledCSS is tokens.css and shell.css together, comments out, so a
// name mentioned in prose does not count as styled.
-func styledCSS() string {
- return stripCSSComments(string(fiesta.TokensCSS())) + "\n" + stripCSSComments(string(fiesta.ShellCSS()))
+func styledCSS(t testing.TB) string {
+ t.Helper()
+ return stripCSSComments(t, string(fiesta.TokensCSS())) + "\n" + stripCSSComments(t, string(fiesta.ShellCSS()))
}
// styledClasses is every rst- class the selectors of css name, whole:
@@ -181,16 +182,18 @@ var attrInSelector = regexp.MustCompile(`\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]`)
// The other classes are not entries of their own: every one is the
// twin of an attribute selector in the same rule (spelling_test.go),
// so the attribute's example is its example.
-func styledVocabulary() map[string]bool {
- return vocabularyOf(string(fiesta.TokensCSS()), string(fiesta.ShellCSS()))
+func styledVocabulary(t testing.TB) map[string]bool {
+ t.Helper()
+ return vocabularyOf(t, string(fiesta.TokensCSS()), string(fiesta.ShellCSS()))
}
// vocabularyOf is styledVocabulary over any stylesheets: the baseline's
// five as well as the tree's two.
-func vocabularyOf(sheets ...string) map[string]bool {
+func vocabularyOf(t testing.TB, sheets ...string) map[string]bool {
+ t.Helper()
out := map[string]bool{}
for _, css := range sheets {
- for _, rule := range parseCSSRules(css) {
+ for _, rule := range cssRules(t, css) {
for _, sel := range rule.selectors {
for _, m := range attrInSelector.FindAllStringSubmatch(sel, -1) {
out[m[1]] = true
@@ -218,7 +221,7 @@ func vocabularyOf(sheets ...string) map[string]bool {
// plain element and look, in the gallery, like the design system's
// idea of that component.
func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) {
- css := styledCSS()
+ css := styledCSS(t)
// 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)
@@ -332,7 +335,7 @@ func TestEverySelectorAppearsInAnExample(t *testing.T) {
// The two stylesheets name some 230 attributes, variants and
// utilities between them; far fewer is a reader that stopped
// reading, not a design system that shrank.
- styled := styledVocabulary()
+ styled := styledVocabulary(t)
if len(styled) < 200 {
t.Fatalf("read %d styled names from the stylesheets; the reader is broken, not the files", len(styled))
}
diff --git a/check/css_test.go b/check/css_test.go
index 69555e0..3494a20 100644
--- a/check/css_test.go
+++ b/check/css_test.go
@@ -59,6 +59,10 @@ func TestTokensCSSIsEmbedded(t *testing.T) {
// else is searched everywhere, the print and forced-colours blocks
// included: a radius there would override a theme's geometry.
func shapeLeaks(css string) ([]string, error) {
+ css, err := cssWithoutComments(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)
@@ -357,29 +361,23 @@ func reducedMotionBlocks(css string) (blocks []string, rest string) {
// declaration body.
type leafRule struct{ selector, body string }
-// leafRulePattern's selector group ([^{}]+) is greedy over everything
-// since the previous rule's closing brace, which includes any comment
-// sitting between the two rules — for the very first rule in the file,
-// that is tokens.css's entire, ~100-line file-header comment. blockCommentPattern
-// strips /* ... */ spans (across lines: (?s) makes '.' match '\n') out of
-// a raw selector capture before it is trimmed and used as a map key or
-// error-message value, so neither is unusably long or noisy.
-var blockCommentPattern = regexp.MustCompile(`(?s)/\*.*?\*/`)
-
// leafRulePattern extracts every innermost selector{...} rule in css —
// this naturally includes rules nested inside an @media block (matched
// before the wrapping @media's own, still-open brace), since none of
// them nest further themselves.
var leafRulePattern = regexp.MustCompile(`([^{}]+)\{([^{}]*)\}`)
+// leafRules is every innermost rule in css, which 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, and a comment reader of its own here would be one more
+// that a /* inside a string could fool.
func leafRules(css string) []leafRule {
var out []leafRule
for _, m := range leafRulePattern.FindAllStringSubmatch(css, -1) {
- sel := blockCommentPattern.ReplaceAllString(m[1], "")
- // Fields+Join collapses any remaining internal whitespace/newlines
- // left by a stripped multi-line comment down to single spaces, on
- // top of trimming the ends.
- sel = strings.Join(strings.Fields(sel), " ")
+ // Fields+Join collapses the newlines and indentation between
+ // rules down to single spaces, on top of trimming the ends.
+ sel := strings.Join(strings.Fields(m[1]), " ")
out = append(out, leafRule{selector: sel, body: m[2]})
}
return out
@@ -473,6 +471,7 @@ func TestShellCSSReducedMotionDisablesEveryAnimation(t *testing.T) {
func assertReducedMotion(t *testing.T, name, css string) {
t.Helper()
+ css = stripCSSComments(t, css)
reduceBlocks, rest := reducedMotionBlocks(css)
if len(reduceBlocks) == 0 {
t.Fatal(name + " declares no @media (prefers-reduced-motion: reduce) block at all")
@@ -544,8 +543,7 @@ func TestEveryTokenReferenceIsDeclared(t *testing.T) {
ref := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)\s*\)`)
// Comments out first: a "--rst-x:" in prose would otherwise count as
// a declaration, and these files are mostly prose.
- comment := regexp.MustCompile(`(?s)/\*.*?\*/`)
- strip := func(b []byte) string { return comment.ReplaceAllString(string(b), "") }
+ strip := func(b []byte) string { return stripCSSComments(t, string(b)) }
tokens := strip(fiesta.TokensCSS())
declared := map[string]bool{}
for _, m := range decl.FindAllStringSubmatch(tokens, -1) {
@@ -644,10 +642,6 @@ func TestDropdownExclusivityIsNative(t *testing.T) {
}
}
-// cssComment matches a whole /* … */ block, so a check can read
-// tokens.css's selectors without its prose about them.
-var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
-
// detailsNamePattern reads the name attribute off a <details> open tag,
// whichever order the attributes are written in.
var detailsNamePattern = regexp.MustCompile(`<details [^>]*name="([^"]*)"`)
@@ -707,7 +701,7 @@ func TestModalPanelIsARenderedOpenDialog(t *testing.T) {
// Comments stripped first: this file explains ::backdrop and names
// the reset rule in prose, and a check that cannot tell a selector
// from a sentence about one is not a check.
- css := cssComment.ReplaceAllString(string(fiesta.TokensCSS()), "")
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
i := strings.Index(css, "dialog[rst-modal-panel]")
if i < 0 {
t.Fatal("tokens.css has no scoped dialog reset; the UA dialog block would position the panel absolutely")
@@ -778,7 +772,7 @@ 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(string(fiesta.TokensCSS())) {
+ for _, rule := range leafRules(stripCSSComments(t, string(fiesta.TokensCSS()))) {
decls := declarations(rule.body)
if !resetsBorder(decls["border"]) || !drawsARule(decls["border-top"]) {
continue
@@ -951,7 +945,11 @@ func schemeNumberBlocks(t *testing.T, name, css string) []string {
func schemeNumberProblems(name, css string) (blocks []string, problems []string) {
where := "themes/" + name + ".css"
bad := func(format string, args ...any) { problems = append(problems, where+": "+fmt.Sprintf(format, args...)) }
- stripped := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(css, "")
+ stripped, err := cssWithoutComments(css)
+ if err != nil {
+ bad("%v", err)
+ return nil, problems
+ }
const (
toggleLight = `:root[data-theme="light"] { color-scheme: light; }`
toggleDark = `:root[data-theme="dark"] { color-scheme: dark; }`
diff --git a/check/markup_v3_browser_test.go b/check/markup_v3_browser_test.go
index f02f943..3cb6e8e 100644
--- a/check/markup_v3_browser_test.go
+++ b/check/markup_v3_browser_test.go
@@ -106,8 +106,9 @@ func bareRSTMarkup(markup string) string {
// declared set is read from the file rather than typed out, so a rule
// that starts painting something new is compared on it without anybody
// remembering to add it here.
-func styleProperties() []string {
- css := stripCSSComments(string(fiesta.TokensCSS()))
+func styleProperties(t testing.TB) []string {
+ t.Helper()
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
seen := map[string]bool{}
for _, m := range regexp.MustCompile(`[;{]\s*([a-z-]+)\s*:`).FindAllStringSubmatch(css, -1) {
if name := m[1]; !strings.HasPrefix(name, "--") {
@@ -243,7 +244,7 @@ func TestBothSpellingsComputeTheSameStyles(t *testing.T) {
ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
defer cancel()
- props := styleProperties()
+ props := styleProperties(t)
propsJSON, err := json.Marshal(props)
if err != nil {
t.Fatalf("marshalling the property list: %v", err)
@@ -389,7 +390,7 @@ func TestBothSpellingsComputeTheSameStyles(t *testing.T) {
func assertFixtureCoversTokensCSS(t *testing.T, fixture string) {
t.Helper()
styled := map[string]bool{}
- for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(string(fiesta.TokensCSS())), -1) {
+ for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
styled[m[1]] = true
}
used := map[string]bool{}
diff --git a/check/markuphelp_test.go b/check/markuphelp_test.go
index 859d54a..f1999ea 100644
--- a/check/markuphelp_test.go
+++ b/check/markuphelp_test.go
@@ -1,6 +1,7 @@
package check
import (
+ "fmt"
"regexp"
"strings"
"testing"
@@ -95,19 +96,120 @@ var (
rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`)
)
-func stripCSSComments(s string) string {
+// stripCSSComments is cssWithoutComments failing the test on a
+// stylesheet it refuses.
+func stripCSSComments(t testing.TB, css string) string {
+ t.Helper()
+ out, err := cssWithoutComments(css)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// cssWithoutComments is css with its comments taken out, found the way
+// a browser finds them. A reader that takes every /* for a comment can
+// be made to skip a rule a browser applies: between content: "/*" and
+// content: "*/" a whole rule drops out of the reading and still styles
+// every page. See cssSpan for what it refuses.
+func cssWithoutComments(css string) (string, error) {
var b strings.Builder
- for {
- k := strings.Index(s, "/*")
+ for i := 0; i < len(css); {
+ end, comment, err := cssSpan(css, i)
+ if err != nil {
+ return "", err
+ }
+ if !comment {
+ b.WriteString(css[i:end])
+ }
+ i = end
+ }
+ return b.String(), nil
+}
+
+// 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 backslash with the character it
+// escapes, a run of name characters, or else one byte. It returns
+// where the piece ends and whether it is a comment. A /* inside a
+// string or after a backslash opens no comment, and a quote inside a
+// comment or after a backslash opens no string.
+//
+// What a browser reads in a way these readers would have to guess at
+// is refused instead. A string cut off by a newline or the end of the
+// file is dropped by a browser along with its declaration; a reader
+// would have to guess where it ends. A comment never closed swallows
+// the rest of the file, and one with no space either side has no
+// spelling without it that means the same (see below). An unquoted url() holds neither a string nor a
+// comment, so url(/*) opens none; reading that would mean reading
+// url() as a browser does, and quoting the address costs nothing. A
+// function name spelt with an escape is refused with it, since u\72l(
+// is url( to a browser.
+func cssSpan(css string, i int) (end int, comment bool, err error) {
+ line := func() int { return 1 + strings.Count(css[:i], "\n") }
+ switch c := css[i]; {
+ case strings.HasPrefix(css[i:], "/*"):
+ k := strings.Index(css[i+2:], "*/")
if k < 0 {
- b.WriteString(s)
- return b.String()
+ return 0, false, fmt.Errorf("line %d: a comment is never closed", line())
+ }
+ end := i + 2 + k + 2
+ // A comment keeps the tokens either side apart without being
+ // whitespace, so dropping it joins them and putting a space in
+ // its place adds a combinator: 1/**/2px is not 12px, and
+ // .a/**/.b is not .a .b.
+ if i > 0 && end < len(css) && !isCSSSpace(css[i-1]) && !isCSSSpace(css[end]) {
+ return 0, false, fmt.Errorf("line %d: a comment sits between two tokens with no space beside it; add one", line())
+ }
+ return end, true, nil
+ case c == '"' || c == '\'':
+ for j := i + 1; j < len(css); j++ {
+ switch css[j] {
+ case c:
+ return j + 1, false, nil
+ case '\\':
+ // An escaped newline continues the string; \r\n is
+ // one newline.
+ if strings.HasPrefix(css[j+1:], "\r\n") {
+ j++
+ }
+ j++
+ case '\n', '\r', '\f':
+ return 0, false, fmt.Errorf("line %d: a string is cut off by the end of its line", line())
+ }
+ }
+ return 0, false, fmt.Errorf("line %d: a string is cut off by the end of the file", line())
+ case c == '\\':
+ return min(i+2, len(css)), false, nil
+ case isCSSNameByte(c):
+ j, escaped := i, false
+ for j < len(css) && (isCSSNameByte(css[j]) || css[j] == '\\') {
+ if css[j] == '\\' {
+ j, escaped = j+1, true
+ }
+ j++
}
- b.WriteString(s[:k])
- j := strings.Index(s[k+2:], "*/")
- if j < 0 {
- return b.String()
+ j = min(j, len(css))
+ if j == len(css) || css[j] != '(' {
+ return j, false, nil
}
- s = s[k+2+j+2:]
+ if escaped {
+ return 0, false, fmt.Errorf("line %d: the function name %s( is spelt with an escape; write the name it spells", line(), css[i:j])
+ }
+ if strings.EqualFold(css[i:j], "url") {
+ if rest := strings.TrimLeft(css[j+1:], " \t\n\r\f"); rest == "" || (rest[0] != '"' && rest[0] != '\'') {
+ return 0, false, fmt.Errorf("line %d: an unquoted url( is not read here; quote its address", line())
+ }
+ }
+ return j, false, nil
}
+ return i + 1, false, nil
+}
+
+func isCSSSpace(c byte) bool { return strings.IndexByte(" \t\n\r\f", c) >= 0 }
+
+// isCSSNameByte is a byte that can continue a CSS name: a letter, a
+// digit, - or _, or any byte of a non-ASCII character.
+func isCSSNameByte(c byte) bool {
+ return c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80
}
diff --git a/check/rig_browser_test.go b/check/rig_browser_test.go
index 8cb327a..10cc329 100644
--- a/check/rig_browser_test.go
+++ b/check/rig_browser_test.go
@@ -511,7 +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(string(fiesta.TokensCSS())), -1) {
+ for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
class := m[1]
name, variant, ok := attributeFor(class)
if !ok {
diff --git a/check/selectors_test.go b/check/selectors_test.go
index f6160cb..21066e4 100644
--- a/check/selectors_test.go
+++ b/check/selectors_test.go
@@ -87,7 +87,7 @@ func plainSpelling(sel string) error {
// spelling and coverage gates as well as in the grammar.
func assertPlainSpelling(t *testing.T, name, css string) {
t.Helper()
- for _, rule := range parseCSSRules(css) {
+ for _, rule := range cssRules(t, css) {
for _, sel := range rule.selectors {
if strings.HasPrefix(sel, "@") {
if strings.ContainsRune(sel, '\\') {
diff --git a/check/sizing_browser_test.go b/check/sizing_browser_test.go
index b0fc454..05eae2d 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(stripCSSComments(block)) {
+ for _, r := range leafRules(stripCSSComments(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 9263bc3..7e3252c 100644
--- a/check/sizing_test.go
+++ b/check/sizing_test.go
@@ -40,7 +40,7 @@ const bareZoomFloor = ":where(input:not([type=checkbox]):not([type=radio]):not([
// this rewrite exists to remove). Moving any floor one rule up or down
// fails here.
func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) {
- css := stripCSSComments(string(fiesta.TokensCSS()))
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
floors := map[string][2]int{}
for _, m := range zoomFloorRule.FindAllStringSubmatchIndex(css, -1) {
sel := collapseSpace(css[m[2]:m[3]])
@@ -100,7 +100,7 @@ func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) {
// The type step itself (§1.2), read as text so a desktop-only edit
// cannot move it: the four tokens, in the touch query, at :root.
func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) {
- css := stripCSSComments(string(fiesta.TokensCSS()))
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
re := regexp.MustCompile(regexp.QuoteMeta(touchQuery) + `\s*:root\s*\{([^{}]*)\}`)
m := re.FindStringSubmatch(css)
if m == nil {
@@ -135,7 +135,7 @@ func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) {
// --rst-tap directly would be 44px on a desktop too, or, moved out of
// the touch query, the 32px desktop floor would never reach it.
func TestTheTargetFloorIsOneToken(t *testing.T) {
- css := stripCSSComments(string(fiesta.TokensCSS()))
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
root := regexp.MustCompile(`(?m)^:root \{([^{}]*)\}`).FindStringSubmatch(css)
if root == nil || !regexp.MustCompile(`--rst-target:\s*2rem;`).MatchString(root[1]) {
t.Error("the top-level :root does not declare --rst-target: 2rem, the desktop floor")
@@ -235,7 +235,7 @@ 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(stripCSSComments(block))
+ rules := leafRules(stripCSSComments(t, block))
if len(rules) < 30 {
t.Fatalf("read %d rules in the floor block; the parse no longer finds them", len(rules))
}
@@ -402,7 +402,7 @@ var iconFree = map[string]string{
// rule exists to stop.
func TestEveryControlSizesTheIconInIt(t *testing.T) {
var sel string
- for _, r := range leafRules(stripCSSComments(string(fiesta.TokensCSS()))) {
+ for _, r := range leafRules(stripCSSComments(t, string(fiesta.TokensCSS()))) {
if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && strings.Contains(r.body, "block-size: 1rem") {
sel = collapseSpace(r.selector)
}
@@ -450,9 +450,10 @@ func endsOnControlElement(sel string) bool {
// interactiveCSSRules is every innermost rule in css that carries cursor:
// pointer or styles an a, button, summary or label.
-func interactiveCSSRules(css string) []leafRule {
+func interactiveCSSRules(t testing.TB, css string) []leafRule {
+ t.Helper()
var out []leafRule
- for _, r := range leafRules(stripCSSComments(css)) {
+ for _, r := range leafRules(stripCSSComments(t, css)) {
hit := strings.Contains(r.body, "cursor: pointer")
for _, s := range splitSelectorList(r.selector) {
hit = hit || endsOnControlElement(s)
@@ -470,7 +471,7 @@ func interactiveCSSRules(css string) []leafRule {
// table above (or an exemption with a reason), and every idiom in the
// table has to be on the page the browser drive measures.
func TestEveryInteractiveRuleIsInTheTapInventory(t *testing.T) {
- rules := interactiveCSSRules(string(fiesta.TokensCSS()))
+ rules := interactiveCSSRules(t, string(fiesta.TokensCSS()))
if len(rules) < 40 {
t.Fatalf("found only %d interactive rules in tokens.css; the reader is broken, not the file", len(rules))
}
diff --git a/check/spelling_fixture_test.go b/check/spelling_fixture_test.go
index b0886dd..ba69652 100644
--- a/check/spelling_fixture_test.go
+++ b/check/spelling_fixture_test.go
@@ -21,7 +21,7 @@ import (
func mappedAttributes(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
- for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(string(fiesta.TokensCSS())), -1) {
+ for _, m := range classInSelector.FindAllStringSubmatch(stripCSSComments(t, string(fiesta.TokensCSS())), -1) {
if name, _, ok := markup.Attribute(m[1]); ok {
out[name] = true
}
diff --git a/check/spelling_test.go b/check/spelling_test.go
index 2bdeb20..f23631c 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 (
+ "slices"
"sort"
"strings"
"testing"
@@ -74,42 +75,44 @@ type cssRule struct {
// tokens.css is a hand-written file with no preprocessor, and a
// full CSS parser would be a dependency this file has no need of. The
// preludes it returns include at-rules (@media, @supports); those carry
-// no rst- selector and fall out of every check below on their own.
-func parseCSSRules(css string) []cssRule {
+// no rst- selector and fall out of every check below on their own. It
+// reads comments, strings and escapes with cssSpan, the comment
+// reader the 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.
+func parseCSSRules(css string) ([]cssRule, error) {
var rules []cssRule
- start, i, n := 0, 0, len(css)
- for i < n {
- if strings.HasPrefix(css[i:], "/*") {
- if j := strings.Index(css[i+2:], "*/"); j >= 0 {
- i += 2 + j + 2
- } else {
- i = n
- }
- continue
+ var prelude strings.Builder
+ for i := 0; i < len(css); {
+ end, comment, err := cssSpan(css, i)
+ if err != nil {
+ return nil, err
}
- switch c := css[i]; c {
- case '"', '\'':
- i++
- for i < n && css[i] != c {
- if css[i] == '\\' {
- i++
- }
- i++
- }
- i++
- case '{':
+ switch {
+ case comment:
+ case css[i] == '{':
rules = append(rules, cssRule{
- selectors: splitSelectorList(stripCSSComments(css[start:i])),
+ selectors: splitSelectorList(prelude.String()),
line: 1 + strings.Count(css[:i], "\n"),
})
- i++
- start = i
- case '}':
- i++
- start = i
+ prelude.Reset()
+ case css[i] == '}':
+ prelude.Reset()
default:
- i++
+ prelude.WriteString(css[i:end])
}
+ i = end
+ }
+ return rules, nil
+}
+
+// cssRules is parseCSSRules failing the test on a stylesheet it
+// refuses.
+func cssRules(t testing.TB, css string) []cssRule {
+ t.Helper()
+ rules, err := parseCSSRules(css)
+ if err != nil {
+ t.Fatal(err)
}
return rules
}
@@ -300,7 +303,7 @@ func TestShellCSSPairsBothSpellings(t *testing.T) {
// leaving nothing to check.
func assertTwins(t *testing.T, name, css string, floor int) {
t.Helper()
- rules := parseCSSRules(css)
+ rules := cssRules(t, css)
if len(rules) == 0 {
t.Fatal(name + " parsed to no rules at all — the reader is broken, not the file")
}
@@ -342,7 +345,7 @@ func assertNoOrphans(t *testing.T, name, css string) {
// A selector spelt to slip past the literal "[rst-" read below
// ([ rst-page], [rst-PAGE], an escape) is refused by name first.
assertPlainSpelling(t, name, css)
- for _, rule := range parseCSSRules(css) {
+ for _, rule := range cssRules(t, css) {
twins := make(map[string]bool, len(rule.selectors))
for _, sel := range rule.selectors {
if twin := attributeTwin(sel); twin != sel {
@@ -368,7 +371,7 @@ func assertNoOrphans(t *testing.T, name, css string) {
// no entry may have quietly grown an attribute form (so "this stays a
// class" stays true).
func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) {
- css := stripCSSComments(string(fiesta.TokensCSS()))
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
for class, why := range classKeepsItsSpelling {
if !strings.Contains(css, "."+class) {
t.Errorf("markup.Utilities names %q (%s) and tokens.css has no such class: delete the entry", class, why)
@@ -404,7 +407,7 @@ 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(string(fiesta.TokensCSS()))
+ css := stripCSSComments(t, string(fiesta.TokensCSS()))
seen := map[string][]string{}
for _, m := range classInSelector.FindAllStringSubmatch(css, -1) {
class := m[1]
@@ -432,6 +435,26 @@ func TestNoTwoClassesWantTheSameAttribute(t *testing.T) {
}
}
+// The selector reader finds every rule a browser applies. A comment
+// marker a browser does not take for one, beside a rule, must not hide
+// that rule from the spelling gates: [ rst-PAGE] is [rst-page] on
+// every 1.0 page.
+func TestTheRuleReaderSeesEveryRuleABrowserApplies(t *testing.T) {
+ for _, css := range []string{
+ `[rst-snippet]::before { content: "/*"; } [ rst-PAGE] { padding: 0; } [rst-snippet]::after { content: "*/"; }`,
+ `[rst-snippet] { --rst-gate-a: \/*; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: */; }`,
+ `[rst-snippet] { --rst-gate-a: \"; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: "\""; }`,
+ } {
+ seen := false
+ for _, rule := range cssRules(t, css) {
+ seen = seen || slices.Contains(rule.selectors, "[ rst-PAGE]")
+ }
+ if !seen {
+ t.Errorf("parseCSSRules(%q) does not read the rule [ rst-PAGE], which a browser applies", css)
+ }
+ }
+}
+
// TestSpecificityReadsSelectorsTheWayTheSpecDoes is the gate's own
// gate. The pairing check leans on specificity() being right about
// :where() (weightless), :not()/:has() (their heaviest argument) and