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.
|
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 | } |