diff --git a/internal/acceptance/acceptance.go b/internal/acceptance/acceptance.go index dd076b1f8..1abb7c039 100644 --- a/internal/acceptance/acceptance.go +++ b/internal/acceptance/acceptance.go @@ -31,44 +31,78 @@ type Section struct { } var ( - headingRe = regexp.MustCompile(`^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$`) - criterionRe = regexp.MustCompile(`^[ \t]{0,3}[-*+][ \t]+(AC-[0-9]+)[ \t]*:[ \t]*(.*)$`) - fenceRe = regexp.MustCompile("^[ \t]{0,3}(```|~~~)") + atxRe = regexp.MustCompile(`^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$`) + setextRe = regexp.MustCompile(`^ {0,3}(=+|-+)[ \t]*$`) + bulletRe = regexp.MustCompile(`^ {0,3}[-*+](?:[ \t]|$)`) + criterionRe = regexp.MustCompile(`^ ?[-*+][ \t]+(AC-[0-9]+)[ \t]*:[ \t]*(.*)$`) + fenceOpenRe = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})(.*)$") + closeHashRe = regexp.MustCompile(`(?:^|[ \t]+)#+$`) ) -// Parse returns the first acceptance-criteria section in body. -func Parse(body string) Section { - lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") - var out Section - level := 0 - start := -1 - end := len(lines) +// heading is one ATX or setext heading: it starts at line `at` and its body +// starts at line `body`. +type heading struct { + at, body, level int + text string +} + +// scan marks the lines inside fenced code blocks (fence lines included) and +// lists the headings outside them, following CommonMark's rules for both: a +// fence closes only on a run of its own character at least as long as the +// opener with nothing after it, and a setext underline makes a heading only +// of a paragraph line, never of a list item, a blank line, or an indented +// line (which continues a list item, so the underline is a thematic break). +func scan(lines []string) (fenced []bool, heads []heading) { + fenced = make([]bool, len(lines)) fence := "" for i, line := range lines { - if m := fenceRe.FindStringSubmatch(line); m != nil { - if fence == "" { - fence = m[1] - } else if m[1] == fence { + if fence != "" { + fenced[i] = true + t := strings.TrimRight(strings.TrimLeft(line, " "), " \t") + if len(line)-len(strings.TrimLeft(line, " ")) <= 3 && len(t) >= len(fence) && strings.Trim(t, fence[:1]) == "" { fence = "" } continue } - if fence != "" { + if m := fenceOpenRe.FindStringSubmatch(line); m != nil && !(m[1][0] == '`' && strings.Contains(m[2], "`")) { + fence = m[1] + fenced[i] = true continue } - m := headingRe.FindStringSubmatch(line) - if m == nil { + if m := atxRe.FindStringSubmatch(line); m != nil { + text := closeHashRe.ReplaceAllString(m[2], "") + heads = append(heads, heading{at: i, body: i + 1, level: len(m[1]), text: strings.TrimSpace(text)}) continue } + if m := setextRe.FindStringSubmatch(line); m != nil && i > 0 && !fenced[i-1] { + prev := lines[i-1] + if strings.TrimSpace(prev) != "" && !atxRe.MatchString(prev) && !bulletRe.MatchString(prev) && !setextRe.MatchString(prev) && !strings.HasPrefix(prev, " ") && !strings.HasPrefix(prev, "\t") { + level := 2 + if m[1][0] == '=' { + level = 1 + } + heads = append(heads, heading{at: i - 1, body: i + 1, level: level, text: strings.TrimSpace(prev)}) + } + } + } + return fenced, heads +} + +// Parse returns the first acceptance-criteria section in body. +func Parse(body string) Section { + lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") + fenced, heads := scan(lines) + var out Section + start, end, level := -1, len(lines), 0 + for _, h := range heads { if start < 0 { - if strings.EqualFold(strings.TrimSpace(m[2]), "acceptance criteria") { - level = len(m[1]) - start = i + 1 + if strings.EqualFold(h.text, "acceptance criteria") { + start, level = h.body, h.level } continue } - if len(m[1]) <= level { - end = i + if h.level <= level { + end = h.at break } } @@ -76,8 +110,7 @@ func Parse(body string) Section { return out } out.Found = true - section := lines[start:end] - out.Raw = strings.TrimSpace(strings.Join(section, "\n")) + out.Raw = strings.TrimSpace(strings.Join(lines[start:end], "\n")) out.Criteria = []Criterion{} out.Placeholders = []string{} @@ -94,20 +127,15 @@ func Parse(body string) Section { } cur = nil } - fence = "" - for _, line := range section { - if m := fenceRe.FindStringSubmatch(line); m != nil { - if fence == "" { - fence = m[1] - } else if m[1] == fence { - fence = "" - } + for i := start; i < end; i++ { + line := lines[i] + if fenced[i] { flush() continue } - if fence != "" { - continue - } + // A criterion is a top-level bullet (at most one space of indent); + // an indented AC-shaped bullet is nested under the one above it and + // joins its text rather than counting as a criterion of its own. if m := criterionRe.FindStringSubmatch(line); m != nil { flush() cur = &Criterion{ID: m[1], Text: m[2]} diff --git a/internal/acceptance/acceptance_test.go b/internal/acceptance/acceptance_test.go index 5ed9a0721..18d698e5c 100644 --- a/internal/acceptance/acceptance_test.go +++ b/internal/acceptance/acceptance_test.go @@ -69,6 +69,75 @@ func TestParse(t *testing.T) { }, placeholders: []string{}, }, + { + name: "a fence closes only on its own marker, at least as long, with nothing after", + body: "## Acceptance criteria\n````\n```\n- AC-8: still fenced\n```info\n- AC-9: still fenced\n````\n- AC-1: after\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "after"}, + }, + placeholders: []string{}, + }, + { + name: "a fenced heading cannot end the section once a short closer fails to close the fence", + body: "## Acceptance criteria\n- AC-1: a\n````\n```\n## Fenced\n````\n- AC-2: b\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "a"}, + {ID: "AC-2", Text: "b"}, + }, + placeholders: []string{}, + }, + { + name: "a backtick run with a backtick in its info string is not a fence", + body: "## Acceptance criteria\n``` a`b\n- AC-1: not fenced\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "not fenced"}, + }, + placeholders: []string{}, + }, + { + name: "an indented AC-shaped bullet is nested, not a criterion", + body: "## Acceptance criteria\n- AC-1: outer\n - AC-2: example\n- AC-3: next\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "outer - AC-2: example"}, + {ID: "AC-3", Text: "next"}, + }, + placeholders: []string{}, + }, + { + name: "setext headings open and close the section", + body: "Acceptance criteria\n===================\n- AC-1: a\n\nOpen questions\n==============\n- AC-2: b\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "a"}, + }, + placeholders: []string{}, + raw: "- AC-1: a", + }, + { + name: "a dash underline under a list item or an indented line is a thematic break, not a heading", + body: "Acceptance criteria\n---\n- AC-1: a\n---\n- AC-2: b\n more\n---\n- AC-3: c\n# Next\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "a"}, + {ID: "AC-2", Text: "b more"}, + {ID: "AC-3", Text: "c"}, + }, + placeholders: []string{}, + }, + { + name: "a hash with no space after it is not a heading", + body: "## Acceptance criteria\n- AC-1: a\n#hashtag\n- AC-2: b\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "a"}, + {ID: "AC-2", Text: "b"}, + }, + placeholders: []string{}, + }, { name: "heading with nothing under it", body: "## Acceptance criteria\n\n## Next\n",