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:
@@ -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
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user