clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

main
lazyopenspec / internal / spec / parse.go
parse.go 5.0K
1package spec23import (4 "bufio"5 "regexp"6 "strings"7)89// These mirror the OpenSpec CLI's parsers so counts match `openspec list`.10var (11 // Top-level checkboxes only: indented sub-tasks are not counted, and the12 // CLI matches them even inside fenced code.13 taskRe = regexp.MustCompile(`^[-*]\s+\[([\sxX])\]`)14 sectionRe = regexp.MustCompile(`(?i)^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$`)15 requirementRe = regexp.MustCompile(`(?i)^###\s*Requirement:`)16 removedAltRe = regexp.MustCompile("(?i)^\\s*-\\s*`?###\\s*Requirement:")17 scenarioRe = regexp.MustCompile(`^####\s+`)18 renameFromRe = regexp.MustCompile("^\\s*-?\\s*FROM:\\s*`?###\\s*Requirement:")19 renameToRe = regexp.MustCompile("^\\s*-?\\s*TO:\\s*`?###\\s*Requirement:")20 fenceRe = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})")21 bulletRe = regexp.MustCompile(`^(?:[-*+]\s+|>\s*)+`)22)2324// lines yields the lines of a markdown document outside fenced code blocks.25func lines(src string, fn func(line string)) {26 sc := bufio.NewScanner(strings.NewReader(src))27 sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)28 fence := "" // opening marker of the current fenced block29 for sc.Scan() {30 line := strings.TrimSuffix(sc.Text(), "\r")31 if m := fenceRe.FindStringSubmatch(line); m != nil {32 // CommonMark: a fence closes only with the same character and at33 // least the same length, so ``` inside ~~~ stays masked.34 switch {35 case fence == "":36 fence = m[1]37 case m[1][0] == fence[0] && len(m[1]) >= len(fence) && strings.TrimSpace(line[len(m[0]):]) == "":38 fence = ""39 }40 continue41 }42 if fence == "" {43 fn(line)44 }45 }46}4748// ParseTasks counts checked and unchecked markdown checkboxes.49func ParseTasks(src string) TaskStats {50 var t TaskStats51 for line := range strings.SplitSeq(src, "\n") {52 m := taskRe.FindStringSubmatch(line)53 if m == nil {54 continue55 }56 t.Total++57 if strings.EqualFold(m[1], "x") {58 t.Done++59 }60 }61 return t62}6364// ParseDelta counts requirement operations in a delta spec.65func ParseDelta(src string) DeltaStats {66 var d DeltaStats67 section := ""68 pendingFrom := false69 lines(src, func(line string) {70 if m := sectionRe.FindStringSubmatch(line); m != nil {71 section = strings.ToUpper(m[1])72 return73 }74 if strings.HasPrefix(line, "## ") {75 section = ""76 return77 }78 switch section {79 case "ADDED":80 if requirementRe.MatchString(line) {81 d.Added++82 }83 case "MODIFIED":84 if requirementRe.MatchString(line) {85 d.Modified++86 }87 case "REMOVED":88 if requirementRe.MatchString(line) || removedAltRe.MatchString(line) {89 d.Removed++90 }91 case "RENAMED":92 // Count FROM/TO pairs, like the CLI; a lone FROM is not a rename.93 switch {94 case renameFromRe.MatchString(line):95 pendingFrom = true96 case renameToRe.MatchString(line) && pendingFrom:97 d.Renamed++98 pendingFrom = false99 }100 }101 })102 return d103}104105// SpecMeta is the summary information extracted from a main spec.106type SpecMeta struct {107 Title string108 Purpose string109 Requirements int110 Scenarios int111}112113// ParseSpec extracts the title, purpose, and requirement counts from a spec.114func ParseSpec(src string) SpecMeta {115 var s SpecMeta116 section := ""117 inRequirement := false118 lines(src, func(line string) {119 switch {120 case strings.HasPrefix(line, "# ") && s.Title == "":121 s.Title = strings.TrimSpace(strings.TrimPrefix(line, "# "))122 case strings.HasPrefix(line, "## "), strings.HasPrefix(line, "### ") && !requirementRe.MatchString(line):123 section = strings.TrimSpace(strings.TrimLeft(line, "# "))124 inRequirement = false125 case !strings.EqualFold(section, "Requirements"):126 // The CLI only counts requirements under "## Requirements".127 if strings.EqualFold(section, "Purpose") && s.Purpose == "" {128 s.Purpose = strings.TrimSpace(line)129 }130 case requirementRe.MatchString(line):131 s.Requirements++132 inRequirement = true133 case inRequirement && scenarioRe.MatchString(line):134 s.Scenarios++135 }136 })137 return s138}139140// ParseSummary returns the first prose line under "## Why" in a proposal,141// falling back to the first prose line in the document.142func ParseSummary(src string) string {143 var why, first string144 section := ""145 lines(src, func(line string) {146 text := strings.TrimSpace(line)147 if strings.HasPrefix(text, "#") {148 section = strings.TrimSpace(strings.TrimLeft(text, "#"))149 return150 }151 if text == "" || strings.HasPrefix(text, "<!--") {152 return153 }154 text = strings.TrimSpace(bulletRe.ReplaceAllString(text, ""))155 if first == "" {156 first = text157 }158 if why == "" && strings.EqualFold(section, "Why") {159 why = text160 }161 })162 if why != "" {163 return why164 }165 return first166}167168// ParseSchema reads the "schema:" field from a change's .openspec.yaml.169func ParseSchema(src string) string {170 for line := range strings.SplitSeq(src, "\n") {171 if v, ok := strings.CutPrefix(strings.TrimSpace(line), "schema:"); ok {172 if i := strings.Index(v, " #"); i >= 0 {173 v = v[:i] // YAML inline comment174 }175 return strings.Trim(strings.TrimSpace(v), `"'`)176 }177 }178 return ""179}