package docs import ( "errors" "io" "regexp" "slices" "strconv" "strings" "go.yaml.in/yaml/v3" ) const marker = "{{docs:todo}}" var yamlLineRe = regexp.MustCompile(`line (\d+)`) type field struct{ key, val *yaml.Node } type item struct { v string line int } // fm reads the typed fields of one record's frontmatter mapping. type fm struct { c *checker r *record f map[string]field } // splitFrontmatter returns the frontmatter text, the body and the file line the body starts on. func splitFrontmatter(src string) (front, body string, bodyLine int, msg string) { lines := strings.Split(src, "\n") if lines[0] != "---" { return "", "", 0, "file must start with a --- frontmatter line" } for i := 1; i < len(lines); i++ { if lines[i] == "---" { return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), i + 2, "" } } return "", "", 0, "frontmatter is not closed by a --- line" } // parseFrontmatter decodes the mapping and rejects anything but plain keys and values. func (c *checker) parseFrontmatter(r *record, front string) (*fm, bool) { report := func(line int, format string, args ...any) { c.add(r.path, line, format, args...) } dec := yaml.NewDecoder(strings.NewReader(front)) var doc, extra yaml.Node if err := dec.Decode(&doc); err != nil || len(doc.Content) == 0 { if err == nil || errors.Is(err, io.EOF) { report(2, "frontmatter is empty") } else { line := 2 if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil { n, _ := strconv.Atoi(m[1]) line += n - 1 } report(line, "invalid YAML: %v", strings.TrimPrefix(err.Error(), "yaml: ")) } return nil, false } if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { report(2, "frontmatter must be a single YAML document") return nil, false } root := doc.Content[0] if root.Kind != yaml.MappingNode { report(root.Line+1, "frontmatter must be a mapping") return nil, false } ok := true var walk func(n *yaml.Node) walk = func(n *yaml.Node) { switch { case n.Kind == yaml.AliasNode: report(n.Line+1, "YAML aliases are not allowed") ok = false case n.Anchor != "": report(n.Line+1, "YAML anchors are not allowed") ok = false case n.Style&yaml.TaggedStyle != 0: report(n.Line+1, "explicit YAML tags are not allowed") ok = false } for _, ch := range n.Content { walk(ch) } } walk(root) m := &fm{c: c, r: r, f: map[string]field{}} for i := 0; i+1 < len(root.Content); i += 2 { k, v := root.Content[i], root.Content[i+1] if k.Kind != yaml.ScalarNode { report(k.Line+1, "keys must be plain strings") ok = false } else if _, dup := m.f[k.Value]; dup { report(k.Line+1, "duplicate key %q", k.Value) ok = false } else { m.f[k.Value] = field{k, v} } markers(v, report) } return m, ok } // markers reports an unfinished placeholder in any string value or list item. func markers(n *yaml.Node, report func(int, string, ...any)) { if n.Kind == yaml.ScalarNode && strings.Contains(n.Value, marker) { report(n.Line+1, "unfinished placeholder %s", marker) } for _, ch := range n.Content { markers(ch, report) } } // keys reports missing and unknown keys. func (m *fm) keys(want ...string) { for _, k := range want { if _, ok := m.f[k]; !ok { m.c.add(m.r.path, 1, "missing key %q", k) } } for k, f := range m.f { if !slices.Contains(want, k) { m.c.add(m.r.path, f.key.Line+1, "unknown key %q", k) } } } func (m *fm) line(key string) int { if f, ok := m.f[key]; ok { return f.key.Line + 1 } return 1 } func (m *fm) isNull(key string, n *yaml.Node) bool { if n.Kind == yaml.ScalarNode && n.Tag == "!!null" { m.c.add(m.r.path, n.Line+1, "%s must not be null", key) return true } return false } // scalar returns the literal text of a non-null scalar value. func (m *fm) scalar(key string) (string, int, bool) { f, ok := m.f[key] if !ok || m.isNull(key, f.val) { return "", 0, false } if f.val.Kind != yaml.ScalarNode { m.c.add(m.r.path, f.key.Line+1, "%s must be a string", key) return "", 0, false } return f.val.Value, f.val.Line + 1, true } // text is a non-empty single-line string. func (m *fm) text(key string) (string, bool) { v, line, ok := m.scalar(key) if ok && (strings.TrimSpace(v) == "" || strings.ContainsAny(v, "\r\n")) { m.c.add(m.r.path, line, "%s must be a non-empty single line", key) return v, false } return v, ok } func (m *fm) enum(key string, allowed ...string) (string, bool) { v, line, ok := m.scalar(key) if ok && !slices.Contains(allowed, v) { m.c.add(m.r.path, line, "%s must be one of %s", key, strings.Join(allowed, ", ")) return v, false } return v, ok } // list returns the items of a sequence of non-null scalars. func (m *fm) list(key string) ([]item, bool) { f, ok := m.f[key] if !ok || m.isNull(key, f.val) { return nil, false } if f.val.Kind != yaml.SequenceNode { m.c.add(m.r.path, f.key.Line+1, "%s must be a list", key) return nil, false } var items []item for _, n := range f.val.Content { if m.isNull(key, n) { continue } if n.Kind != yaml.ScalarNode { m.c.add(m.r.path, n.Line+1, "%s items must be strings", key) continue } items = append(items, item{n.Value, n.Line + 1}) } m.r.lists[key] = items return items, true } // tags checks the ADR tags list. func (m *fm) tags() { items, ok := m.list("tags") if !ok { return } if len(items) == 0 { m.c.add(m.r.path, m.line("tags"), "tags must not be empty") } seen := map[string]bool{} for _, it := range items { switch { case it.v == marker: case !slugRe.MatchString(it.v): m.c.add(m.r.path, it.line, "tag %q must be lowercase a-z0-9 words joined by single hyphens", it.v) case seen[it.v]: m.c.add(m.r.path, it.line, "duplicate tag %q", it.v) } seen[it.v] = true } } func (m *fm) problemf(key, format string, args ...any) { m.c.add(m.r.path, m.line(key), format, args...) }