rastrillo / fiesta Public

Refuse a backslash outside a string in every CSS reader in check

An escaped url( still hid a rule from the gates. The scanner skipped a
backslash and the character after it, so \url(/*) read as a name, a
bracket and a comment, while a browser reads url(, whose /* opens no
comment. A rule between that and a matching \url(*/ ) dropped out of
the reading and still styled every 1.0 page. u\72 l( did the same: a
hex escape ends at the space after it, which the scanner took for the
end of the name.

The scanner now refuses any backslash outside a string, and tells the
author to write the character plainly. Following each escape form a
browser accepts was the alternative; every form missed so far has
hidden a rule, and no stylesheet needs one. No CSS file under css/, the 1.0
seed or releases/ contains a backslash at all. Inside a string a
backslash is read as before.

The structure readers behind the gate no longer skip a backslash
outside a string, since none reaches them, and the old refusal of a
function name spelt with an escape is part of the new one.

Red: both of Astra's inputs read without error as a single
[rst-snippet] rule, the page rule gone, and pass these three:
TestTheNewRuleGateHasNoKnownHoles,
TestTheReaderRefusesWhatItCannotReadAsABrowserDoes,
TestTheRuleReaderSeesEveryRuleABrowserApplies.
Three mutations of the new scanner each fail them: skipping the
backslash again, letting a name run swallow an escape, and no escapes
inside strings. Green: the check package.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1ab0dfc27ace46c305e8ade47f1e86caa28e21e5 parent ef382e6
3 files changed, +83 −63
  • check/baseline_test.go +46 −31
  • check/markuphelp_test.go +18 −20
  • check/spelling_test.go +19 −12
diff --git a/check/baseline_test.go b/check/baseline_test.go
index ed6a3a5..f3e764b 100644
--- a/check/baseline_test.go
+++ b/check/baseline_test.go
@@ -326,17 +326,15 @@ 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. 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.
+// brace inside content: "{" or a url() is not taken for structure. It
+// and the two readers below see only what cssWithoutComments returned,
+// which has no backslash outside a string, so only skipString reads
+// escapes.
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 '(':
@@ -387,8 +385,6 @@ 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 '{':
@@ -428,8 +424,6 @@ 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 '(':
@@ -829,6 +823,7 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string {
// gate must let through, so the test cannot pass by refusing
// everything.
func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) {
+ const outsideAString = "a backslash outside a string"
// The spelling rules that refuse escaped, spaced and capitalised
// names must not refuse 1.0.0's own CSS, or the grammar would be
// a rule 1.0 itself breaks.
@@ -847,8 +842,8 @@ func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) {
{`@property --rst-gate-control { syntax: "<length>"; inherits: false; initial-value: 0px; }`, "@property --rst-gate-control", false},
{`:root { --rst-gate-control: 1px; }`, "--rst-gate-control", false},
{`@media (width: 731px) { [ rst-PAGE] { padding-inline: 100px; } }`, "[ rst-PAGE]", true},
- {`@media (width: 731px) { @property --rst-\73p-4 { syntax: "<length>"; inherits: false; initial-value: 48px; } }`, `--rst-\73p-4`, true},
- {`@media (width: 731px) { @keyframes rst-\73pin {} }`, `rst-\73pin`, true},
+ {`@media (width: 731px) { @property --rst-\73p-4 { syntax: "<length>"; inherits: false; initial-value: 48px; } }`, outsideAString, true},
+ {`@media (width: 731px) { @keyframes rst-\73pin {} }`, outsideAString, true},
{`[rst-page-foot] [rst-dropdown] { margin: 0; }`, "[rst-page-foot] [rst-dropdown]", false},
// A comment marker the reader takes for one and a browser does
// not, either side of a rule: inside a string of either quote,
@@ -858,23 +853,36 @@ func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) {
{`@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 backslash outside a string is refused by the reader, before
+ // the gate: an escaped quote opens no string and an escaped
+ // url( is still url(, so either can hide a rule.
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \/*; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: */; } }`, outsideAString, true},
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \"; } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: "\""; } }`, outsideAString, true},
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: \url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: \url(*/ ); } }`, outsideAString, true},
+ {`@media (min-width: 700px) and (max-width: 750px) { [rst-snippet] { --rst-gate-a: u\72 l(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-gate-b: u\72 l(*/ ); } }`, outsideAString, true},
// A snippet whose content is a comment marker adds nothing to a
// 1.0 page, so the reading that refuses the rules above must
// still let it through.
{`[rst-snippet]::before { content: "/* */"; }`, "[rst-snippet]::before", false},
} {
+ // A stylesheet the reader refuses fails the gate before it
+ // compares anything, so the refusal is the reader's error.
+ var refusals []string
files := everyStylesheet(t)
for i := range files {
if files[i].src == "tokens.css" {
- files[i].is, files[i].isAt = parseDeclarations(t, "css/tokens.css", files[i].wasBytes+"\n"+c.css+"\n")
+ var err error
+ files[i].is, files[i].isAt, err = readStylesheet("css/tokens.css", files[i].wasBytes+"\n"+c.css+"\n")
+ if err != nil {
+ refusals = []string{err.Error()}
+ }
}
}
+ if refusals == nil {
+ refusals = refusedAdditions(t, files)
+ }
named := false
- for _, why := range refusedAdditions(t, files) {
+ for _, why := range refusals {
if strings.Contains(why, c.names) {
named = true
t.Logf("refused: %s", why)
@@ -967,19 +975,26 @@ 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.
+// The reader takes comments out and refuses what it cannot read the
+// way a browser does: a string a newline or the end of the file cuts
+// off, a comment never closed or wedged between two tokens, an
+// unquoted url(), whose contents a browser reads as neither a string
+// nor a comment, so url(/*) opens none, and a backslash outside a
+// string, since \url( and u\72 l( are url( to a browser and \" opens
+// no string. A backslash inside a string is read as a browser reads
+// it.
func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) {
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"},
+ // An escaped url( hides the rule between as an unescaped one
+ // would, however the escape is spelt.
+ {`[rst-snippet] { --rst-a: \url(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: \url(*/ ); }`, "backslash"},
+ {`[rst-snippet] { --rst-a: u\72 l(/*); } [rst-page] { padding-inline: 3rem; } [rst-snippet] { --rst-b: u\72 l(*/ ); }`, "backslash"},
+ {`a { b: u\72l(c) }`, "backslash"},
+ {`i\"j { k: l }`, "backslash"},
+ {`a { m: \"; }`, "backslash"},
+ {`a { b: \/* }`, "backslash"},
// Two strings cut off by their lines pair up into one if the
// newline is not read as cutting the first one off.
{"a { content: \"b\n; } c { content: \"d\n; }", "string"},
@@ -992,7 +1007,7 @@ func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) {
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 }"
+ const css = "a { content: \"/*\"; b: url(\"*/\"); c: url( '/*' ) } d { content: \"e\\\nf\"; g: x-url( /**/ h) } i { k: \"\\\"/*\\\"\"; m: '\\'}'; n: o }"
got, err := declarationsOf("t.css", css)
if err != nil {
t.Fatal(err)
@@ -1003,9 +1018,9 @@ func TestTheReaderRefusesWhatItCannotReadAsABrowserDoes(t *testing.T) {
{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"},
+ {File: "t.css", Selector: "i", Property: "k", Value: `"\"/*\""`},
+ {File: "t.css", Selector: "i", Property: "m", Value: `'\'}'`},
+ {File: "t.css", Selector: "i", Property: "n", Value: "o"},
}
sameDeclarations(t, got, want)
}
diff --git a/check/markuphelp_test.go b/check/markuphelp_test.go
index f1999ea..b05f7c4 100644
--- a/check/markuphelp_test.go
+++ b/check/markuphelp_test.go
@@ -129,22 +129,27 @@ func cssWithoutComments(css string) (string, error) {
// 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.
+// a comment, a whole string, 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 opens no comment, and a quote inside a comment
+// 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.
+// 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 backslash outside a string is refused whatever follows it. It
+// escapes a character a browser then reads as part of a name, so \url(
+// and u\72 l( are url(, \" opens no string and \/* no comment. Each
+// escape form a reader missed has hidden a rule, and no stylesheet
+// needs one: the character can be written plainly. Inside a string a
+// backslash is read as a browser reads it.
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]; {
@@ -180,22 +185,15 @@ func cssSpan(css string, i int) (end int, comment bool, err error) {
}
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
+ return 0, false, fmt.Errorf("line %d: a backslash outside a string is not read here; write the character it escapes plainly", line())
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 := i
+ for j < len(css) && isCSSNameByte(css[j]) {
j++
}
- j = min(j, len(css))
if j == len(css) || css[j] != '(' {
return j, false, nil
}
- 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())
diff --git a/check/spelling_test.go b/check/spelling_test.go
index f23631c..b28a3cc 100644
--- a/check/spelling_test.go
+++ b/check/spelling_test.go
@@ -76,10 +76,10 @@ type cssRule struct {
// 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. 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.
+// reads comments and strings 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, a backslash outside a string among them.
func parseCSSRules(css string) ([]cssRule, error) {
var rules []cssRule
var prelude strings.Builder
@@ -438,19 +438,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.
+// every 1.0 page. One inside a string is read past; one an escape
+// makes is refused with the backslash, since an escape outside a
+// string is one more spelling the reader would have to decode.
func TestTheRuleReaderSeesEveryRuleABrowserApplies(t *testing.T) {
+ const css = `[rst-snippet]::before { content: "/*"; } [ rst-PAGE] { padding: 0; } [rst-snippet]::after { content: "*/"; }`
+ 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)
+ }
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: "\""; }`,
+ `[rst-snippet] { --rst-gate-a: \url(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: \url(*/ ); }`,
+ `[rst-snippet] { --rst-gate-a: u\72 l(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: u\72 l(*/ ); }`,
} {
- 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)
+ if _, err := parseCSSRules(css); err == nil || !strings.Contains(err.Error(), "backslash") {
+ t.Errorf("parseCSSRules(%q): %v, want an error naming the backslash", css, err)
}
}
}