package main import ( "fmt" "regexp" "sort" "strconv" "strings" "unicode" ) // An issue report in hq, `04-ISSUES/NNN-slug/00-report.md`, as the import of slice 3 reads it (novox/hq // to-be 53 §8): a YAML frontmatter of a few keys, a heading that is the title, and the report. The // frontmatter is read as hq's own checks read it (00-META/checks/cycle.py), a little more carefully: a // list in brackets may run over lines and hold commas inside parentheses or quotes, a block list's items // may be quoted, and a long value may wrap onto an indented line. // Report is one issue report as read from its folder. type Report struct { Number uint64 // Folder is the issue's folder, `04-ISSUES/NNN-slug`. Folder string // Path is the report, `/00-report.md`. Path string Title string Status string // Opened and Closed are dates as the frontmatter gives them, YYYY-MM-DD, or empty. Opened string Closed string LocatedIn []string FixedBy []string AmendedDesign []string Replay string ReplayNone string // Body is the report without its frontmatter and its title. Body string // Issues and Decisions are the other issues and the decision records the report names. Issues []uint64 Decisions []string // Odd is what was odd about the report and did not stop it from being read. Odd []string } var ( issueFolder = regexp.MustCompile(`^([0-9]{1,4})-([a-z0-9]+(?:-[a-z0-9]+)*)$`) frontKey = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*):(.*)$`) blockItem = regexp.MustCompile(`^\s+-\s*(.*)$`) continued = regexp.MustCompile(`^\s+(\S.*)$`) heading = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`) numbered = regexp.MustCompile(`^([0-9]{1,4})\s*([.:]|—|–|-)?\s*(.*)$`) dateShape = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`) namesIssue = regexp.MustCompile(`(?i)\bissue ([0-9]{1,4})\b|04-ISSUES/([0-9]{1,4})-`) namesRecord = regexp.MustCompile(`\bADR ([0-9]{4})\b|02-DECISIONS/([0-9]{4})-`) ) // folderNumber is the number an issue folder's name carries, or false for a name that is not one. func folderNumber(name string) (uint64, bool) { m := issueFolder.FindStringSubmatch(name) if m == nil || len(m[2]) > 120 { return 0, false } n, err := strconv.ParseUint(m[1], 10, 64) if err != nil || n == 0 { return 0, false } return n, true } // frontmatter is the YAML block between the first two `---` lines as {key: values}, and the text after // it. A scalar is one value; a list, in brackets or as block items, its items; an empty value no values. // A key given twice keeps the last. False when the text has no frontmatter. func frontmatter(text string) (map[string][]string, string, bool) { text = strings.ReplaceAll(text, "\r\n", "\n") if !strings.HasPrefix(text, "---\n") { return nil, "", false } rest := text[4:] end := -1 if strings.HasPrefix(rest, "---\n") || rest == "---" { end = 0 } else if i := strings.Index(rest, "\n---\n"); i >= 0 { end = i + 1 } else if strings.HasSuffix(rest, "\n---") { end = len(rest) - 3 } if end < 0 { return nil, "", false } block := rest[:end] body := strings.TrimPrefix(rest[end:], "---") body = strings.TrimPrefix(body, "\n") out := map[string][]string{} key := "" const ( none = iota scalar list flow ) mode := none flowText := "" closeFlow := func() { out[key] = splitFlow(flowText) mode = list } for _, line := range strings.Split(block, "\n") { if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { continue } if mode == flow { flowText += " " + strings.TrimSpace(line) if flowClosed(flowText) { closeFlow() } continue } if m := frontKey.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") { key = m[1] value := strings.TrimSpace(m[2]) switch { case value == "": out[key] = nil mode = list case strings.HasPrefix(value, "["): flowText = value mode = flow if flowClosed(flowText) { closeFlow() } default: out[key] = []string{unquote(value)} mode = scalar } continue } if key == "" { continue } if m := blockItem.FindStringSubmatch(line); m != nil && mode == list { out[key] = append(out[key], unquote(strings.TrimSpace(m[1]))) continue } if m := continued.FindStringSubmatch(line); m != nil && len(out[key]) > 0 { last := len(out[key]) - 1 out[key][last] = strings.TrimSpace(out[key][last] + " " + strings.TrimSpace(m[1])) } } if mode == flow { // Never closed: what was read of it, without the bracket. out[key] = splitFlow(flowText + "]") } return out, body, true } // flowClosed says whether a bracketed list has its closing bracket, outside quotes. A quote opens an item // only at the item's start: an apostrophe inside prose ("the provisioner's ACL") is no quote. func flowClosed(s string) bool { depth, quote, atStart := 0, rune(0), true for _, r := range s { switch { case quote != 0: if r == quote { quote = 0 } continue case (r == '"' || r == '\'') && atStart: quote = r case r == '[': depth++ case r == ']': depth-- if depth == 0 { return true } } atStart = r == '[' || r == ',' || atStart && unicode.IsSpace(r) } return false } // splitFlow is a bracketed list's items: split on the commas outside parentheses, brackets and quotes, // a quote opening an item only at its start. func splitFlow(s string) []string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "[") s = strings.TrimSuffix(s, "]") var items []string var cur strings.Builder depth, quote, atStart := 0, rune(0), true flush := func() { if v := unquote(strings.TrimSpace(cur.String())); v != "" { items = append(items, v) } cur.Reset() } for _, r := range s { switch { case quote != 0: if r == quote { quote = 0 } case (r == '"' || r == '\'') && atStart: quote = r case r == '(' || r == '[': depth++ case r == ')' || r == ']': if depth > 0 { depth-- } case r == ',' && depth == 0: flush() atStart = true continue } cur.WriteRune(r) atStart = atStart && unicode.IsSpace(r) } flush() return items } // unquote strips one pair of matching quotes around a value, and the escapes a double-quoted YAML // scalar uses for its own quote. func unquote(v string) string { if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') { inner := v[1 : len(v)-1] if v[0] == '"' { inner = strings.ReplaceAll(inner, `\"`, `"`) } else { inner = strings.ReplaceAll(inner, `''`, `'`) } return strings.TrimSpace(inner) } return v } // ParseReport reads one issue report. The folder is its name, `NNN-slug`; the text is the report whole. // What makes a report unreadable is an error: no frontmatter, no title, no status, or a status that // is not one of the six. What is odd but readable is said in Odd. func ParseReport(folder, text string) (*Report, error) { n, ok := folderNumber(folder) if !ok { return nil, fmt.Errorf("%q is not an issue folder's name: NNN-slug, lower-case words joined by hyphens", folder) } front, body, ok := frontmatter(text) if !ok { return nil, fmt.Errorf("%s/00-report.md has no frontmatter", folder) } r := &Report{Number: n, Folder: "04-ISSUES/" + folder, Path: "04-ISSUES/" + folder + "/00-report.md"} one := func(key string) string { if v := front[key]; len(v) > 0 { if len(v) > 1 { r.Odd = append(r.Odd, fmt.Sprintf("%s: is a list of %d; one value was expected, and they were joined", key, len(v))) return strings.Join(v, "; ") } return v[0] } return "" } r.Status = one("status") if r.Status == "" { return nil, fmt.Errorf("%s/00-report.md has no status", folder) } if !known(r.Status) { return nil, fmt.Errorf("%s/00-report.md has the status %q, not one of %s", folder, r.Status, strings.Join(Statuses, ", ")) } r.Opened = one("opened") if r.Opened != "" && !dateShape.MatchString(r.Opened) { r.Odd = append(r.Odd, fmt.Sprintf("opened: %q is not a date", r.Opened)) r.Opened = "" } for _, key := range []string{"resolved", "closed"} { if v := one(key); v != "" { if dateShape.MatchString(v) { r.Closed = v } else { r.Odd = append(r.Odd, fmt.Sprintf("%s: %q is not a date", key, v)) } } } r.LocatedIn = front["located-in"] r.FixedBy = front["fixed-by"] r.AmendedDesign = front["amended-design"] r.Replay = one("replay") r.ReplayNone = one("replay-none") imported := map[string]bool{"status": true, "opened": true, "resolved": true, "closed": true, "located-in": true, "fixed-by": true, "amended-design": true, "replay": true, "replay-none": true} var unknown []string for k := range front { if !imported[k] { unknown = append(unknown, k) } } if len(unknown) > 0 { sort.Strings(unknown) r.Odd = append(r.Odd, "frontmatter keys not imported: "+strings.Join(unknown, ", ")) } m := heading.FindStringSubmatchIndex(body) if m == nil { return nil, fmt.Errorf("%s/00-report.md has no title: no `# ` heading", folder) } title := body[m[2]:m[3]] // "362. The title", "043 — The title": the number and its separator are the folder's, not the title's. A // title that starts with a bare number ("2 nodes …") keeps it, unless the number is the folder's. if nm := numbered.FindStringSubmatch(title); nm != nil { heads, _ := strconv.ParseUint(nm[1], 10, 64) if nm[2] != "" || heads == n { title = nm[3] if heads != n && title != "" { r.Odd = append(r.Odd, fmt.Sprintf("the title is numbered %s; the folder is %d", nm[1], n)) } } } r.Title = strings.TrimSpace(title) if r.Title == "" { return nil, fmt.Errorf("%s/00-report.md has an empty title", folder) } r.Body = strings.TrimSpace(body[:m[0]] + body[m[1]:]) seen := map[uint64]bool{n: true} for _, mm := range namesIssue.FindAllStringSubmatch(text, -1) { if other, err := strconv.ParseUint(mm[1]+mm[2], 10, 64); err == nil && !seen[other] { seen[other] = true r.Issues = append(r.Issues, other) } } sort.Slice(r.Issues, func(i, j int) bool { return r.Issues[i] < r.Issues[j] }) records := map[string]bool{} for _, mm := range namesRecord.FindAllStringSubmatch(text, -1) { if d := mm[1] + mm[2]; !records[d] { records[d] = true r.Decisions = append(r.Decisions, d) } } sort.Strings(r.Decisions) return r, nil }