Build the frontmatter engine with byte-perfect round-trip

Yams validates and reads; writes are surgical line-span edits over
retained raw text, so untouched bytes — unknown keys, comments, odd
formatting, bodies — round-trip identically by construction. Strict
schema/order readers distinguish valid/missing/malformed for the
loader's fail-fast; lenient fields preserve malformed values verbatim;
modified-by is schema-owned. Adds the Yams package and the fixture
folder-reference wiring. 44 tests (105 cases).

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 15:26:12 -04:00
parent 954c4b351d
commit cb6f6100a7
8 changed files with 1162 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
import Foundation
import Yams
/// One `index.md`: YAML frontmatter between `---` delimiters, then the Markdown body.
///
/// The document keeps the file's raw text and edits it by line span, so anything the caller
/// does not touch is byte-identical on the way out including unknown keys, their order,
/// comments, blank lines, and the body. YAML is used to *validate and read*, never to write.
public struct FrontmatterDocument: Sendable, Equatable {
/// A contiguous run of frontmatter lines. `key == nil` is a preamble/orphan run of
/// comments or blank lines. `tail` is the trailing comment/blank lines the run picked up
/// held separately so `set`/`remove` never disturb them.
private struct Span: Sendable, Equatable {
var key: String?
var value: String
var tail: String
var text: String { value + tail }
}
private struct KeyedValue: Sendable, Equatable {
var key: String
var value: YAMLValue
}
private var openingDelimiter: String
private var closingDelimiter: String
private var spans: [Span]
private var values: [KeyedValue]
/// Everything after the closing delimiter line, verbatim.
public var body: String
/// An empty document, for files the app is creating rather than rewriting.
public init(body: String = "") {
openingDelimiter = "---\n"
closingDelimiter = "---\n"
spans = []
values = []
self.body = body
}
private init(openingDelimiter: String, closingDelimiter: String, spans: [Span], values: [KeyedValue], body: String) {
self.openingDelimiter = openingDelimiter
self.closingDelimiter = closingDelimiter
self.spans = spans
self.values = values
self.body = body
}
// MARK: - Parsing
public static func parse(_ text: String) throws(FrontmatterError) -> FrontmatterDocument {
let lines = Self.lines(of: text)
guard let first = lines.first, Self.isDelimiter(first) else { throw .missingOpeningDelimiter }
guard let closingIndex = lines.dropFirst().firstIndex(where: Self.isDelimiter) else {
throw .missingClosingDelimiter
}
let yamlLines = Array(lines[1 ..< closingIndex])
let node: Node?
do {
node = try Yams.compose(yaml: yamlLines.joined())
} catch let error as YamlError {
throw .unparseableYAML(message: "\(error)", line: Self.fileLine(of: error))
} catch {
throw .unparseableYAML(message: "\(error)", line: nil)
}
let mapping: Node.Mapping
switch node {
case .none:
mapping = Node.Mapping([])
case let .some(root):
if let rootMapping = root.mapping {
mapping = rootMapping
} else if Resolver.default.resolveTag(of: root) == .null {
mapping = Node.Mapping([])
} else {
throw .frontmatterNotAMapping
}
}
let keys = mapping.map { $0.key.string }
let values = zip(keys, mapping).compactMap { key, pair -> KeyedValue? in
key.map { KeyedValue(key: $0, value: YAMLValue(pair.value)) }
}
return FrontmatterDocument(
openingDelimiter: first,
closingDelimiter: lines[closingIndex],
spans: Self.makeSpans(lines: yamlLines, keys: keys),
values: values,
body: lines[(closingIndex + 1)...].joined()
)
}
public func serialized() -> String {
openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body
}
// MARK: - Generic access
/// Top-level keys in document order.
public var keys: [String] { values.map(\.key) }
public var fields: [FrontmatterField] {
values.map { FrontmatterField(key: $0.key, value: $0.value, rawValue: rawValue(for: $0.key) ?? $0.value.description) }
}
/// Every key the schema does not own reserved names (`labels`, `due`, `template`, ) and
/// agent overlays alike. Preserved verbatim and never interpreted.
public var unknownFields: [FrontmatterField] {
fields.filter { !FrontmatterKeys.schemaOwned.contains($0.key) }
}
public func contains(_ key: String) -> Bool {
values.contains { $0.key == key }
}
public func value(for key: String) -> YAMLValue? {
values.first { $0.key == key }?.value
}
/// The key's value text exactly as written, minus the `key:` header and surrounding whitespace.
public func rawValue(for key: String) -> String? {
spans.first { $0.key == key }.map { Self.valueText(of: $0.value, key: key) }
}
// MARK: - Surgical edits
/// Rewrites only this key's value lines. A new key is appended before the closing delimiter.
public mutating func set(_ key: String, to value: FrontmatterValue) {
if let index = spans.firstIndex(where: { $0.key == key }) {
let keyText = Self.keyText(of: spans[index].value, key: key)
spans[index].value = "\(keyText): \(value.yamlText)\n"
} else {
spans.append(Span(key: key, value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\n", tail: ""))
}
let parsed = KeyedValue(key: key, value: value.parsedValue)
if let index = values.firstIndex(where: { $0.key == key }) {
values[index] = parsed
} else {
values.append(parsed)
}
}
/// Deletes the key's value lines. Trailing comments and blank lines survive they read as
/// belonging to whatever follows, and losing user text is worse than an orphaned comment.
public mutating func remove(_ key: String) {
if let index = spans.firstIndex(where: { $0.key == key }) {
if spans[index].tail.isEmpty {
spans.remove(at: index)
} else {
spans[index] = Span(key: nil, value: "", tail: spans[index].tail)
}
}
values.removeAll { $0.key == key }
}
// MARK: - Line splitting
private static func lines(of text: String) -> [String] {
let parts = text.components(separatedBy: "\n")
var lines = parts.dropLast().map { $0 + "\n" }
if let last = parts.last, !last.isEmpty { lines.append(last) }
return lines
}
private static func isDelimiter(_ line: String) -> Bool {
var trimmed = Substring(line)
while let last = trimmed.last, last.isWhitespace { trimmed = trimmed.dropLast() }
return trimmed == "---"
}
/// Splits the YAML block into per-key spans. A line only starts a span when it opens the key
/// the parser expects next, so an unindented value continuation (multi-line flow collections)
/// can never be mistaken for a key.
private static func makeSpans(lines: [String], keys: [String?]) -> [Span] {
var spans: [Span] = []
var current: Span?
var preamble = ""
var expected = 0
func flush() {
if let current {
spans.append(current)
} else if !preamble.isEmpty {
spans.append(Span(key: nil, value: "", tail: preamble))
}
}
for line in lines {
if expected < keys.count, let key = keys[expected], lineStartsKey(line, key: key) {
flush()
preamble = ""
current = Span(key: key, value: line, tail: "")
expected += 1
} else if var span = current {
if isTailLine(line) {
span.tail += line
} else {
span.value += span.tail + line
span.tail = ""
}
current = span
} else {
preamble += line
}
}
flush()
return spans
}
private static func isTailLine(_ line: String) -> Bool {
line.hasPrefix("#") || line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private static func keyCandidates(_ key: String) -> [String] {
[key, "\"\(key)\"", "'\(key)'"]
}
private static func lineStartsKey(_ line: String, key: String) -> Bool {
for candidate in keyCandidates(key) {
let prefix = candidate + ":"
guard line.hasPrefix(prefix) else { continue }
let rest = line.dropFirst(prefix.count)
if rest.isEmpty || rest.first?.isWhitespace == true { return true }
}
return false
}
private static func keyText(of spanValue: String, key: String) -> String {
keyCandidates(key).first { spanValue.hasPrefix($0 + ":") } ?? FrontmatterValue.emitScalar(key)
}
private static func valueText(of spanValue: String, key: String) -> String {
let header = keyText(of: spanValue, key: key) + ":"
guard spanValue.hasPrefix(header) else {
return spanValue.trimmingCharacters(in: .whitespacesAndNewlines)
}
return spanValue.dropFirst(header.count).trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func fileLine(of error: YamlError) -> Int? {
// +1 for the opening delimiter line, which is not part of the YAML handed to Yams.
switch error {
case let .scanner(_, _, mark, _), let .parser(_, _, mark, _), let .composer(_, _, mark, _):
mark.line + 1
case let .duplicatedKeysInMapping(_, context):
context.mark.line + 1
default:
nil
}
}
}
/// The keys the schema owns (01-storage-format.md § Frontmatter). Everything else is an
/// unknown key: round-tripped untouched, never interpreted.
public enum FrontmatterKeys {
public static let schema = "schema"
public static let title = "title"
public static let order = "order"
public static let width = "width"
public static let created = "created"
public static let modified = "modified"
public static let modifiedBy = "modified-by"
public static let deleted = "deleted"
public static let background = "background"
public static let icon = "icon"
public static let iconColor = "iconColor"
public static let schemaOwned: Set<String> = [
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor
]
}
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// Structural failures parsing an `index.md`. Everything here is fail-fast material
/// (01-storage-format.md § Malformed input); lenient field problems never surface as errors.
public enum FrontmatterError: Error, Equatable, Sendable, CustomStringConvertible {
case missingOpeningDelimiter
case missingClosingDelimiter
/// `line` is 1-based within the whole file, not within the YAML block.
case unparseableYAML(message: String, line: Int?)
case frontmatterNotAMapping
public var description: String {
switch self {
case .missingOpeningDelimiter:
"File does not start with a '---' frontmatter delimiter"
case .missingClosingDelimiter:
"Frontmatter is never closed by a '---' delimiter"
case let .unparseableYAML(message, line):
line.map { "Unparseable YAML at line \($0): \(message)" } ?? "Unparseable YAML: \(message)"
case .frontmatterNotAMapping:
"Frontmatter is not a YAML mapping"
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import Foundation
import Yams
/// One top-level frontmatter entry: what YAML made of it plus the text as written.
public struct FrontmatterField: Sendable, Equatable {
public let key: String
public let value: YAMLValue
public let rawValue: String
}
/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`,
/// `order`) from being silently coerced and lenient fields (colors, icons, `width`) from
/// erroring the loader decides which reaction each one gets.
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
case missing
case valid(Value)
case malformed(raw: String)
public var value: Value? {
if case let .valid(value) = self { return value }
return nil
}
public var isMissing: Bool {
if case .missing = self { return true }
return false
}
public var isMalformed: Bool {
if case .malformed = self { return true }
return false
}
/// The value as written, for a malformed field.
public var rawText: String? {
if case let .malformed(raw) = self { return raw }
return nil
}
}
extension FrontmatterDocument {
// MARK: - Strict (structure the loader fails fast on `.malformed`)
public var schema: FieldValue<Int> {
read(FrontmatterKeys.schema) { if case let .int(value) = $0 { value } else { nil } }
}
public var order: FieldValue<Double> {
read(FrontmatterKeys.order) {
switch $0 {
case let .int(value): Double(value)
case let .double(value): value
default: nil
}
}
}
// MARK: - Lenient (a malformed value is preserved and simply not used)
public var title: FieldValue<String> { read(FrontmatterKeys.title, Self.string) }
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
/// Width multiplier; anything but an integer 1 is malformed and renders as the default 1.
public var width: FieldValue<Int> {
read(FrontmatterKeys.width) { if case let .int(value) = $0, value >= 1 { value } else { nil } }
}
public var created: FieldValue<Date> { read(FrontmatterKeys.created, Self.date) }
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified, Self.date) }
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted, Self.date) }
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
// MARK: -
private func read<Value>(_ key: String, _ transform: (YAMLValue) -> Value?) -> FieldValue<Value> {
guard let value = value(for: key) else { return .missing }
if case .null = value { return .missing }
if let typed = transform(value) { return .valid(typed) }
return .malformed(raw: rawValue(for: key) ?? value.description)
}
private static func string(_ value: YAMLValue) -> String? {
if case let .string(text) = value { return text }
return nil
}
/// A quoted timestamp reads the same as an unquoted one same YAML 1.1 timestamp grammar.
private static func date(_ value: YAMLValue) -> Date? {
switch value {
case let .date(date): date
case let .string(text): Date.construct(from: Node.Scalar(text))
default: nil
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import Foundation
import Yams
/// A value being written into frontmatter. The engine emits these as minimal YAML scalars
/// it never round-trips a whole document through a YAML emitter.
public enum FrontmatterValue: Sendable, Equatable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case date(Date)
/// Verbatim YAML supplied by the caller (flow collections, block scalars). Not validated.
case raw(String)
/// The scalar text placed after `key: `.
public var yamlText: String {
switch self {
case let .string(value): Self.emitScalar(value)
case let .int(value): String(value)
case let .double(value): Self.emitDouble(value)
case let .bool(value): value ? "true" : "false"
case let .date(value): value.formatted(.iso8601)
case let .raw(value): value
}
}
var parsedValue: YAMLValue {
switch self {
case let .string(value): .string(value)
case let .int(value): .int(value)
case let .double(value): .double(value)
case let .bool(value): .bool(value)
case let .date(value): .date(value)
case let .raw(value):
if let node = try? Yams.compose(yaml: value) { YAMLValue(node) } else { .string(value) }
}
}
}
extension FrontmatterValue {
private static func emitDouble(_ value: Double) -> String {
if value.isNaN { return ".nan" }
if value.isInfinite { return value < 0 ? "-.inf" : ".inf" }
if value == value.rounded(), abs(value) < 1e15 { return String(Int64(value)) }
return String(value)
}
/// Plain when YAML round-trips it back to the same string, double-quoted otherwise.
/// The round-trip check is the authority: it catches leading/trailing space, comment
/// introducers, and anything that would resolve to a bool/int/float/null/timestamp.
static func emitScalar(_ value: String) -> String {
guard !value.isEmpty, !value.unicodeScalars.contains(where: { $0 == "\n" || $0 == "\r" }) else {
return quoted(value)
}
guard let node = try? Yams.compose(yaml: "v: \(value)"),
let mapping = node.mapping,
let scalar = mapping["v"],
Resolver.default.resolveTag(of: scalar) == .str,
scalar.scalar?.string == value
else { return quoted(value) }
return value
}
private static func quoted(_ value: String) -> String {
var out = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\\": out += "\\\\"
case "\"": out += "\\\""
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if scalar.value < 0x20 || scalar.value == 0x7F {
out += String(format: "\\x%02x", scalar.value)
} else {
out.unicodeScalars.append(scalar)
}
}
}
return out + "\""
}
}
+78
View File
@@ -0,0 +1,78 @@
import Foundation
import Yams
/// A `Sendable` snapshot of a parsed YAML value. Mappings keep document order; this is a
/// read-only view the engine never serializes through it (see `FrontmatterDocument`).
public enum YAMLValue: Sendable, Equatable {
case null
case bool(Bool)
case int(Int)
case double(Double)
case string(String)
case date(Date)
case sequence([YAMLValue])
case mapping([Pair])
public struct Pair: Sendable, Equatable {
public let key: YAMLValue
public let value: YAMLValue
public init(key: YAMLValue, value: YAMLValue) {
self.key = key
self.value = value
}
}
}
extension YAMLValue {
init(_ node: Node, resolver: Resolver = .default) {
switch node {
case let .scalar(scalar):
self = YAMLValue(scalar: scalar, tag: resolver.resolveTag(of: node))
case let .sequence(sequence):
self = .sequence(sequence.map { YAMLValue($0, resolver: resolver) })
case let .mapping(mapping):
self = .mapping(mapping.map {
Pair(key: YAMLValue($0.key, resolver: resolver), value: YAMLValue($0.value, resolver: resolver))
})
case .alias:
self = .null
}
}
private init(scalar: Node.Scalar, tag: Tag.Name) {
switch tag {
case .null:
self = .null
case .bool:
self = Bool.construct(from: scalar).map(YAMLValue.bool) ?? .string(scalar.string)
case .int:
self = Int.construct(from: scalar).map(YAMLValue.int)
?? Double.construct(from: scalar).map(YAMLValue.double)
?? .string(scalar.string)
case .float:
self = Double.construct(from: scalar).map(YAMLValue.double) ?? .string(scalar.string)
case .timestamp:
self = Date.construct(from: scalar).map(YAMLValue.date) ?? .string(scalar.string)
default:
self = .string(scalar.string)
}
}
}
extension YAMLValue: CustomStringConvertible {
/// Diagnostic rendering only never used to write files.
public var description: String {
switch self {
case .null: "null"
case let .bool(value): value ? "true" : "false"
case let .int(value): String(value)
case let .double(value): String(value)
case let .string(value): value
case let .date(value): value.formatted(.iso8601)
case let .sequence(values): "[" + values.map(\.description).joined(separator: ", ") + "]"
case let .mapping(pairs):
"{" + pairs.map { "\($0.key.description): \($0.value.description)" }.joined(separator: ", ") + "}"
}
}
}