rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
4 "fmt"
5 "regexp"
6 "sort"
7 "strings"
8 "testing"
9
10 "amadan.net/rastrillo/fiesta/internal/markup"
11)
12
13// The grammar every rule 1.x adds is written in, read by a parser
14// rather than by pattern. A check that a new rule mentions a new name
15// is fooled twice over: [rst-page]:not([rst-snippet]) mentions one and
16// matches every 1.0 page because it lacks it, and
17// :is([rst-page], [rst-snippet]) matches 1.0 pages through its first
18// branch. So a new rule is held to a grammar narrow enough that what a
19// selector requires can be read off it:
20//
21// - an alternative is compounds joined by descendant, > or +;
22// - a compound is an optional type selector, then classes, attribute
23// selectors ([a], [a="v"], [a~="v"]), the pseudo-classes :hover,
24// :active, :focus-visible, :disabled, :empty, :first-child,
25// :last-child, :nth-child(An+B), :has() and :not(), and last
26// ::before or ::after (a state like :disabled, or :empty, only
27// narrows, and never stands for a name);
28// - :is(), :where(), * and anything else are outside it, except *
29// inside a :not() argument, where it can only narrow.
30
31// plainSpelling refuses the spellings of a name that the gates' plain
32// reading would take for a different name, though a browser matches
33// the same elements: a CSS escape (\73 is s, so rst-\73pin is
34// rst-spin), whitespace inside an attribute selector's brackets
35// ([ rst-page] is [rst-page]), and capitals in an attribute or class
36// name (HTML attribute names match whatever their case, so [rst-PAGE]
37// is [rst-page] on every 1.0 page). Rather than decode each spelling,
38// the files are held to the one spelling the gates read. It is a
39// lexical check, so the spelling and coverage gates apply it to every
40// selector, :where() and :is() included, and the grammar before it
41// parses one.
42func plainSpelling(sel string) error {
43 if strings.ContainsRune(sel, '\\') {
44 return fmt.Errorf("a CSS escape (\\) is outside the grammar")
45 }
46 for i := 0; i < len(sel); i++ {
47 switch c := sel[i]; c {
48 case '"', '\'':
49 end := strings.IndexByte(sel[i+1:], c)
50 if end < 0 {
51 return fmt.Errorf("an unclosed string")
52 }
53 i += end + 1
54 case '[':
55 end := i + 1
56 for end < len(sel) && sel[end] != ']' {
57 if q := sel[end]; q == '"' || q == '\'' {
58 if k := strings.IndexByte(sel[end+1:], q); k >= 0 {
59 end += k + 1
60 }
61 }
62 end++
63 }
64 inner := sel[i+1 : min(end, len(sel))]
65 if strings.ContainsAny(inner, " \t\n\r\f") {
66 return fmt.Errorf("whitespace inside [%s] is outside the grammar", inner)
67 }
68 name := inner
69 if k := strings.IndexAny(inner, "~|^$*="); k >= 0 {
70 name = inner[:k]
71 }
72 if name != strings.ToLower(name) {
73 return fmt.Errorf("the attribute name %q is not lower case", name)
74 }
75 i = end
76 case '.':
77 if m := identifier.FindString(sel[i+1:]); m != "" && m != strings.ToLower(m) {
78 return fmt.Errorf("the class name %q is not lower case", m)
79 }
80 }
81 }
82 return nil
83}
84
85// assertPlainSpelling holds every selector of css to plainSpelling, so
86// a rule spelt to slip past a gate's reading fails by name in the
87// spelling and coverage gates as well as in the grammar.
88func assertPlainSpelling(t *testing.T, name, css string) {
89 t.Helper()
90 for _, rule := range cssRules(t, css) {
91 for _, sel := range rule.selectors {
92 if strings.HasPrefix(sel, "@") {
93 if strings.ContainsRune(sel, '\\') {
94 t.Errorf("%s:%d: %q holds a CSS escape; write the name the escape spells", name, rule.line, sel)
95 }
96 continue
97 }
98 if err := plainSpelling(sel); err != nil {
99 t.Errorf("%s:%d: %q: %v", name, rule.line, sel, err)
100 }
101 }
102 }
103}
104
105// attrSel is one attribute selector: Op is "", "=" or "~=".
106type attrSel struct{ Name, Op, Value string }
107
108// pseudo is one pseudo-class: Arg is :nth-child()'s argument, List the
109// selectors inside :has() or :not().
110type pseudo struct {
111 Name, Arg string
112 List []complexSel
113}
114
115// compound is one compound selector. Classes and Attrs are sorted,
116// since their order within a compound means nothing and two spellings
117// of one selector must compare equal.
118type compound struct {
119 Type string
120 Classes []string
121 Attrs []attrSel
122 Pseudos []pseudo
123 Element string
124}
125
126// complexSel is one alternative: Combinators[i] joins Compounds[i] and
127// Compounds[i+1], " " for a descendant. Inside :has(), an argument is
128// relative, and its first combinator is the one before its first
129// compound, so it has as many combinators as compounds.
130type complexSel struct {
131 Compounds []compound
132 Combinators []string
133}
134
135// parseSelector reads one alternative of a selector list in the
136// grammar above, or says what in it is outside the grammar.
137func parseSelector(sel string) (complexSel, error) {
138 if err := plainSpelling(sel); err != nil {
139 return complexSel{}, fmt.Errorf("%s: %w", sel, err)
140 }
141 p := &selParser{s: strings.TrimSpace(sel)}
142 c, err := p.complex(false, false)
143 if err == nil && p.i < len(p.s) {
144 err = p.errorf("%q is outside the grammar", p.s[p.i:])
145 }
146 if err != nil {
147 return complexSel{}, fmt.Errorf("%s: %w", sel, err)
148 }
149 return c, nil
150}
151
152type selParser struct {
153 s string
154 i int
155}
156
157func (p *selParser) errorf(format string, args ...any) error { return fmt.Errorf(format, args...) }
158
159func (p *selParser) peek() byte {
160 if p.i < len(p.s) {
161 return p.s[p.i]
162 }
163 return 0
164}
165
166func (p *selParser) space() bool {
167 start := p.i
168 for p.i < len(p.s) && strings.IndexByte(" \t\n\r", p.s[p.i]) >= 0 {
169 p.i++
170 }
171 return p.i > start
172}
173
174// complex reads compounds and the combinators between them, up to the
175// end, a comma or a closing parenthesis. relative reads :has()'s
176// leading combinator; inNot allows *.
177func (p *selParser) complex(relative, inNot bool) (complexSel, error) {
178 var c complexSel
179 p.space()
180 if relative {
181 comb := " "
182 if b := p.peek(); b == '>' || b == '+' {
183 comb = string(b)
184 p.i++
185 p.space()
186 }
187 c.Combinators = append(c.Combinators, comb)
188 }
189 for {
190 cp, err := p.compound(inNot)
191 if err != nil {
192 return complexSel{}, err
193 }
194 c.Compounds = append(c.Compounds, cp)
195 spaced := p.space()
196 switch b := p.peek(); {
197 case b == 0 || b == ',' || b == ')':
198 return c, nil
199 case b == '>' || b == '+':
200 p.i++
201 p.space()
202 c.Combinators = append(c.Combinators, string(b))
203 case b == '~':
204 return complexSel{}, p.errorf("the ~ combinator is outside the grammar")
205 case spaced:
206 c.Combinators = append(c.Combinators, " ")
207 default:
208 return complexSel{}, p.errorf("%q is outside the grammar", p.s[p.i:])
209 }
210 if cp.Element != "" {
211 return complexSel{}, p.errorf("::%s must end the selector", cp.Element)
212 }
213 }
214}
215
216// identifier is a CSS name as these files write one.
217var identifier = regexp.MustCompile(`^-?[A-Za-z_][A-Za-z0-9_-]*`)
218
219func (p *selParser) ident() string {
220 m := identifier.FindString(p.s[p.i:])
221 p.i += len(m)
222 return m
223}
224
225// nthArg is the An+B forms :nth-child() takes here; "of S" is not.
226var nthArg = regexp.MustCompile(`^(odd|even|[+-]?[0-9]*n(\s*[+-]\s*[0-9]+)?|[+-]?[0-9]+)$`)
227
228func (p *selParser) compound(inNot bool) (compound, error) {
229 var c compound
230 start := p.i
231 switch b := p.peek(); {
232 case b == '*':
233 if !inNot {
234 return c, p.errorf("`*` is outside the grammar except inside a :not() argument")
235 }
236 p.i++
237 c.Type = "*"
238 case identifier.MatchString(p.s[p.i:]):
239 c.Type = strings.ToLower(p.ident())
240 }
241 for {
242 if c.Element != "" && strings.IndexByte(".[:#", p.peek()) >= 0 {
243 return c, p.errorf("::%s must end its compound", c.Element)
244 }
245 switch p.peek() {
246 case '.':
247 p.i++
248 name := p.ident()
249 if name == "" {
250 return c, p.errorf("a . with no class after it")
251 }
252 c.Classes = append(c.Classes, name)
253 case '[':
254 a, err := p.attr()
255 if err != nil {
256 return c, err
257 }
258 c.Attrs = append(c.Attrs, a)
259 case '#':
260 return c, p.errorf("an id selector is outside the grammar")
261 case ':':
262 if strings.HasPrefix(p.s[p.i:], "::") {
263 p.i += 2
264 name := p.ident()
265 if name != "before" && name != "after" {
266 return c, p.errorf("`::%s` is outside the grammar", name)
267 }
268 c.Element = name
269 continue
270 }
271 p.i++
272 ps, err := p.pseudo(inNot)
273 if err != nil {
274 return c, err
275 }
276 c.Pseudos = append(c.Pseudos, ps)
277 default:
278 if p.i == start {
279 return c, p.errorf("an empty compound before %q", p.s[p.i:])
280 }
281 sort.Strings(c.Classes)
282 sort.Slice(c.Attrs, func(i, j int) bool {
283 a, b := c.Attrs[i], c.Attrs[j]
284 return a.Name+"\x00"+a.Op+"\x00"+a.Value < b.Name+"\x00"+b.Op+"\x00"+b.Value
285 })
286 return c, nil
287 }
288 }
289}
290
291func (p *selParser) attr() (attrSel, error) {
292 p.i++ // [
293 p.space()
294 a := attrSel{Name: p.ident()}
295 if a.Name == "" {
296 return a, p.errorf("an attribute selector with no name")
297 }
298 p.space()
299 switch {
300 case strings.HasPrefix(p.s[p.i:], "~="):
301 a.Op, p.i = "~=", p.i+2
302 case strings.HasPrefix(p.s[p.i:], "="):
303 a.Op, p.i = "=", p.i+1
304 case p.peek() == ']':
305 p.i++
306 return a, nil
307 default:
308 return a, p.errorf("the attribute operator in %q is outside the grammar", p.s[p.i:])
309 }
310 p.space()
311 if q := p.peek(); q == '"' || q == '\'' {
312 end := strings.IndexByte(p.s[p.i+1:], q)
313 if end < 0 {
314 return a, p.errorf("an unclosed string in an attribute selector")
315 }
316 a.Value = p.s[p.i+1 : p.i+1+end]
317 p.i += end + 2
318 } else {
319 a.Value = p.ident()
320 }
321 p.space()
322 if p.peek() != ']' {
323 return a, p.errorf("%q in an attribute selector is outside the grammar", p.s[p.i:])
324 }
325 p.i++
326 // ~= takes one token: an empty or spaced value matches nothing at
327 // all, so the selector could only be a mistake.
328 if a.Op == "~=" && (a.Value == "" || len(strings.Fields(a.Value)) != 1 || strings.TrimSpace(a.Value) != a.Value) {
329 return a, p.errorf("[%s~=%q] matches nothing", a.Name, a.Value)
330 }
331 return a, nil
332}
333
334func (p *selParser) pseudo(inNot bool) (pseudo, error) {
335 ps := pseudo{Name: strings.ToLower(p.ident())}
336 switch ps.Name {
337 case "hover", "active", "focus-visible", "disabled", "empty", "first-child", "last-child":
338 if p.peek() == '(' {
339 return ps, p.errorf("`:%s(` is outside the grammar", ps.Name)
340 }
341 return ps, nil
342 case "nth-child":
343 if p.peek() != '(' {
344 return ps, p.errorf(":nth-child with no argument")
345 }
346 end := strings.IndexByte(p.s[p.i:], ')')
347 if end < 0 {
348 return ps, p.errorf("an unclosed :nth-child(")
349 }
350 ps.Arg = collapseSpace(p.s[p.i+1 : p.i+end])
351 if !nthArg.MatchString(ps.Arg) {
352 return ps, p.errorf(":nth-child(%s) is outside the grammar", ps.Arg)
353 }
354 p.i += end + 1
355 return ps, nil
356 case "has", "not":
357 if p.peek() != '(' {
358 return ps, p.errorf(":%s with no argument", ps.Name)
359 }
360 p.i++
361 for {
362 c, err := p.complex(ps.Name == "has", inNot || ps.Name == "not")
363 if err != nil {
364 return ps, err
365 }
366 ps.List = append(ps.List, c)
367 p.space()
368 if p.peek() == ',' {
369 p.i++
370 continue
371 }
372 if p.peek() != ')' {
373 return ps, p.errorf("an unclosed :%s(", ps.Name)
374 }
375 p.i++
376 return ps, nil
377 }
378 default:
379 if p.peek() == '(' {
380 return ps, p.errorf("`:%s(` is outside the grammar", ps.Name)
381 }
382 return ps, p.errorf("`:%s` is outside the grammar", ps.Name)
383 }
384}
385
386// requiresNewMarkup reports whether sel can only match markup that
387// carries a name baseline does not have, and says why not when it
388// cannot. Some compound must positively require such a name, as one
389// of its own classes or attribute selectors, or inside a :has() every
390// one of whose alternatives does. Names inside :not() never count: a
391// :not() only narrows, and a compound that needs nothing new narrowed
392// by one still matches 1.0 markup. Only rst- names count: any other
393// attribute or class is one a 1.0 page may carry for its own reasons.
394//
395// The alternative as a whole is enough, since every compound of it
396// must match some element: one compound that no 1.0 page can carry
397// keeps the whole selector off 1.0 pages.
398func requiresNewMarkup(sel string, baseline map[string]bool) (bool, string) {
399 c, err := parseSelector(sel)
400 if err != nil {
401 return false, err.Error()
402 }
403 var partial []string
404 if requiresNew(c, baseline, &partial) {
405 return true, ""
406 }
407 why := sel + ": no compound positively requires a name 1.0 does not style"
408 if strings.Contains(sel, ":not(") {
409 why += " (names inside :not() never count)"
410 }
411 if len(partial) > 0 {
412 why += "; " + strings.Join(partial, "; ")
413 }
414 return false, why
415}
416
417// requiresNew is requiresNewMarkup over a parsed alternative; partial
418// collects each :has() alternative that let its list down, for the
419// reason.
420func requiresNew(c complexSel, baseline map[string]bool, partial *[]string) bool {
421 for _, cp := range c.Compounds {
422 for _, class := range cp.Classes {
423 if strings.HasPrefix(class, "rst-") && newClass(class, baseline) {
424 return true
425 }
426 }
427 for _, a := range cp.Attrs {
428 if strings.HasPrefix(a.Name, "rst-") && newAttr(a, baseline) {
429 return true
430 }
431 }
432 for _, ps := range cp.Pseudos {
433 if ps.Name != "has" {
434 continue
435 }
436 all := true
437 for _, alt := range ps.List {
438 if !requiresNew(alt, baseline, partial) {
439 all = false
440 *partial = append(*partial, fmt.Sprintf("in :has() the alternative %q matches 1.0 markup", relativeText(alt)))
441 }
442 }
443 if all {
444 return true
445 }
446 }
447 }
448 return false
449}
450
451// newAttr reports whether an attribute selector needs markup 1.0 never
452// wrote, read by what it matches rather than how it is spelt: a bare
453// [a] and an empty [a=""] need only the attribute, which <main rst-page>
454// carries; a value needs every one of its tokens, so [a="v"] is new
455// exactly when [a~="v"] is.
456func newAttr(a attrSel, baseline map[string]bool) bool {
457 tokens := strings.Fields(a.Value)
458 if a.Op == "" || len(tokens) == 0 {
459 return !baseline[a.Name]
460 }
461 for _, token := range tokens {
462 if !baseline[a.Name+"~="+token] {
463 return true
464 }
465 }
466 return false
467}
468
469// newClass reports whether a class needs markup 1.0 never wrote. The
470// class is read as the attribute it spells (markup.Attribute), so
471// .rst-job and [rst-job] are one name, and a 1.0 page that writes
472// either carries it.
473func newClass(class string, baseline map[string]bool) bool {
474 if baseline[class] || baseline["."+class] {
475 return false
476 }
477 name, variant, ok := markup.Attribute(class)
478 switch {
479 case !ok:
480 return true
481 case variant == "":
482 return !baseline[name]
483 }
484 return !baseline[name+"~="+variant]
485}
486
487// relativeText writes a parsed alternative back as a selector, for a
488// reason a reader can find in the CSS.
489func relativeText(c complexSel) string {
490 var b strings.Builder
491 lead := len(c.Combinators) == len(c.Compounds)
492 for i, cp := range c.Compounds {
493 comb := ""
494 switch {
495 case lead:
496 comb = c.Combinators[i]
497 case i > 0:
498 comb = c.Combinators[i-1]
499 }
500 switch {
501 case comb == " " && i > 0:
502 b.WriteString(" ")
503 case comb != "" && comb != " ":
504 if i > 0 {
505 b.WriteString(" ")
506 }
507 b.WriteString(comb + " ")
508 }
509 b.WriteString(cp.Type)
510 for _, class := range cp.Classes {
511 b.WriteString("." + class)
512 }
513 for _, a := range cp.Attrs {
514 if a.Op == "" {
515 b.WriteString("[" + a.Name + "]")
516 } else {
517 b.WriteString("[" + a.Name + a.Op + `"` + a.Value + `"]`)
518 }
519 }
520 for _, ps := range cp.Pseudos {
521 b.WriteString(":" + ps.Name)
522 switch {
523 case ps.Arg != "":
524 b.WriteString("(" + ps.Arg + ")")
525 case ps.List != nil:
526 var args []string
527 for _, alt := range ps.List {
528 args = append(args, relativeText(alt))
529 }
530 b.WriteString("(" + strings.Join(args, ", ") + ")")
531 }
532 }
533 if cp.Element != "" {
534 b.WriteString("::" + cp.Element)
535 }
536 }
537 return b.String()
538}
539
540// TestOnlyNewMarkupPassesTheGrammar holds requiresNewMarkup to the
541// cases that fooled a check by mention: a new name under :not() or in
542// one branch of :is() matches every 1.0 page, and a :has() list passes
543// only if every alternative needs a new name. Each refusal's reason
544// must say what failed, since the gate's failure message is that
545// reason and a bare "false" sends the reader through the grammar.
546func TestOnlyNewMarkupPassesTheGrammar(t *testing.T) {
547 baseline := map[string]bool{"rst-page": true, "rst-row": true, "rst-status": true, "rst-dropdown": true, "rst-seg-tabs": true}
548 for _, c := range []struct {
549 sel string
550 want bool
551 reason string // what a refusal's reason must name
552 }{
553 {".rst-page:not(.rst-snippet)", false, "positively requires"},
554 {"[rst-page]:not([rst-snippet])", false, "positively requires"},
555 {":is([rst-page], [rst-snippet])", false, ":is("},
556 {":where([rst-snippet])", false, ":where("},
557 {"[rst-page]:not([rst-seg-tabs] > button)", false, "positively requires"},
558 {"* [rst-snippet]", false, "`*`"},
559 {"[rst-page]:has(> [rst-snippet], > [rst-row])", false, "> [rst-row]"},
560 {`[rst-page=""]`, false, "positively requires"},
561 {`[rst-page=" "]`, false, "whitespace"},
562 {"[ rst-page]", false, "whitespace"},
563 {"[rst-page ]", false, "whitespace"},
564 {"[rst-PAGE]", false, "lower case"},
565 {".rst-PAGE", false, "lower case"},
566 {`[rst-\70 age]`, false, "escape"},
567 {`.rst-\73nippet`, false, "escape"},
568 {`[rst-dropdown~=""]`, false, "matches nothing"},
569 {`.rst-page[rst-row]`, false, "positively requires"},
570 {"[rst-page] button:disabled", false, "positively requires"},
571 {"[rst-snippet]", true, ""},
572 {"[rst-snippet] > button:disabled", true, ""},
573 {"[rst-page]:empty", false, "positively requires"},
574 {"[rst-snippet-status]:empty", true, ""},
575 {"[rst-page-foot]:not(:has(> *))", true, ""},
576 {`[rst-row]:has(> [rst-status~="inline"]) > [rst-status]`, true, ""},
577 {"[rst-page-foot] [rst-dropdown]", true, ""},
578 {".rst-stats--pairs > .rst-stat:nth-child(odd)", true, ""},
579 } {
580 got, why := requiresNewMarkup(c.sel, baseline)
581 if got != c.want {
582 t.Errorf("requiresNewMarkup(%q) = %v (%s), want %v", c.sel, got, why, c.want)
583 continue
584 }
585 if !c.want && !strings.Contains(why, c.reason) {
586 t.Errorf("requiresNewMarkup(%q) refused it saying %q, which does not name %q", c.sel, why, c.reason)
587 }
588 }
589}
590