package issue import ( "fmt" "regexp" "strings" ) // A checkbox is the one part of a body that is *state* and not prose, so the // format gives it markup of its own. It is item markup, not a property of one // section: `## Acceptance criteria` is the usual home, but a type/feature // keeps its children as checkboxes under `## Issues`. The scan is therefore // over the whole text and the heading is only recorded, never required. var ( // The trailing group stands in for a lookahead RE2 does not have: after // the bracket there is either whitespace and then anything, or end of line. checkboxRe = regexp.MustCompile( `^(?P[ \t]*)(?P[-*+]|\d+[.)])[ \t]+` + `\[(?P[ xX])\](?P[ \t].*|)$`) // Any list item — a sibling ends the item above it, checkbox or not. listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)])([ \t]|$)`) fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})") ) // Checkbox is one checkbox item found in a text. type Checkbox struct { // Index is the 1-based position in the list — what a user types to pick it. Index int // Line is the 1-based line of the `- [ ]` marker, in the text given. Line int // EndLine is the 1-based last line of the item, continuations included. EndLine int // Checked is true for [x] / [X]. Checked bool // Text is the item's text; continuation lines joined with one space. Text string // Section is the nearest preceding `## ` heading, "" above the first one. Section string } // Checkboxes returns every checkbox item in text, in document order. // // A pure function of the string it is given — no I/O, no store, no tracker. // Pass an issue body to get body-relative line numbers, or a whole file to get // file-relative ones; nothing else changes. // // Rules: // // - Only a line matching checkboxRe opens an item. A wrapped ("continuation") // line is part of the item above it, never an item of its own; the item runs // to the next blank line, heading, code fence, or list marker. // - Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example // of the markup, not a box anybody may tick. // - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested // lists are seen too. func Checkboxes(text string) []Checkbox { lines := splitLines(text) var items []Checkbox section, fence := "", "" for n, line := range lines { if m := fenceRe.FindStringSubmatch(line); m != nil { tok := m[1] switch { case fence == "": fence = tok case tok[0] == fence[0] && len(tok) >= len(fence): fence = "" } continue } if fence != "" { continue } if strings.HasPrefix(line, "## ") { section = strings.TrimSpace(line) continue } if strings.HasPrefix(line, "# ") { section = "" continue } m := checkboxRe.FindStringSubmatch(line) if m == nil { continue } end := n + 1 parts := []string{strings.TrimSpace(m[4])} for k := n + 1; k < len(lines); k++ { next := lines[k] if strings.TrimSpace(next) == "" || strings.HasPrefix(next, "#") || fenceRe.MatchString(next) || listItemRe.MatchString(next) { break } end = k + 1 parts = append(parts, strings.TrimSpace(next)) } var kept []string for _, p := range parts { if p != "" { kept = append(kept, p) } } items = append(items, Checkbox{ Index: len(items) + 1, Line: n + 1, EndLine: end, Checked: m[3] != " ", Text: strings.Join(kept, " "), Section: section, }) } return items } // SetCheckbox returns text with the checkbox on the given 1-based line set to // checked. // // Pure, and deliberately surgical: exactly one byte of the input changes — the // one between the brackets. Everything else, including trailing whitespace and // the item's own wording, comes back byte for byte. That is the whole point: // ticking a box must not produce a diff wider than the state that changed. // // Already in the requested state is a no-op — text comes back unchanged, and // an existing [X] keeps its capital. func SetCheckbox(text string, line int, checked bool) (string, error) { off := 0 for n := 1; off <= len(text); n++ { nl := strings.IndexByte(text[off:], '\n') var raw string if nl == -1 { raw = text[off:] } else { raw = text[off : off+nl] } if n == line { m := checkboxRe.FindStringSubmatchIndex(strings.TrimRight(raw, "\r")) if m == nil { return "", fmt.Errorf("line %d is not a checkbox item", line) } box := off + m[6] // group 3: box if (text[box] != ' ') == checked { return text, nil } c := byte(' ') if checked { c = 'x' } return text[:box] + string(c) + text[box+1:], nil } if nl == -1 { break } off += nl + 1 } return "", fmt.Errorf("line %d is past the end of the text", line) } // CheckboxProgress is (done, total) over every checkbox in text; (0, 0) when // it has none. // // Computed on the fly, on purpose. Progress is not a metadata field: it is the // body read back, and the body is the only place the state lives. func CheckboxProgress(text string) (done, total int) { items := Checkboxes(text) for _, c := range items { if c.Checked { done++ } } return done, len(items) } // splitLines is strings.Split minus the phantom final element a trailing // newline produces, matching Python's str.splitlines(). func splitLines(text string) []string { if text == "" { return nil } lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") if last := len(lines) - 1; lines[last] == "" { lines = lines[:last] } return lines }