Documentation
¶
Overview ¶
Package frontmatter is abcd's shared markdown-frontmatter line scanner. It is deliberately a line scanner, not a YAML parser: it reads only the top-level keys of the leading `---`…`---` block, first key wins, and pulls in zero dependencies. It exists so its consumers (internal/core/spec, internal/core/intent, and record-lint's top-level frontmatter checks) share ONE copy of this primitive rather than each keeping a private replica.
It is transport-agnostic: no stdout, no os.Exit, no filesystem access — the caller supplies the file's lines and decides what the fields mean.
Index ¶
- Variables
- func Close(lines []string) int
- func CloseAfter(lines []string, open int) int
- func Fields(lines []string) map[string]Field
- func IsDelimiter(line string) bool
- func IsEmptyValue(raw string) bool
- func IsNull(v string) bool
- func QuoteScalar(s string) string
- func ScalarString(raw string) (value string, ok bool)
- func Split(text string) (head, body string)
- func StringList(v string) []string
- func StripComment(v string) string
- func TrimBOM(s string) string
- func Unquote(s string) string
- func UnquoteScalar(v string) (value string, quoted bool)
- type Dup
- type Emptiness
- type Field
Constants ¶
This section is empty.
Variables ¶
var BlockScalarHeaderRe = regexp.MustCompile(`^[|>][0-9+-]*(?:\s+#.*)?$`)
BlockScalarHeaderRe matches a YAML block-scalar header and nothing else: `|`, `>`, with the chomping and indentation indicators the spelling allows (`|-`, `>+`, `|2-`) and the trailing comment YAML permits after the header. A key carrying one holds its value on the lines BELOW it, so the same-line scanner reports the header as the value — which is why a reader asking "is this a single-line string?" has to recognise the header rather than take the byte for a value. It lives here, beside the scanner whose reading it corrects, so record-lint's block-emptiness leg and the record readers test one pattern.
Functions ¶
func Close ¶ added in v0.12.0
Close returns the index in lines of the leading frontmatter block's closing delimiter, or -1 when there is no block: no opening delimiter on line 0, or nothing closing it. It reads the block exactly as Fields does — the BOM trimmed at line 0 and nowhere else, every delimiter judged by IsDelimiter — so a reader that needs the block's extent rather than its keys (a writer splicing a key in, a reader taking the body after it) asks here instead of re-deriving the walk. Private copies of this walk skipped the BOM Fields trims, so a BOM-led record the reader accepted was refused by intent's writers and had its whole frontmatter taken for body by the changelog (iss-2608221126066379).
func CloseAfter ¶ added in v0.12.0
CloseAfter returns the index of the first delimiter after the opening one at lines[open], or -1 when nothing closes the block. It is Close's walk for a reader that has located the opening delimiter itself — record-lint and the glossary admit an attribution comment above it, so their block need not open at line 0 — and it judges every closing line by IsDelimiter exactly as Close does: an indented ` ---` and a mid-file "\ufeff---" are body lines, never a close. Whether lines[open] opens a block is the caller's question.
The lines may carry their end-of-line bytes or not; IsDelimiter trims both.
func Fields ¶
Fields returns the top-level keys of the leading frontmatter block (the block between the first two `---` lines). Nested keys and list items are ignored, and the first occurrence of a key wins. An input whose first line is not `---` (or is empty) yields no fields. An unclosed block — a leading `---` with no closing `---` — is treated as no frontmatter (an empty map), so body prose is never harvested as fields.
func IsDelimiter ¶ added in v0.6.7
IsDelimiter reports whether a line is a frontmatter `---` delimiter.
This is the ONE delimiter rule. It is deliberately tolerant of trailing whitespace, and deliberately intolerant of everything else: `----`, `--- yaml` and an indented ` ---` are not delimiters, because leading whitespace survives the trim.
It does NOT trim a BOM, and must not. U+FEFF is a byte-order mark only at byte 0 of a file; anywhere else it is ZERO WIDTH NO-BREAK SPACE, an ordinary character, and a line spelled "\ufeff---" is a body line to every other reader. Trimming it here made Fields close a block early at such a line while intent's writer read on to the next bare `---` and inserted keys into the body — a lint-green record, a write that reports success, and a value invisible on reload (iss-2608270926036966). The BOM is a property of the FILE's first position, not of the character, so callers strip it from line 0 themselves (see TrimBOM) before asking this predicate anything.
Tolerance here is about the delimiter LINE, not about the block's content — capture's strictness about what is inside the block (duplicate top-level keys refused, indented lines refused, a restricted YAML subset) is a separate and deliberate policy, unaffected by this predicate.
Every reader of a record's bytes comes here rather than re-deriving the compare. capture kept a private byte-exact match (`--- ` was not a delimiter) while record-lint's ledger gate and the lifeboat graveyard read the same file through Fields, which trims: a trailing-space delimiter produced a lint-green issue file that every capture verb refused as malformed and that `abcd iss-N` reported as "not found in the issue ledger" while it sat in open/ — one format, two parsers, opposite verdicts, with the permissive one on the gate side (GitHub #338, the class iss-69 opened and left capture out of).
The line may still carry its end-of-line bytes: callers that split keeping ends (internal/core/capture) pass them in, and callers that split on "\n" do not, so both are trimmed.
func IsEmptyValue ¶ added in v0.8.0
IsEmptyValue reports whether a frontmatter scalar carries no value at all. It is EmptinessOf's boolean, defined over it rather than beside it, so a gate and a reader can never disagree about what counts as empty.
This is the ONE emptiness question. Before it, record-lint's schema gate decided absence by comparing against a list of literals — `!!null` accepted and `!!null null` refused, though YAML makes them the same node — so closing the tenth spelling left the eleventh open, the arms race adr-56 ruled against one workstream over (iss-2608301808198621). Deciding on the node closes the spellings nobody enumerated at the same time as the ones that were.
It takes the RAW value, exactly as the same-line scanner read it. Quoting is what separates a null from a string in YAML and a caller that unquotes first destroys the distinction — and it strips ONE level of quoting only, so a value that is two apostrophes inside double quotes stays the two-character string it is (iss-2608301656192369).
func IsNull ¶
IsNull reports whether a frontmatter scalar is a YAML null.
The set is the YAML 1.2 core schema's, exactly: the empty value, "~", and the three spellings "null", "Null" and "NULL". It is deliberately NOT a case-insensitive compare — YAML does not accept "nUlL", and an EqualFold here would make abcd read records no YAML parser would agree with, which is worse than the miss it fixes (iss-287, reported as GitHub #290).
This is the ONE null predicate. internal/core/lint held a private copy that recognised only the lower-case spelling, so `impact: NULL` read as null in one gate and as a malformed impact in the other — the split-verdict shape that makes a record pass a lint and then fail the command that acts on it. Callers come here rather than re-deriving it.
The value must be the RAW scalar, before unquoting. Quoting is what separates a null from a string in YAML: bare null is a null, and "null" with its quotes is the three-character string. A caller that unquotes first destroys that distinction and cannot get it back.
func QuoteScalar ¶ added in v0.10.0
QuoteScalar renders s as a double-quoted frontmatter scalar: a backslash and a double quote are each written `\`-prefixed, and nothing else is escaped. It is the encoder Unquote mirrors, spelled once so a value written here reads back byte-for-byte through ScalarString.
It does not judge s. A caller writing a single-line key refuses a newline or any other control character BEFORE encoding, because a newline inside the quotes is a second frontmatter line to the same-line scanner, whatever YAML would make of it.
func ScalarString ¶ added in v0.10.0
ScalarString reads a raw same-line value — exactly as Fields returned it — as a populated single-line string scalar, and returns the string it spells. ok is false for every other shape: a blank or null value, an explicitly empty string, an empty or populated flow collection (`[…]`, `{…}`), a block-scalar header, and a quoted scalar its own line does not close.
It is the ONE definition of "a single-line string" for a frontmatter key whose value a command writes as one — the writer (core/intent's hold) and the gate that judges committed bytes (record-lint's record_provenance) both come here, so the gate refuses exactly the shapes the reader does not read, and a shape the reader accepts is never reported. Quoting is decoded by the package's own decoders: a double-quoted scalar through Unquote (the mirror of QuoteScalar), a single-quoted one by folding the doubled apostrophe.
It is a line scanner's reading, not a YAML parser's: a node tag or anchor in front of a bare scalar is returned as part of the string, because the tools that write these keys never emit one and a reader that stripped it would be claiming a grammar the scanner does not have.
func Split ¶ added in v0.7.0
Split separates a record file's leading frontmatter block from its BODY. The two returned strings concatenate back to the input byte for byte: head runs from the opening delimiter through the closing one and its line ending, and body is everything after it. Nothing is trimmed, so a caller that splices an edited body back onto head gets the file it read.
It exists so a record's writer and its readers can judge the SAME bytes. A writer handed the whole file looks for its section in the frontmatter too, and a `# Grounds` line there is a legal YAML comment \u2014 skipped by the block parser and matched by an ATX heading pattern \u2014 so the writer wrote into a pseudo-section the body reader never consults, agreed with itself on the read-back, and reported success about a value nothing could read (iss-2608301805069999).
A text with no opening delimiter, and a block nothing closes, are BOTH all body: there is no frontmatter to hold back, and holding back prose that no reader treats as frontmatter would hide it from the caller that asked for the body. The block's interior is not parsed here \u2014 which lines close it is IsDelimiter's rule, and an indented line is never a close, exactly as the strict ledger parser reads it.
func StringList ¶ added in v0.8.0
StringList parses an inline YAML flow sequence of strings — `["sprint", "milestone"]` — into its members, tolerating quotes and surrounding whitespace. An empty or null sequence yields nothing.
It is deliberately small: the frontmatter this package reads only ever writes the inline `[…]` form for a list, and a block sequence is a different shape the line scanner does not claim to read. It lives here for the reason IsNull and Unquote do — record-lint's forbidden-synonym rule and the glossary's own index both read the same `aliases`/`forbidden_synonyms` fields, and two readers of one field that drift make a term the one rule gates and the other does not.
A YAML null is NOT special-cased here, for the reason Unquote leaves quoting to its caller: `IsNull` is the one predicate that answers it, and a caller that means "absent" asks that question before asking this one.
func StripComment ¶ added in v0.8.0
StripComment removes a trailing YAML comment from the text that follows a key's colon, returning the value alone.
This is the ONE place a comment is separated from a value, and it belongs in the scanner rather than in any predicate downstream. Every gate reads its value through Fields, and each of record-lint's emptiness tests anchors on the value's LAST byte, so an unstripped `grounds: {} # todo` defeated all of them at once — the closing brace was no longer last — while `severity: minor # todo` reached the enum leg as the value `minor # todo`. Stripping in one predicate would have left the same escape open for every gate that reads a value some other way (iss-2608301744268001).
It is exported because the strict ledger parser (internal/core/capture) is a SECOND reader of the same bytes and calls it too. A gate exists to refuse exactly what the reader refuses, and a strip on one side alone would put the permissive verdict on the gate: `severity: minor # todo` lint-green and capture-refused, which is the split this repository closes wherever it finds one. Two callers, one rule.
The rule is YAML's, not a split on the first hash. A comment starts at a `#` that is preceded by whitespace and is outside a quoted scalar; a `#` inside quotes, or with no whitespace in front of it, is part of the value — which is why `slug: a#b` and a URL fragment survive intact. Inside a double-quoted scalar a backslash escapes the next byte, and inside a single-quoted one a doubled apostrophe is a literal apostrophe rather than the close, so neither hides a live quote from the scan.
The byte before the text handed here is the key's colon, never whitespace, so a leading `#` is content: `slug:#x` is not a comment. (It is not a mapping entry to a YAML parser either — a block key needs a space after its colon — but that leniency is the key pattern's, and widening it here would only turn one divergence into a second.)
func TrimBOM ¶
TrimBOM removes a single leading UTF-8 byte-order mark from s. The mark only ever appears as the file's very first bytes, so callers apply this to the first line before testing it for the opening `---`: a BOM sits invisibly ahead of the delimiter and, untrimmed, makes a well-formed record read as having no frontmatter — every frontmatter-keyed gate then passes it silently.
func Unquote ¶ added in v0.7.0
Unquote reverses the backslash escaping a double-quoted frontmatter scalar carries — the mirror of the escaping capture's yamlScalar emits, where a backslash and a double quote are each written `\`-prefixed.
This is the ONE decoder, for the reason IsNull above is the one null predicate. capture's reader and record-lint's schema gate each held a byte-identical private copy of this loop (iss-2608301212424896), which is the split-verdict shape in waiting: the gate exists to refuse exactly what the reader refuses, and two decoders that drift make a record the reader SKIPS go lint-green. Callers come here rather than re-deriving it.
The argument is the scalar's INNER text, with the surrounding quotes already removed: whether a value is double-quoted at all is the caller's question, and each caller answers it differently for its own reasons.
func UnquoteScalar ¶ added in v0.12.0
UnquoteScalar reads a raw value that may be a double-quoted scalar: a value that opens AND closes with a double quote has the pair stripped and its inner text decoded through Unquote, and quoted reports true; any other value is returned unchanged with quoted false, so a caller that reads a bare value differently (as a number, say) branches on it rather than re-testing the quotes.
Unquote takes the scalar's INNER text, so every reader holding a raw value has to strip the quotes first. Capture's reader, record-lint's schema gate and the cold-reading definition locator each kept a private copy of that strip, and a caller that forgot it refused well-formed records with a message comparing a value against itself (iss-2608311039531552). The strip lives here, beside the decoder, so a reader comes here rather than re-deriving it.
It trims nothing: whitespace around the value is the caller's to remove, as it was at every call site this replaces. A single-quoted value is returned as it stands; ScalarString is the reader that folds that spelling.
Types ¶
type Dup ¶ added in v0.6.7
Dup is a duplicated top-level key and the 1-based line of its SECOND (the offending) occurrence.
func Duplicates ¶ added in v0.6.7
Duplicates returns the top-level keys that appear more than once in the leading frontmatter block, one Dup per extra occurrence, in source order.
Fields keeps the FIRST occurrence and drops the rest silently — correct for a reader that wants one value, but it means a second `impact:` line hides the value a gate is armed to reject, and the strict ledger parser (internal/core/capture) refuses such a file outright (last-wins there would diverge from the first-occurrence rewrite setScalarField performs). A gate built on Fields calls Duplicates so it can refuse exactly what its consumer refuses (GitHub #357). The block-boundary contract is Fields': no leading `---` or no closing `---` yields nothing, so body prose is never scanned.
type Emptiness ¶ added in v0.8.0
type Emptiness int
Emptiness is what a frontmatter scalar carries, decided by the CLASS of YAML node it spells rather than by the literal it is written with.
The distinction between the three empty classes is not decoration. framework section 10 requires that "an absent stamp on an older record is evidence of that record's age and is never backfilled", and the W3 ruling it rests on says the distinction between an absent field and a recorded nullity "is what preserves the difference between a claim not carried and a claim considered and declined. Do not collapse them." A single boolean cannot hold that: a caller that must tell a deliberate `~` from a field left blank asks for the class, and a caller that only wants "does this carry anything" asks IsEmptyValue. Whether the KEY was written at all is a third question, and it is Fields' — a key nobody wrote is not in the map, which is not the same fact as a key written blank.
const ( // Populated: the node carries a value. Populated Emptiness = iota // Blank: nothing was written after the key, or nothing but whitespace. Blank // NullNode: a null was written down — `~`, `null`, an explicit null tag, or // a node whose properties are all that was written. NullNode // EmptyString: an explicitly quoted empty (or all-whitespace) string. EmptyString // EmptyCollection: an empty flow sequence or an empty flow mapping. EmptyCollection )
func EmptinessOf ¶ added in v0.8.0
EmptinessOf classifies a raw frontmatter scalar.
The reading is: strip the node's properties — its tag, its anchor, an alias in their place — and then judge what remains. That is the YAML node's own structure, so `!!null`, `!!null null`, `!<tag:yaml.org,2002:null>` and `&a !!null null` reach one verdict because they are one node, and a tag over a value (`!!int 3`) is the value it tags.
A node written with properties and NOTHING else is an empty node, which YAML resolves to null: `!!null`, a bare `&anchor`, and a tag naming any type over no content are all NullNode. An alias is read the same way, and that is a deliberate limit rather than an oversight — this is a line scanner with no anchor table, so `*a` cannot be resolved to the node it names, and the record enumerating these spellings lists a bare alias among the values that carry nothing here.
type Field ¶
type Field struct {
Value string
Line int
// SpacedKey is true when whitespace sits between the key and its colon
// (`held :`). The key is normalised either way and every reader honours it;
// the raw spelling is exposed for the rules that judge a command-written key
// against the one spelling the command writes (iss-2609210748122003).
SpacedKey bool
}
Field is a frontmatter key's value and its 1-based source line.