package check import ( "fmt" "regexp" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/markup" ) // ── Reading the examples by name ───────────────────────────────────── // manifest reads examples/examples.json from the embedded tree. func manifest(t *testing.T) *example.Manifest { t.Helper() m, err := example.Load(fiesta.FS()) if err != nil { t.Fatal(err) } return m } // idiomMarkup reads the example state the manifest's idiom table names // for key, so a test written against rastrillo's Styleguide()[key] // reads the same markup here. func idiomMarkup(t *testing.T, key string) string { t.Helper() name, state, ok := manifest(t).Idiom(key) if !ok { t.Fatalf("examples.json names no idiom %q", key) } markup, err := example.Markup(fiesta.FS(), name, state) if err != nil { t.Fatal(err) } return markup } // dropdownSample is the dropdown idiom's menu alone, out of whatever // row its example shows it in: the drives that box a dropdown on its // own and the checks on its
want the component, and a row // around it would put a heading and a toolbar in their boxes too. func dropdownSample(t *testing.T) string { t.Helper() menu, err := example.Element(idiomMarkup(t, "dropdown"), "rst-dropdown") if err != nil { t.Fatal(err) } return menu } // defaultMarkup reads the state examples.json names as the default of // the example called name: the whole document for a layout, the body // for anything else. func defaultMarkup(t *testing.T, name string) string { t.Helper() m := manifest(t) e, ok := m.Entry(name) if !ok { t.Fatalf("examples.json has no example %q", name) } state, ok := m.Default(name) if !ok || state == "" { t.Fatalf("examples.json names no default state for %q", name) } read := example.Markup if e.Document { read = example.Document } out, err := read(fiesta.FS(), name, state) if err != nil { t.Fatal(err) } return out } // ── The spelling grammar's helpers ─────────────────────────────────── // What classSpelling (rig_browser_test.go) and the coverage gates // (coverage_test.go) reach in rastrillo's untagged ui/markup_v3_test.go // and ui/ui_test.go, verbatim: the class-to-attribute lookup, the // regexes that find a class in a selector and in markup and an rst- // attribute in an open tag, and the comment stripper. The CSS reader // beside them (parseCSSRules, splitSelectorList, specificity) is in // spelling_test.go with the gate that uses it. func attributeFor(class string) (name, variant string, ok bool) { return markup.Attribute(class) } var ( classInMarkup = regexp.MustCompile(`class="([^"]*)"`) openTagInFixture = regexp.MustCompile(`<[a-zA-Z][^>]*>`) rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`) ) // 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. // // It refuses brackets that do not nest, with checkCSSBrackets, so no // reader of what it returns has to guess where a bracket ends. func cssWithoutComments(css string) (string, error) { if err := checkCSSBrackets(css); err != nil { return "", err } var b strings.Builder 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 } // checkCSSBrackets refuses css whose brackets, read outside strings and // comments with cssSpan, do not nest: a (, [ or { must be closed, by // its own partner, before the one around it is. // // A browser and a reader that counts brackets disagree about anything // else. A ) that closes nothing ends nothing for a browser, so in // --rst-x: ); color: red the ; ends the declaration and color: red // applies, while a count that went below zero read the rest of the // block as one custom property. A ( never closed swallows the rest of // the block for a browser, and only for a browser. // // A { inside ( or [ is refused too. A browser reads it as a block // inside the value or the prelude, and a reader that takes every { for // a rule would start one there. No stylesheet needs one. func checkCSSBrackets(css string) error { var open []int for i := 0; i < len(css); { end, comment, err := cssSpan(css, i) if err != nil { return err } if !comment { if open, err = stepCSSBracket(css, i, open); err != nil { return err } } i = end } if len(open) > 0 { return unclosedCSSBracket(css, open) } return nil } // braceMatchEnd is the index of the bracket that closes the one at // css[start-1], a (, [ or {, read the way checkCSSBrackets reads them: // a bracket inside a string or a comment is text, and one that closes // the wrong bracket, or none, is an error. Counting braces alone, a // "{" in one rule and a "}" in a later one paired up, and the block // ran on past its end over every rule between. func braceMatchEnd(css string, start int) (int, error) { open := []int{start - 1} for i := start; i < len(css); { end, comment, err := cssSpan(css, i) if err != nil { return 0, err } if !comment { if open, err = stepCSSBracket(css, i, open); err != nil { return 0, err } if len(open) == 0 { return i, nil } } i = end } return 0, unclosedCSSBracket(css, open) } // cssPartner is the opening bracket each closing one closes. var cssPartner = map[byte]byte{')': '(', ']': '[', '}': '{'} // stepCSSBracket reads the piece of css at i, which cssSpan has found // is not a comment, against open, the indexes of the brackets open // there, innermost last. A string or a name starts with no bracket, so // only a one-byte piece can be one. func stepCSSBracket(css string, i int, open []int) ([]int, error) { line := func(at int) int { return 1 + strings.Count(css[:at], "\n") } switch c := css[i]; c { case '(', '[', '{': if n := len(open); c == '{' && n > 0 && css[open[n-1]] != '{' { 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]]) } return append(open, i), nil case ')', ']', '}': n := len(open) if n == 0 { return nil, fmt.Errorf("line %d: a %c closes nothing", line(i), c) } if css[open[n-1]] != cssPartner[c] { 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])) } return open[:n-1], nil } return open, nil } // unclosedCSSBracket names the innermost bracket open at the end. func unclosedCSSBracket(css string, open []int) error { at := open[len(open)-1] return fmt.Errorf("line %d: a %c is never closed", 1+strings.Count(css[:at], "\n"), css[at]) } // commentAt is the offset of the first comment in css that starts // with prefix, found with cssSpan, or -1. A heading comment marks // where a section of a stylesheet starts; found by searching the text, // the same words inside a string would move the cut. func commentAt(t testing.TB, css, prefix string) int { t.Helper() for i := 0; i < len(css); { end, comment, err := cssSpan(css, i) if err != nil { t.Fatal(err) } if comment && strings.HasPrefix(css[i:end], prefix) { return i } i = end } return -1 } // selectorClasses is every class sel names, read with cssSpan, so a // .rst- inside a quoted attribute value is not one. func selectorClasses(sel string) []string { var out []string for i := 0; i < len(sel); { end, _, err := cssSpan(sel, i) if err != nil { return out } if sel[i] == '.' && end < len(sel) && isCSSNameByte(sel[end]) { name, _, _ := cssSpan(sel, end) out = append(out, sel[end:name]) end = name } i = end } return out } // selectorAttributes is every attribute selector sel holds, each from // its [ to the ] that closes it, read with cssSpan and braceMatchEnd, // so a [ or ] inside a quoted value neither starts nor ends one. func selectorAttributes(sel string) []string { var out []string for i := 0; i < len(sel); { end, _, err := cssSpan(sel, i) if err != nil { return out } if sel[i] == '[' { close, err := braceMatchEnd(sel, i+1) if err != nil { return out } out = append(out, sel[i:close+1]) end = close + 1 } i = end } return out } // 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 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 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]; { case strings.HasPrefix(css[i:], "/*"): k := strings.Index(css[i+2:], "*/") if k < 0 { 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 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 := i for j < len(css) && isCSSNameByte(css[j]) { j++ } if j == len(css) || css[j] != '(' { return j, false, nil } 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 }