package markup
import (
"bytes"
"strings"
"testing"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// sqliteA7f5 is the description of sqlite ticket a7f5b0856f, byte for
// byte: CRLF line endings, and a fence opened on a quote line ("> ~~~~")
// whose body and closing fence carry no ">" at all. Fossil keeps all of
// it inside the quote.
const sqliteA7f5 = "As reported by [forum post 2ed79a01ae](forum:/forumpost/2ed79a01ae), the\r\n" +
"query planner is missing an opportunity to optimization WHERE clause\r\n" +
"expressions of the form:\r\n\r\n" +
"> constant IN (indexed-column-1, indexed-column-2)\r\n\r\n" +
"The script below illustrates the missed optimization opportunity.\r\n" +
"The first query (using the IN operator) runs about 180 times more\r\n" +
"opcodes to compute the answer than does the second query where the\r\n" +
"optimization is implemented manually.\r\n\r\n" +
"> ~~~~\r\n" + sqliteA7f5Script + "~~~~\r\n\r\n" +
"The desired result is that both queries should generate the same\r\n" +
"query plan and perform equally as well."
const sqliteA7f5Script = ".echo on\r\n.mode qbox\r\nCREATE TABLE edge(\r\n src INT,\r\n dst INT,\r\n" +
" PRIMARY KEY(src, dst)\r\n) WITHOUT ROWID;\r\n" +
"WITH RECURSIVE c(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM c WHERE x<1000)\r\n" +
" INSERT INTO edge SELECT x, x+1000 FROM c;\r\nCREATE INDEX edge_x1 ON edge(dst,src);\r\n" +
".parameter set :vertex 1234\r\n.stat vmstep\r\n.eqp on\r\n" +
"SELECT * FROM edge WHERE :vertex IN (src,dst);\r\n" +
"SELECT * FROM edge WHERE :vertex=src OR :vertex=dst;\r\n"
func parseBody(t *testing.T, src string) *html.Node {
t.Helper()
out, err := Render([]byte(src), nil)
if err != nil {
t.Fatal(err)
}
doc, err := html.Parse(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
return doc
}
func textOf(n *html.Node) string {
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return b.String()
}
func findAll(n *html.Node, a atom.Atom) []*html.Node {
var got []*html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.DataAtom == a {
got = append(got, n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return got
}
func insideA(n *html.Node, a atom.Atom) bool {
for p := n.Parent; p != nil; p = p.Parent {
if p.Type == html.ElementNode && p.DataAtom == a {
return true
}
}
return false
}
// A fence opened on a quote line runs to its closing fence inside the
// quote, as fossil's markdown has it: a quote continues over every
// following line until a blank line.
func TestQuotedFenceWithoutMarkersStaysQuoted(t *testing.T) {
doc := parseBody(t, sqliteA7f5)
quotes := findAll(doc, atom.Blockquote)
if len(quotes) != 2 {
t.Fatalf("blockquotes = %d, want 2", len(quotes))
}
for i, q := range quotes {
if strings.TrimSpace(textOf(q)) == "" {
t.Errorf("blockquote %d is empty", i)
}
}
codes := findAll(quotes[1], atom.Code)
if len(codes) != 1 {
t.Fatalf("code blocks in second quote = %d, want 1", len(codes))
}
want := strings.ReplaceAll(sqliteA7f5Script, "\r\n", "\n")
if got := strings.ReplaceAll(textOf(codes[0]), "\r\n", "\n"); got != want {
t.Errorf("quoted code = %q, want %q", got, want)
}
for _, pre := range findAll(doc, atom.Pre) {
if !insideA(pre, atom.Blockquote) {
t.Errorf("code block outside any quote: %q", textOf(pre))
}
}
ps := findAll(doc, atom.P)
if len(ps) != 3 {
t.Errorf("paragraphs = %d, want 3", len(ps))
}
}
func TestQuoteContinuation(t *testing.T) {
cases := []struct {
name string
src string
// quotes is the number of blockquotes; quoted and unquoted are
// input lines that must, and must not, end up inside one.
quotes int
quoted []string
unquoted []string
}{
{"blank line ends the quote", "> ~~~\nin\n\nout\n", 1, []string{"in"}, []string{"out"}},
{"lazy paragraph line", "> first\nsecond\n\nafter\n", 1, []string{"second"}, []string{"after"}},
{"blank line inside a quoted fence does not end it", "> ~~~\nin\n\nstill\n~~~\n\nafter\n", 1, []string{"in", "still"}, []string{"after"}},
{"blank line between quote lines keeps one quote", "> one\n\n> two\n", 1, []string{"one", "two"}, nil},
{"quote marker inside a top-level fence is code", "~~~\n> not a quote\nplain\n~~~\n", 0, nil, []string{"plain"}},
{"quote nested in a list keeps the list", "- item\n > ~~~\n code\n ~~~\n\nafter\n", 1, []string{"code"}, []string{"after"}},
{"indented line is quoted with its indent", "> ~~~\n x = 1\n~~~\n", 1, []string{" x = 1"}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
doc := parseBody(t, c.src)
quotes := findAll(doc, atom.Blockquote)
if len(quotes) != c.quotes {
t.Fatalf("blockquotes = %d, want %d", len(quotes), c.quotes)
}
var in strings.Builder
for _, q := range quotes {
in.WriteString(textOf(q))
}
for _, s := range c.quoted {
if !strings.Contains(in.String(), s) {
t.Errorf("%q not inside a quote", s)
}
}
for _, s := range c.unquoted {
if strings.Contains(in.String(), s) {
t.Errorf("%q inside a quote", s)
}
if !strings.Contains(textOf(doc), s) {
t.Errorf("%q lost", s)
}
}
if strings.Contains(c.name, "list") {
li := findAll(doc, atom.Li)
if len(li) != 1 || len(findAll(li[0], atom.Blockquote)) != 1 {
t.Errorf("quote not inside the list item")
}
}
})
}
}