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 "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.
17func 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.
29func 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.
46func 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.
58func 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
90func attributeFor(class string) (name, variant string, ok bool) { return markup.Attribute(class) }
91
92var (
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.
100func 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.
117func 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.
149func 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.
175func 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.
196var 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.
202func 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.
224func 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.
233func 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.
250func 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.
270func 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.
313func 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
367func 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.
371func isCSSNameByte(c byte) bool {
372 return c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80
373}
374