Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · metadata/frontmatter.go

Line attribution on main · 271 line(s)

c5902451
Keenan Nemetz
16d ago
1 // Frontmatter reads and writes the fenced block that every metadata file
307cbc2a
Keenan Nemetz
17d ago
2 // on refs/heads/vole begins with: `key: value` lines between two `---` fences,
3 // then the markdown body.
4 //
5 // ---
6 // title: "crash on startup"
7 // state: open
8 // labels:
9 // - bug
10 // ---
11 // It broke.
12 //
13 // The writer decides the shape of the file so the reader can stay boring. Free
14 // text is always double-quoted, which means no YAML indicator, reserved word or
15 // `#` can change what it means. Constrained values (a state, a timestamp, an
16 // account name) are written bare and quoted only when the parser would not read
17 // them back unchanged. A list is one item per line, so adding an entry is an
18 // added line and never a rewritten one — which is the whole reason this format
19 // replaced JSON.
20 //
21 // The one thing quoting cannot fix is a hand-edit that drops the quotes on a
22 // title containing ` # `: YAML would read a comment and silently store a
23 // shorter title. RoundTrip closes that hole by comparing what the parser saw
24 // with what the line said.
c5902451
Keenan Nemetz
16d ago
25 package metadata
307cbc2a
Keenan Nemetz
17d ago
26
27 import (
28 "errors"
29 "fmt"
30 "io"
31 "strings"
32
33 "gopkg.in/yaml.v3"
34 )
35
36 const fence = "---\n"
37
38 // Split separates a file into its head (the text between the fences, without
39 // them) and its body. Only the first closing fence ends the head, so a body may
40 // contain `---` rules of its own.
41 func Split(raw []byte) (head, body string, err error) {
42 s := string(raw)
43 if !strings.HasPrefix(s, fence) {
44 return "", "", errors.New("a metadata file must open with --- and its frontmatter")
45 }
46 rest := s[len(fence):]
47 if i := strings.Index(rest, "\n"+fence); i >= 0 {
48 return rest[:i], rest[i+1+len(fence):], nil
49 }
50 // A hand-written file may end at the closing fence without a trailing newline.
51 if strings.HasSuffix(rest, "\n---") {
52 return rest[:len(rest)-len("\n---")], "", nil
53 }
54 return "", "", errors.New("unterminated frontmatter: the closing --- is missing")
55 }
56
57 // Decode reads the head into v, refusing fields v does not declare. An empty head
58 // decodes to v unchanged.
59 func Decode(head string, v any) error {
60 dec := yaml.NewDecoder(strings.NewReader(head))
61 dec.KnownFields(true)
62 if err := dec.Decode(v); err != nil {
63 if errors.Is(err, io.EOF) {
64 return nil
65 }
66 return err
67 }
68 return nil
69 }
70
79c88d72
Keenan Nemetz
17d ago
71 // Value reads one top-level scalar out of a head without decoding the whole
72 // document, and without refusing fields it does not know. Decode is strict on
73 // purpose — a metadata tree should not carry stray keys — but a reader that only
74 // wants "who wrote this" must not fail on a file whose other fields belong to a
75 // different entity type.
76 func Value(head, key string) (string, bool) {
77 var doc map[string]any
78 if err := yaml.Unmarshal([]byte(head), &doc); err != nil {
79 return "", false
80 }
81 v, ok := doc[key]
82 if !ok {
83 return "", false
84 }
85 s, ok := v.(string)
86 if !ok {
87 return "", false
88 }
89 return s, true
90 }
91
307cbc2a
Keenan Nemetz
17d ago
92 // RoundTrip rejects an unquoted field that the parser read as something other
93 // than what the line says. A quoted value is taken at face value: the writer
94 // chose the quotes, and YAML reads them back exactly.
95 //
96 // Without this, `title: fix #12 crash` is stored as "fix" and nobody finds out
97 // until the bug is quoted in a search that misses it.
98 func RoundTrip(head, key, got string) error {
99 raw, ok := bareValue(head, key)
100 if !ok {
101 return nil
102 }
103 if raw == got {
104 return nil
105 }
106 return fmt.Errorf("%s must be double-quoted: the line reads %q but the value parsed as %q", key, raw, got)
107 }
108
109 // Text is the reader-side check for a free-text field: it must have survived the
110 // parse unchanged, and it must fit on one line. Both halves matter — a truncated
111 // title and a two-line title are the same bug seen from different ends.
112 func Text(head, key, value string) error {
113 if err := RoundTrip(head, key, value); err != nil {
114 return err
115 }
116 return ValidText(value)
117 }
118
119 // bareValue returns the text of a top-level unquoted `key: value` line, and
120 // reports whether the line was bare (rather than quoted or absent).
121 func bareValue(head, key string) (string, bool) {
122 for _, line := range strings.Split(head, "\n") {
123 if !strings.HasPrefix(line, key+":") {
124 continue
125 }
126 value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
127 if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, "'") {
128 return "", false
129 }
130 return value, true
131 }
132 return "", false
133 }
134
135 // Doc accumulates fields in the order they are written and renders the file. It
136 // records the first problem instead of returning an error from every setter, so
137 // a constructor can read as a list of fields.
138 type Doc struct {
139 lines []string
140 err error
141 }
142
143 // New starts an empty document.
c5902451
Keenan Nemetz
16d ago
144 func NewDoc() *Doc { return &Doc{} }
307cbc2a
Keenan Nemetz
17d ago
145
146 // Text writes a free-text field, always double-quoted.
147 func (d *Doc) Text(key, value string) {
148 q, err := quote(value)
149 if err != nil {
150 d.fail(err)
151 return
152 }
153 d.lines = append(d.lines, key+": "+q)
154 }
155
156 // Word writes a constrained value bare, quoting it only if the parser would not
157 // read it back unchanged.
158 func (d *Doc) Word(key, value string) {
159 if plain(value) {
160 d.lines = append(d.lines, key+": "+value)
161 return
162 }
163 q, err := quote(value)
164 if err != nil {
165 d.fail(err)
166 return
167 }
168 d.lines = append(d.lines, key+": "+q)
169 }
170
171 // List writes a sequence with one item per line. Labels are quoted only when a
172 // bare item would not survive the parse (`null` is the one a forge actually
173 // hits).
174 func (d *Doc) List(key string, values []string) {
175 if len(values) == 0 {
176 return
177 }
178 d.lines = append(d.lines, key+":")
179 for _, v := range values {
180 if plain(v) {
181 d.lines = append(d.lines, " - "+v)
182 continue
183 }
184 q, err := quote(v)
185 if err != nil {
186 d.fail(err)
187 return
188 }
189 d.lines = append(d.lines, " - "+q)
190 }
191 }
192
193 // Bytes renders the document and the body that follows it. An empty body leaves
194 // the file ending at the closing fence, which is what a record with no prose
195 // (an approval) looks like.
196 func (d *Doc) Bytes(body string) ([]byte, error) {
197 if d.err != nil {
198 return nil, d.err
199 }
200 var b strings.Builder
201 b.WriteString(fence)
202 for _, line := range d.lines {
203 b.WriteString(line)
204 b.WriteByte('\n')
205 }
206 b.WriteString(fence)
207 if body == "" {
208 return []byte(b.String()), nil
209 }
210 b.WriteString(body)
211 if !strings.HasSuffix(body, "\n") {
212 b.WriteByte('\n')
213 }
214 return []byte(b.String()), nil
215 }
216
217 func (d *Doc) fail(err error) {
218 if d.err == nil {
219 d.err = err
220 }
221 }
222
223 // ValidText reports whether s can be written as a quoted scalar: one line, no
224 // control characters. The writer refuses exactly these, so a value the validator
225 // accepts is a value the writer can reproduce — a tree the reader takes but the
226 // writer cannot render is a tree nobody can close an issue in.
227 func ValidText(s string) error {
228 for _, r := range s {
229 if r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029' {
230 return fmt.Errorf("control character %q is not allowed here; keep the value on one line", r)
231 }
232 }
233 return nil
234 }
235
236 // quote renders a double-quoted scalar.
237 func quote(s string) (string, error) {
238 if err := ValidText(s); err != nil {
239 return "", err
240 }
241 var b strings.Builder
242 b.WriteByte('"')
243 for _, r := range s {
244 switch r {
245 case '"':
246 b.WriteString(`\"`)
247 case '\\':
248 b.WriteString(`\\`)
249 default:
250 b.WriteRune(r)
251 }
252 }
253 b.WriteByte('"')
254 return b.String(), nil
255 }
256
257 // plain reports whether s can be written bare and read back unchanged. It asks
258 // the parser rather than carrying a list of YAML's reserved words, so the answer
259 // cannot drift from the reader.
260 func plain(s string) bool {
261 if s == "" || strings.TrimSpace(s) != s {
262 return false
263 }
264 var probe struct {
265 K string `yaml:"k"`
266 }
267 if err := yaml.Unmarshal([]byte("k: "+s+"\n"), &probe); err != nil {
268 return false
269 }
270 return probe.K == s
271 }