| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "amadan.net/rastrillo/fiesta" |
| 10 | "amadan.net/rastrillo/fiesta/example" |
| 11 | "amadan.net/rastrillo/fiesta/internal/markup" |
| 12 | ) |
| 13 | |
| 14 | // ── Reading the examples by name ───────────────────────────────────── |
| 15 | |
| 16 | // manifest reads examples/examples.json from the embedded tree. |
| 17 | func manifest(t *testing.T) *example.Manifest { |
| 18 | t.Helper() |
| 19 | m, err := example.Load(fiesta.FS()) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | return m |
| 24 | } |
| 25 | |
| 26 | // idiomMarkup reads the example state the manifest's idiom table names |
| 27 | // for key, so a test written against rastrillo's Styleguide()[key] |
| 28 | // reads the same markup here. |
| 29 | func idiomMarkup(t *testing.T, key string) string { |
| 30 | t.Helper() |
| 31 | name, state, ok := manifest(t).Idiom(key) |
| 32 | if !ok { |
| 33 | t.Fatalf("examples.json names no idiom %q", key) |
| 34 | } |
| 35 | markup, err := example.Markup(fiesta.FS(), name, state) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | return markup |
| 40 | } |
| 41 | |
| 42 | // dropdownSample is the dropdown idiom's menu alone, out of whatever |
| 43 | // row its example shows it in: the drives that box a dropdown on its |
| 44 | // own and the checks on its <details> want the component, and a row |
| 45 | // around it would put a heading and a toolbar in their boxes too. |
| 46 | func dropdownSample(t *testing.T) string { |
| 47 | t.Helper() |
| 48 | menu, err := example.Element(idiomMarkup(t, "dropdown"), "rst-dropdown") |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | return menu |
| 53 | } |
| 54 | |
| 55 | // defaultMarkup reads the state examples.json names as the default of |
| 56 | // the example called name: the whole document for a layout, the body |
| 57 | // for anything else. |
| 58 | func defaultMarkup(t *testing.T, name string) string { |
| 59 | t.Helper() |
| 60 | m := manifest(t) |
| 61 | e, ok := m.Entry(name) |
| 62 | if !ok { |
| 63 | t.Fatalf("examples.json has no example %q", name) |
| 64 | } |
| 65 | state, ok := m.Default(name) |
| 66 | if !ok || state == "" { |
| 67 | t.Fatalf("examples.json names no default state for %q", name) |
| 68 | } |
| 69 | read := example.Markup |
| 70 | if e.Document { |
| 71 | read = example.Document |
| 72 | } |
| 73 | out, err := read(fiesta.FS(), name, state) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | return out |
| 78 | } |
| 79 | |
| 80 | // ── The spelling grammar's helpers ─────────────────────────────────── |
| 81 | |
| 82 | // What classSpelling (rig_browser_test.go) and the coverage gates |
| 83 | // (coverage_test.go) reach in rastrillo's untagged ui/markup_v3_test.go |
| 84 | // and ui/ui_test.go, verbatim: the class-to-attribute lookup, the |
| 85 | // regexes that find a class in a selector and in markup and an rst- |
| 86 | // attribute in an open tag, and the comment stripper. The CSS reader |
| 87 | // beside them (parseCSSRules, splitSelectorList, specificity) is in |
| 88 | // spelling_test.go with the gate that uses it. |
| 89 | |
| 90 | func attributeFor(class string) (name, variant string, ok bool) { return markup.Attribute(class) } |
| 91 | |
| 92 | var ( |
| 93 | classInMarkup = regexp.MustCompile(`class="([^"]*)"`) |
| 94 | openTagInFixture = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| 95 | rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`) |
| 96 | ) |
| 97 | |
| 98 | // stripCSSComments is cssWithoutComments failing the test on a |
| 99 | // stylesheet it refuses. |
| 100 | func stripCSSComments(t testing.TB, css string) string { |
| 101 | t.Helper() |
| 102 | out, err := cssWithoutComments(css) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | return out |
| 107 | } |
| 108 | |
| 109 | // cssWithoutComments is css with its comments taken out, found the way |
| 110 | // a browser finds them. A reader that takes every /* for a comment can |
| 111 | // be made to skip a rule a browser applies: between content: "/*" and |
| 112 | // content: "*/" a whole rule drops out of the reading and still styles |
| 113 | // every page. See cssSpan for what it refuses. |
| 114 | // |
| 115 | // It refuses brackets that do not nest, with checkCSSBrackets, so no |
| 116 | // reader of what it returns has to guess where a bracket ends. |
| 117 | func cssWithoutComments(css string) (string, error) { |
| 118 | if err := checkCSSBrackets(css); err != nil { |
| 119 | return "", err |
| 120 | } |
| 121 | var b strings.Builder |
| 122 | for i := 0; i < len(css); { |
| 123 | end, comment, err := cssSpan(css, i) |
| 124 | if err != nil { |
| 125 | return "", err |
| 126 | } |
| 127 | if !comment { |
| 128 | b.WriteString(css[i:end]) |
| 129 | } |
| 130 | i = end |
| 131 | } |
| 132 | return b.String(), nil |
| 133 | } |
| 134 | |
| 135 | // checkCSSBrackets refuses css whose brackets, read outside strings and |
| 136 | // comments with cssSpan, do not nest: a (, [ or { must be closed, by |
| 137 | // its own partner, before the one around it is. |
| 138 | // |
| 139 | // A browser and a reader that counts brackets disagree about anything |
| 140 | // else. A ) that closes nothing ends nothing for a browser, so in |
| 141 | // --rst-x: ); color: red the ; ends the declaration and color: red |
| 142 | // applies, while a count that went below zero read the rest of the |
| 143 | // block as one custom property. A ( never closed swallows the rest of |
| 144 | // the block for a browser, and only for a browser. |
| 145 | // |
| 146 | // A { inside ( or [ is refused too. A browser reads it as a block |
| 147 | // inside the value or the prelude, and a reader that takes every { for |
| 148 | // a rule would start one there. No stylesheet needs one. |
| 149 | func checkCSSBrackets(css string) error { |
| 150 | var open []int |
| 151 | for i := 0; i < len(css); { |
| 152 | end, comment, err := cssSpan(css, i) |
| 153 | if err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if !comment { |
| 157 | if open, err = stepCSSBracket(css, i, open); err != nil { |
| 158 | return err |
| 159 | } |
| 160 | } |
| 161 | i = end |
| 162 | } |
| 163 | if len(open) > 0 { |
| 164 | return unclosedCSSBracket(css, open) |
| 165 | } |
| 166 | return nil |
| 167 | } |
| 168 | |
| 169 | // braceMatchEnd is the index of the bracket that closes the one at |
| 170 | // css[start-1], a (, [ or {, read the way checkCSSBrackets reads them: |
| 171 | // a bracket inside a string or a comment is text, and one that closes |
| 172 | // the wrong bracket, or none, is an error. Counting braces alone, a |
| 173 | // "{" in one rule and a "}" in a later one paired up, and the block |
| 174 | // ran on past its end over every rule between. |
| 175 | func braceMatchEnd(css string, start int) (int, error) { |
| 176 | open := []int{start - 1} |
| 177 | for i := start; i < len(css); { |
| 178 | end, comment, err := cssSpan(css, i) |
| 179 | if err != nil { |
| 180 | return 0, err |
| 181 | } |
| 182 | if !comment { |
| 183 | if open, err = stepCSSBracket(css, i, open); err != nil { |
| 184 | return 0, err |
| 185 | } |
| 186 | if len(open) == 0 { |
| 187 | return i, nil |
| 188 | } |
| 189 | } |
| 190 | i = end |
| 191 | } |
| 192 | return 0, unclosedCSSBracket(css, open) |
| 193 | } |
| 194 | |
| 195 | // cssPartner is the opening bracket each closing one closes. |
| 196 | var cssPartner = map[byte]byte{')': '(', ']': '[', '}': '{'} |
| 197 | |
| 198 | // stepCSSBracket reads the piece of css at i, which cssSpan has found |
| 199 | // is not a comment, against open, the indexes of the brackets open |
| 200 | // there, innermost last. A string or a name starts with no bracket, so |
| 201 | // only a one-byte piece can be one. |
| 202 | func stepCSSBracket(css string, i int, open []int) ([]int, error) { |
| 203 | line := func(at int) int { return 1 + strings.Count(css[:at], "\n") } |
| 204 | switch c := css[i]; c { |
| 205 | case '(', '[', '{': |
| 206 | if n := len(open); c == '{' && n > 0 && css[open[n-1]] != '{' { |
| 207 | return nil, fmt.Errorf("line %d: a { opens inside the %c opened on line %d; close the %c before it", line(i), css[open[n-1]], line(open[n-1]), css[open[n-1]]) |
| 208 | } |
| 209 | return append(open, i), nil |
| 210 | case ')', ']', '}': |
| 211 | n := len(open) |
| 212 | if n == 0 { |
| 213 | return nil, fmt.Errorf("line %d: a %c closes nothing", line(i), c) |
| 214 | } |
| 215 | if css[open[n-1]] != cssPartner[c] { |
| 216 | return nil, fmt.Errorf("line %d: a %c closes the %c opened on line %d", line(i), c, css[open[n-1]], line(open[n-1])) |
| 217 | } |
| 218 | return open[:n-1], nil |
| 219 | } |
| 220 | return open, nil |
| 221 | } |
| 222 | |
| 223 | // unclosedCSSBracket names the innermost bracket open at the end. |
| 224 | func unclosedCSSBracket(css string, open []int) error { |
| 225 | at := open[len(open)-1] |
| 226 | return fmt.Errorf("line %d: a %c is never closed", 1+strings.Count(css[:at], "\n"), css[at]) |
| 227 | } |
| 228 | |
| 229 | // commentAt is the offset of the first comment in css that starts |
| 230 | // with prefix, found with cssSpan, or -1. A heading comment marks |
| 231 | // where a section of a stylesheet starts; found by searching the text, |
| 232 | // the same words inside a string would move the cut. |
| 233 | func commentAt(t testing.TB, css, prefix string) int { |
| 234 | t.Helper() |
| 235 | for i := 0; i < len(css); { |
| 236 | end, comment, err := cssSpan(css, i) |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if comment && strings.HasPrefix(css[i:end], prefix) { |
| 241 | return i |
| 242 | } |
| 243 | i = end |
| 244 | } |
| 245 | return -1 |
| 246 | } |
| 247 | |
| 248 | // selectorClasses is every class sel names, read with cssSpan, so a |
| 249 | // .rst- inside a quoted attribute value is not one. |
| 250 | func selectorClasses(sel string) []string { |
| 251 | var out []string |
| 252 | for i := 0; i < len(sel); { |
| 253 | end, _, err := cssSpan(sel, i) |
| 254 | if err != nil { |
| 255 | return out |
| 256 | } |
| 257 | if sel[i] == '.' && end < len(sel) && isCSSNameByte(sel[end]) { |
| 258 | name, _, _ := cssSpan(sel, end) |
| 259 | out = append(out, sel[end:name]) |
| 260 | end = name |
| 261 | } |
| 262 | i = end |
| 263 | } |
| 264 | return out |
| 265 | } |
| 266 | |
| 267 | // selectorAttributes is every attribute selector sel holds, each from |
| 268 | // its [ to the ] that closes it, read with cssSpan and braceMatchEnd, |
| 269 | // so a [ or ] inside a quoted value neither starts nor ends one. |
| 270 | func selectorAttributes(sel string) []string { |
| 271 | var out []string |
| 272 | for i := 0; i < len(sel); { |
| 273 | end, _, err := cssSpan(sel, i) |
| 274 | if err != nil { |
| 275 | return out |
| 276 | } |
| 277 | if sel[i] == '[' { |
| 278 | close, err := braceMatchEnd(sel, i+1) |
| 279 | if err != nil { |
| 280 | return out |
| 281 | } |
| 282 | out = append(out, sel[i:close+1]) |
| 283 | end = close + 1 |
| 284 | } |
| 285 | i = end |
| 286 | } |
| 287 | return out |
| 288 | } |
| 289 | |
| 290 | // cssSpan reads the piece of css that starts at i, as a browser's |
| 291 | // tokenizer would, for the readers that need to know what is a comment: |
| 292 | // a comment, a whole string, a run of name characters, or else one |
| 293 | // byte. It returns where the piece ends and whether it is a comment. A |
| 294 | // /* inside a string opens no comment, and a quote inside a comment |
| 295 | // opens no string. |
| 296 | // |
| 297 | // What a browser reads in a way these readers would have to guess at |
| 298 | // is refused instead. A string cut off by a newline or the end of the |
| 299 | // file is dropped by a browser along with its declaration; a reader |
| 300 | // would have to guess where it ends. A comment never closed swallows |
| 301 | // the rest of the file, and one with no space either side has no |
| 302 | // spelling without it that means the same (see below). An unquoted |
| 303 | // url() holds neither a string nor a comment, so url(/*) opens none; |
| 304 | // reading that would mean reading url() as a browser does, and quoting |
| 305 | // the address costs nothing. |
| 306 | // |
| 307 | // A backslash outside a string is refused whatever follows it. It |
| 308 | // escapes a character a browser then reads as part of a name, so \url( |
| 309 | // and u\72 l( are url(, \" opens no string and \/* no comment. Each |
| 310 | // escape form a reader missed has hidden a rule, and no stylesheet |
| 311 | // needs one: the character can be written plainly. Inside a string a |
| 312 | // backslash is read as a browser reads it. |
| 313 | func cssSpan(css string, i int) (end int, comment bool, err error) { |
| 314 | line := func() int { return 1 + strings.Count(css[:i], "\n") } |
| 315 | switch c := css[i]; { |
| 316 | case strings.HasPrefix(css[i:], "/*"): |
| 317 | k := strings.Index(css[i+2:], "*/") |
| 318 | if k < 0 { |
| 319 | return 0, false, fmt.Errorf("line %d: a comment is never closed", line()) |
| 320 | } |
| 321 | end := i + 2 + k + 2 |
| 322 | // A comment keeps the tokens either side apart without being |
| 323 | // whitespace, so dropping it joins them and putting a space in |
| 324 | // its place adds a combinator: 1/**/2px is not 12px, and |
| 325 | // .a/**/.b is not .a .b. |
| 326 | if i > 0 && end < len(css) && !isCSSSpace(css[i-1]) && !isCSSSpace(css[end]) { |
| 327 | return 0, false, fmt.Errorf("line %d: a comment sits between two tokens with no space beside it; add one", line()) |
| 328 | } |
| 329 | return end, true, nil |
| 330 | case c == '"' || c == '\'': |
| 331 | for j := i + 1; j < len(css); j++ { |
| 332 | switch css[j] { |
| 333 | case c: |
| 334 | return j + 1, false, nil |
| 335 | case '\\': |
| 336 | // An escaped newline continues the string; \r\n is |
| 337 | // one newline. |
| 338 | if strings.HasPrefix(css[j+1:], "\r\n") { |
| 339 | j++ |
| 340 | } |
| 341 | j++ |
| 342 | case '\n', '\r', '\f': |
| 343 | return 0, false, fmt.Errorf("line %d: a string is cut off by the end of its line", line()) |
| 344 | } |
| 345 | } |
| 346 | return 0, false, fmt.Errorf("line %d: a string is cut off by the end of the file", line()) |
| 347 | case c == '\\': |
| 348 | return 0, false, fmt.Errorf("line %d: a backslash outside a string is not read here; write the character it escapes plainly", line()) |
| 349 | case isCSSNameByte(c): |
| 350 | j := i |
| 351 | for j < len(css) && isCSSNameByte(css[j]) { |
| 352 | j++ |
| 353 | } |
| 354 | if j == len(css) || css[j] != '(' { |
| 355 | return j, false, nil |
| 356 | } |
| 357 | if strings.EqualFold(css[i:j], "url") { |
| 358 | if rest := strings.TrimLeft(css[j+1:], " \t\n\r\f"); rest == "" || (rest[0] != '"' && rest[0] != '\'') { |
| 359 | return 0, false, fmt.Errorf("line %d: an unquoted url( is not read here; quote its address", line()) |
| 360 | } |
| 361 | } |
| 362 | return j, false, nil |
| 363 | } |
| 364 | return i + 1, false, nil |
| 365 | } |
| 366 | |
| 367 | func isCSSSpace(c byte) bool { return strings.IndexByte(" \t\n\r\f", c) >= 0 } |
| 368 | |
| 369 | // isCSSNameByte is a byte that can continue a CSS name: a letter, a |
| 370 | // digit, - or _, or any byte of a non-ASCII character. |
| 371 | func isCSSNameByte(c byte) bool { |
| 372 | return c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80 |
| 373 | } |
| 374 | |