package docs import ( "bytes" "regexp" "slices" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) var ( adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"} planSections = []string{"Scope", "Steps", "Validation"} ) var atxRe = regexp.MustCompile(`(?m)^ {0,3}#{1,6}(?:[ \t]|$)`) type heading struct { level int text string line int at int // index among the document's top-level nodes } type doc struct { c *checker r *record src []byte startLine int kids []ast.Node heads []heading } func (d *doc) lineAt(off int) int { return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n")) } // headingLine is the line of h. An empty ATX heading has no lines, so it is the first // `#` line after the end of the previous top-level block. func (d *doc) headingLine(h *ast.Heading, prev ast.Node) int { if h.Lines().Len() > 0 { return d.lineAt(h.Lines().At(0).Start) } from := 0 if prev != nil { _ = ast.Walk(prev, func(c ast.Node, enter bool) (ast.WalkStatus, error) { if enter { for i := range c.Lines().Len() { from = max(from, c.Lines().At(i).Stop) } if t, ok := c.(*ast.Text); ok { from = max(from, t.Segment.Stop) } } return ast.WalkContinue, nil }) } if m := atxRe.FindIndex(d.src[from:]); m != nil { return d.lineAt(from + m[0]) } return d.startLine } // lineOf is the line of the first text under n. Without text (an autolink, an image with // no alt text) it is the line where needle appears in the enclosing block, else the block's first line. func (d *doc) lineOf(n ast.Node, needle string) int { var off = -1 _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { if t, ok := c.(*ast.Text); ok && enter { off = t.Segment.Start return ast.WalkStop, nil } return ast.WalkContinue, nil }) for p := n; off < 0 && p != nil; p = p.Parent() { if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 { off = p.Lines().At(0).Start if i := bytes.Index(d.src[off:p.Lines().At(p.Lines().Len()-1).Stop], []byte(needle)); needle != "" && i >= 0 { off += i } } } if off < 0 { return d.startLine } return d.lineAt(off) } func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) { b := []byte(src) ctx := &refCtx{Context: parser.NewContext()} root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx)) d := &doc{c: c, r: r, src: b, startLine: startLine} for n := root.FirstChild(); n != nil; n = n.NextSibling() { if h, ok := n.(*ast.Heading); ok { line := d.headingLine(h, n.PreviousSibling()) d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)}) } d.kids = append(d.kids, n) } d.placeholders(root) d.links(root, ctx) d.title(title, titleOK) if r.kind == "adr" { for _, name := range adrSections { d.section(name) } d.decisionOutcome() } else { for _, name := range planSections { d.section(name) } } } // title checks the single leading H1. func (d *doc) title(title string, titleOK bool) { h1s := 0 for _, h := range d.heads { if h.level == 1 { if h1s++; h1s > 1 { d.c.add(d.r.path, h.line, "more than one H1") } } } switch first := d.heads; { case len(first) == 0 || first[0].level != 1: d.c.add(d.r.path, d.startLine, "the first heading must be an H1") case d.r.kind == "plan" && first[0].text == "": d.c.add(d.r.path, first[0].line, "the H1 (plan title) must not be empty") case d.r.kind == "adr" && titleOK && first[0].text != title: d.c.add(d.r.path, first[0].line, "H1 %q must equal title %q", first[0].text, title) } } // extent returns the top-level nodes after heading h up to the next heading of level <= upTo. func (d *doc) extent(h heading, upTo int) []ast.Node { end := len(d.kids) for _, o := range d.heads { if o.at > h.at && o.level <= upTo { end = o.at break } } return d.kids[h.at+1 : end] } // section finds the one H2 called name and checks that it has prose. func (d *doc) section(name string) (heading, bool) { var found []heading for _, h := range d.heads { if h.level == 2 && h.text == name { found = append(found, h) } } switch { case len(found) == 0: d.c.add(d.r.path, 1, "missing required section %q", name) case len(found) > 1: d.c.add(d.r.path, found[1].line, "section %q appears more than once", name) case !d.hasProse(d.extent(found[0], 2)): d.c.add(d.r.path, found[0].line, "section %q has no prose", name) return found[0], true default: return found[0], true } return heading{}, false } func (d *doc) decisionOutcome() { for _, h := range d.heads { if h.level == 2 && h.text == "Consequences" { d.c.add(d.r.path, h.line, "Consequences must be a ### subsection of Decision Outcome, not a ## section") } } var outcome []heading for _, h := range d.heads { if h.level == 2 && h.text == "Decision Outcome" { outcome = append(outcome, h) } } if len(outcome) != 1 { return } h := outcome[0] nodes := d.extent(h, 2) // Prose before the first subsection is the decision itself. pre := nodes for i, n := range nodes { if h, ok := n.(*ast.Heading); ok && h.Level == 3 { pre = nodes[:i] break } } if !d.hasProse(pre) { d.c.add(d.r.path, h.line, "Decision Outcome needs prose before its first subsection") } var cons []heading for _, s := range d.heads { if s.level == 3 && s.text == "Consequences" && s.at > h.at && s.at < h.at+len(nodes)+1 { cons = append(cons, s) } } switch { case len(cons) == 0: d.c.add(d.r.path, h.line, "Decision Outcome must contain a ### Consequences subsection") case len(cons) > 1: d.c.add(d.r.path, cons[1].line, "### Consequences appears more than once") case !d.hasProse(d.extent(cons[0], 3)): d.c.add(d.r.path, cons[0].line, "### Consequences has no prose") } } // hasProse reports whether nodes hold a paragraph with text outside code, images and raw HTML. func (d *doc) hasProse(nodes []ast.Node) bool { for _, n := range nodes { found := false _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { switch c.(type) { case *ast.Paragraph, *ast.TextBlock: if enter && strings.TrimSpace(d.inlineText(c)) != "" { found = true return ast.WalkStop, nil } return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil }) if found { return true } } return false } func (d *doc) inlineText(n ast.Node) string { var s strings.Builder for c := n.FirstChild(); c != nil; c = c.NextSibling() { switch t := c.(type) { case *ast.Text: s.Write(t.Segment.Value(d.src)) case *ast.String: s.Write(t.Value) case *ast.CodeSpan, *ast.RawHTML, *ast.Image: default: s.WriteString(d.inlineText(c)) } } return s.String() } // codeRanges returns the source ranges of code blocks and code spans. func codeRanges(root ast.Node) (code [][2]int) { _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { switch n.(type) { case *ast.FencedCodeBlock, *ast.CodeBlock: for i := range n.Lines().Len() { seg := n.Lines().At(i) code = append(code, [2]int{seg.Start, seg.Stop}) } case *ast.CodeSpan: for c := n.FirstChild(); c != nil; c = c.NextSibling() { if t, ok := c.(*ast.Text); ok { code = append(code, [2]int{t.Segment.Start, t.Segment.Stop}) } } } return ast.WalkContinue, nil }) return code } // placeholders reports every marker outside code. func (d *doc) placeholders(root ast.Node) { code := codeRanges(root) for off := 0; ; { i := strings.Index(string(d.src[off:]), marker) if i < 0 { return } at := off + i off = at + len(marker) inCode := false for _, r := range code { inCode = inCode || at >= r[0] && at < r[1] } if !inCode { d.c.add(d.r.path, d.lineAt(at), "unfinished placeholder %s", marker) } } } // refCtx records every link reference definition; the parser keeps only the first of a label. type refCtx struct { parser.Context all []parser.Reference } func (c *refCtx) AddReference(r parser.Reference) { c.all = append(c.all, r) c.Context.AddReference(r) } // defRe finds a definition's label at the start of a line, allowing blockquote and list prefixes. var defRe = regexp.MustCompile(`(?m)^[ \t>]*(?:(?:[*+-]|\d+[.)])[ \t]+)*\[([^\]\n]+)\]:`) // dest decodes a link destination as CommonMark does before it is used. func dest(b []byte) string { return string(util.ResolveEntityNames(util.ResolveNumericReferences(util.UnescapePunctuations(b)))) } // links checks every link, image and autolink destination and every reference definition. func (d *doc) links(root ast.Node, ctx *refCtx) { _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { if !enter { return ast.WalkContinue, nil } switch t := n.(type) { case *ast.Link: d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination)) case *ast.Image: d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination)) case *ast.AutoLink: d.c.checkLink(d.r, d.lineOf(n, "<"+string(t.Label(d.src))), string(t.URL(d.src))) } return ast.WalkContinue, nil }) // shortcut: a definition's line is its label's nth match outside code; a multi-line // definition inside a container may be located by label only. Upgrade by tracking offsets. code := codeRanges(root) lines := map[string][]int{} for _, m := range defRe.FindAllSubmatchIndex(d.src, -1) { if !slices.ContainsFunc(code, func(r [2]int) bool { return m[0] >= r[0] && m[0] < r[1] }) { k := util.ToLinkReference(d.src[m[2]:m[3]]) lines[k] = append(lines[k], d.lineAt(m[0])) } } for _, ref := range ctx.all { line, k := d.startLine, util.ToLinkReference(ref.Label()) if len(lines[k]) > 0 { line, lines[k] = lines[k][0], lines[k][1:] } d.c.checkLink(d.r, line, dest(ref.Destination())) } }