From cb6f6100a73b4cfe12fe0b22bf10e99b447cc60f Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 15:26:12 -0400 Subject: [PATCH] Build the frontmatter engine with byte-perfect round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Fixtures/README.md | 5 + Kanban/Storage/FrontmatterDocument.swift | 277 +++++++++++ Kanban/Storage/FrontmatterError.swift | 24 + Kanban/Storage/FrontmatterFields.swift | 99 ++++ Kanban/Storage/FrontmatterValue.swift | 83 ++++ Kanban/Storage/YAMLValue.swift | 78 +++ KanbanTests/FrontmatterTests.swift | 586 +++++++++++++++++++++++ project.yml | 10 + 8 files changed, 1162 insertions(+) create mode 100644 Fixtures/README.md create mode 100644 Kanban/Storage/FrontmatterDocument.swift create mode 100644 Kanban/Storage/FrontmatterError.swift create mode 100644 Kanban/Storage/FrontmatterFields.swift create mode 100644 Kanban/Storage/FrontmatterValue.swift create mode 100644 Kanban/Storage/YAMLValue.swift create mode 100644 KanbanTests/FrontmatterTests.swift diff --git a/Fixtures/README.md b/Fixtures/README.md new file mode 100644 index 0000000..ccb10e7 --- /dev/null +++ b/Fixtures/README.md @@ -0,0 +1,5 @@ +# Fixture boards + +Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook. + +Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift new file mode 100644 index 0000000..9936530 --- /dev/null +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -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 = [ + schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor + ] +} diff --git a/Kanban/Storage/FrontmatterError.swift b/Kanban/Storage/FrontmatterError.swift new file mode 100644 index 0000000..df6f8b0 --- /dev/null +++ b/Kanban/Storage/FrontmatterError.swift @@ -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" + } + } +} diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift new file mode 100644 index 0000000..9e89e34 --- /dev/null +++ b/Kanban/Storage/FrontmatterFields.swift @@ -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: 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 { + read(FrontmatterKeys.schema) { if case let .int(value) = $0 { value } else { nil } } + } + + public var order: FieldValue { + 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 { read(FrontmatterKeys.title, Self.string) } + public var background: FieldValue { read(FrontmatterKeys.background, Self.string) } + public var icon: FieldValue { read(FrontmatterKeys.icon, Self.string) } + public var iconColor: FieldValue { read(FrontmatterKeys.iconColor, Self.string) } + + /// Width multiplier; anything but an integer ≥ 1 is malformed and renders as the default 1. + public var width: FieldValue { + read(FrontmatterKeys.width) { if case let .int(value) = $0, value >= 1 { value } else { nil } } + } + + public var created: FieldValue { read(FrontmatterKeys.created, Self.date) } + public var modified: FieldValue { read(FrontmatterKeys.modified, Self.date) } + public var deleted: FieldValue { read(FrontmatterKeys.deleted, Self.date) } + + /// Schema-owned, not an unknown key: the app clears it on every app-mediated write. + public var modifiedBy: FieldValue { read(FrontmatterKeys.modifiedBy, Self.string) } + + // MARK: - + + private func read(_ key: String, _ transform: (YAMLValue) -> Value?) -> FieldValue { + 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 + } + } +} diff --git a/Kanban/Storage/FrontmatterValue.swift b/Kanban/Storage/FrontmatterValue.swift new file mode 100644 index 0000000..108e765 --- /dev/null +++ b/Kanban/Storage/FrontmatterValue.swift @@ -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 + "\"" + } +} diff --git a/Kanban/Storage/YAMLValue.swift b/Kanban/Storage/YAMLValue.swift new file mode 100644 index 0000000..96ea7f3 --- /dev/null +++ b/Kanban/Storage/YAMLValue.swift @@ -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: ", ") + "}" + } + } +} diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift new file mode 100644 index 0000000..40f4d44 --- /dev/null +++ b/KanbanTests/FrontmatterTests.swift @@ -0,0 +1,586 @@ +import Foundation +import Testing +@testable import Kanban + +// MARK: - Fixtures + +private enum Fixture { + static let rich = """ + --- + # board settings, hand-written + schema: 1 + title: My Board + + project: lanework # agent overlay + sphere: work + labels: [a, b, c] + template: {order: 3} + notes: | + line one + indented two + folded: > + wrapped + text + created: 2026-07-26T16:41:38Z + modified-by: claude + tagged: !!str 42 + "quoted key": yes + # trailing note + --- + Body text. + + More body — with *markdown*. + """ + + static let minimal = """ + --- + schema: 1 + order: 1024 + title: Thing + --- + Body + """ + + /// Verbatim copy of a real card written by an agent. + static let realCard = """ + --- + schema: 1 + title: Build the frontmatter engine with byte-perfect round-trip + order: 3072 + created: 2026-07-26T16:41:38Z + modified: 2026-07-26T19:06:55Z + modified-by: claude + source: DESIGN/01-storage-format.md + labels: [m1-storage-read] + --- + Build the Frontmatter component. + **Design constraints:** + - index.md is YAML frontmatter between `---` delimiters, then a Markdown body. + + """ + + /// A flow mapping whose continuation sits at column 0 — the shape that would fool a naive + /// "any `key:` at column 0 starts a field" scanner. + static let unindentedFlow = """ + --- + flow: {a: 1, + b: 2} + last: x + --- + body + """ +} + +private func parseError(_ text: String) -> FrontmatterError? { + do { + _ = try FrontmatterDocument.parse(text) + return nil + } catch { + return error + } +} + +// MARK: - Round-trip + +struct FrontmatterRoundTripTests { + @Test(arguments: [ + Fixture.rich, + Fixture.minimal, + Fixture.realCard, + Fixture.unindentedFlow, + // body with a trailing newline + "---\nschema: 1\n---\nbody\n", + // body with several trailing newlines + "---\nschema: 1\n---\nbody\n\n\n", + // no body at all, closing delimiter unterminated + "---\nschema: 1\n---", + // no body, closing delimiter terminated + "---\nschema: 1\n---\n", + // empty frontmatter mapping + "---\n---\nbody\n", + // frontmatter that is only blank lines + "---\n\n\n---\nbody\n", + // frontmatter that is only comments + "---\n# nothing to see\n---\nbody\n", + // explicit empty flow mapping + "---\n{}\n---\nbody\n", + // top-level flow mapping + "---\n{schema: 1, order: 5}\n---\nbody\n", + // odd key order, blank lines between entries + "---\nzzz: last\n\norder: 5\n\n\nschema: 1\n---\nbody\n", + // block scalar keep/strip indicators + "---\nkeep: |+\n text\n\nstrip: |-\n text\n---\nbody\n", + // anchors and aliases + "---\nbase: &b\n x: 1\ncopy: *b\n---\nbody\n", + // quoted values with escapes and embedded delimiters + "---\ntitle: \"a \\\"quoted\\\" --- thing\"\nother: 'single ---'\n---\nbody\n", + // a --- inside a block scalar (indented, so not a delimiter) + "---\nnote: |\n ---\n still the value\n---\nbody\n", + // delimiter line with trailing spaces + "--- \nschema: 1\n--- \nbody\n", + // no trailing newline on the body + "---\nschema: 1\n---\nbody without newline", + // CRLF throughout + "---\r\nschema: 1\r\n---\r\nbody\r\n", + ]) + func serializingAnUntouchedDocumentIsByteIdentical(text: String) throws { + let document = try FrontmatterDocument.parse(text) + #expect(document.serialized() == text) + } + + @Test func bodyIsEverythingAfterTheClosingDelimiter() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.body == "Body text.\n\nMore body — with *markdown*.") + } + + @Test func emptyBodyIsEmpty() throws { + #expect(try FrontmatterDocument.parse("---\nschema: 1\n---\n").body == "") + #expect(try FrontmatterDocument.parse("---\nschema: 1\n---").body == "") + } + + @Test func bodyTrailingNewlinesAreNotNormalized() throws { + let document = try FrontmatterDocument.parse("---\nschema: 1\n---\nbody\n\n\n") + #expect(document.body == "body\n\n\n") + } +} + +// MARK: - Malformed input + +struct FrontmatterMalformedTests { + @Test func missingOpeningDelimiter() { + #expect(parseError("schema: 1\n---\nbody\n") == .missingOpeningDelimiter) + #expect(parseError("") == .missingOpeningDelimiter) + #expect(parseError("\n---\nschema: 1\n---\n") == .missingOpeningDelimiter) + #expect(parseError("----\nschema: 1\n---\n") == .missingOpeningDelimiter) + #expect(parseError("--- yaml\nschema: 1\n---\n") == .missingOpeningDelimiter) + } + + @Test func missingClosingDelimiter() { + #expect(parseError("---\nschema: 1\nbody\n") == .missingClosingDelimiter) + #expect(parseError("---\n") == .missingClosingDelimiter) + #expect(parseError("---") == .missingClosingDelimiter) + } + + @Test(arguments: [ + "---\nfoo: [1, 2\n---\nbody\n", + "---\nschema: 1\n\tindented-with-tab: 2\n---\nbody\n", + "---\nschema: 1\n bad: indent\n---\nbody\n", + "---\ndupe: 1\ndupe: 2\n---\nbody\n", + "---\n\"unterminated: 1\n---\nbody\n", + "---\nfoo: {a: 1\n---\nbody\n", + "---\n*undefined-alias\n---\nbody\n", + ]) + func unparseableYAML(text: String) { + guard case .unparseableYAML = parseError(text) else { + Issue.record("expected .unparseableYAML for \(text.debugDescription), got \(String(describing: parseError(text)))") + return + } + } + + @Test func unparseableYAMLCarriesAFileRelativeLineNumber() { + guard case let .unparseableYAML(message, line) = parseError("---\nschema: 1\n bad: indent\n---\n") else { + Issue.record("expected .unparseableYAML") + return + } + #expect(line == 3) + #expect(!message.isEmpty) + } + + @Test(arguments: [ + "---\n- a\n- b\n---\nbody\n", + "---\njust a scalar\n---\nbody\n", + "---\n42\n---\nbody\n", + "---\n[1, 2]\n---\nbody\n", + ]) + func frontmatterNotAMapping(text: String) { + #expect(parseError(text) == .frontmatterNotAMapping) + } + + @Test(arguments: [ + "---\n---\nbody\n", + "---\n\n---\nbody\n", + "---\n# just a comment\n---\nbody\n", + "---\n{}\n---\nbody\n", + "---\nnull\n---\nbody\n", + "---\n~\n---\nbody\n", + ]) + func emptyFrontmatterIsAnEmptyMapping(text: String) throws { + let document = try FrontmatterDocument.parse(text) + #expect(document.keys.isEmpty) + #expect(document.serialized() == text) + } + + @Test func wellFormedButUnusualYAMLIsAccepted() throws { + for text in [Fixture.rich, Fixture.unindentedFlow, "---\nbase: &b\n x: 1\ncopy: *b\n---\n"] { + #expect(parseError(text) == nil) + } + } +} + +// MARK: - Generic access + +struct FrontmatterAccessTests { + @Test func keysAreInDocumentOrder() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.keys == [ + "schema", "title", "project", "sphere", "labels", "template", + "notes", "folded", "created", "modified-by", "tagged", "quoted key", + ]) + } + + @Test func realCardReadsAsExpected() throws { + let document = try FrontmatterDocument.parse(Fixture.realCard) + #expect(document.schema == .valid(1)) + #expect(document.order == .valid(3072)) + #expect(document.title == .valid("Build the frontmatter engine with byte-perfect round-trip")) + #expect(document.modifiedBy == .valid("claude")) + #expect(document.created.value != nil) + #expect(document.unknownFields.map(\.key) == ["source", "labels"]) + #expect(document.body.hasPrefix("Build the Frontmatter component.\n")) + } + + @Test func unknownKeysExcludeSchemaOwnedOnes() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.unknownFields.map(\.key) == [ + "project", "sphere", "labels", "template", "notes", "folded", "tagged", "quoted key", + ]) + } + + @Test func modifiedByIsExposedDistinctlyAndIsNotAnUnknownKey() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.modifiedBy == .valid("claude")) + #expect(!document.unknownFields.contains { $0.key == "modified-by" }) + #expect(document.contains("modified-by")) + } + + @Test func reservedKeysAreOrdinaryUnknownKeys() throws { + let text = """ + --- + schema: 1 + labels: [bug, ui] + assignees: [ann] + due: 2026-08-01 + remote: {kind: gitea} + remote-state: synced + template: {order: 2} + --- + body + + """ + let document = try FrontmatterDocument.parse(text) + #expect(document.unknownFields.map(\.key) + == ["labels", "assignees", "due", "remote", "remote-state", "template"]) + #expect(document.serialized() == text) + } + + @Test func parsedValuesAreAvailableForUnknownKeys() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.value(for: "labels") == .sequence([.string("a"), .string("b"), .string("c")])) + #expect(document.value(for: "template") + == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(3))])) + #expect(document.value(for: "quoted key") == .bool(true)) + #expect(document.value(for: "tagged") == .string("42")) + #expect(document.value(for: "nope") == nil) + } + + @Test func rawValuePreservesBlockScalarsVerbatim() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.rawValue(for: "notes") == "|\n line one\n indented two") + #expect(document.rawValue(for: "labels") == "[a, b, c]") + #expect(document.rawValue(for: "schema") == "1") + } +} + +// MARK: - Strict fields + +struct FrontmatterStrictFieldTests { + private func document(_ frontmatter: String) throws -> FrontmatterDocument { + try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n") + } + + @Test func schemaValidMissingMalformed() throws { + #expect(try document("schema: 1").schema == .valid(1)) + #expect(try document("title: x").schema == .missing) + #expect(try document("schema:").schema == .missing) + #expect(try document("schema: banana").schema == .malformed(raw: "banana")) + #expect(try document("schema: 1.5").schema == .malformed(raw: "1.5")) + #expect(try document("schema: [1]").schema == .malformed(raw: "[1]")) + } + + @Test func orderValidMissingMalformed() throws { + #expect(try document("order: 1024").order == .valid(1024)) + #expect(try document("order: 1536.5").order == .valid(1536.5)) + #expect(try document("order: -1024").order == .valid(-1024)) + #expect(try document("schema: 1").order == .missing) + #expect(try document("order:").order == .missing) + } + + @Test func nonNumericOrderIsMalformedNotNil() throws { + let malformed = try document("order: banana").order + #expect(malformed == .malformed(raw: "banana")) + #expect(malformed.value == nil) + #expect(malformed.isMalformed) + #expect(!malformed.isMissing) + #expect(malformed.rawText == "banana") + + #expect(try document("order: \"1024\"").order == .malformed(raw: "\"1024\"")) + #expect(try document("order: [1]").order == .malformed(raw: "[1]")) + } +} + +// MARK: - Lenient fields + +struct FrontmatterLenientFieldTests { + private func document(_ frontmatter: String) throws -> FrontmatterDocument { + try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n") + } + + @Test func wellFormedLenientValues() throws { + #expect(try document("title: My Board").title == .valid("My Board")) + #expect(try document("background: \"#ff8800\"").background == .valid("#ff8800")) + #expect(try document("background: slate").background == .valid("slate")) + #expect(try document("icon: tray.full").icon == .valid("tray.full")) + #expect(try document("iconColor: teal").iconColor == .valid("teal")) + #expect(try document("width: 3").width == .valid(3)) + #expect(try document("width: 1").width == .valid(1)) + } + + @Test func malformedLenientValuesArePreservedVerbatimAndDoNotThrow() throws { + #expect(try document("background: [red, blue]").background == .malformed(raw: "[red, blue]")) + #expect(try document("background: 42").background == .malformed(raw: "42")) + #expect(try document("icon: {a: 1}").icon == .malformed(raw: "{a: 1}")) + #expect(try document("iconColor: true").iconColor == .malformed(raw: "true")) + #expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]")) + } + + @Test func widthIsLenientForAnythingButAnIntegerAtLeastOne() throws { + #expect(try document("width: 0").width == .malformed(raw: "0")) + #expect(try document("width: -3").width == .malformed(raw: "-3")) + #expect(try document("width: 1.5").width == .malformed(raw: "1.5")) + #expect(try document("width: wide").width == .malformed(raw: "wide")) + #expect(try document("schema: 1").width == .missing) + } + + @Test func malformedLenientValuesStillRoundTrip() throws { + let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 0\nicon: {a: 1}\n---\nbody\n" + let document = try FrontmatterDocument.parse(text) + #expect(document.serialized() == text) + #expect(document.background.isMalformed) + #expect(document.width.isMalformed) + #expect(document.icon.isMalformed) + } + + @Test func timestampsReadPlainAndQuoted() throws { + let expected = Date(timeIntervalSince1970: 1_785_084_098) // 2026-07-26T16:41:38Z + #expect(try document("created: 2026-07-26T16:41:38Z").created == .valid(expected)) + #expect(try document("modified: \"2026-07-26T16:41:38Z\"").modified == .valid(expected)) + #expect(try document("deleted: 2026-07-26T16:41:38Z").deleted == .valid(expected)) + #expect(try document("schema: 1").deleted == .missing) + #expect(try document("created: never").created == .malformed(raw: "never")) + } +} + +// MARK: - Surgical edits + +struct FrontmatterEditTests { + @Test func settingOneKeyLeavesEveryOtherByteIdentical() throws { + var document = try FrontmatterDocument.parse(Fixture.rich) + document.set("title", to: .string("Renamed")) + let output = document.serialized() + + let expected = Fixture.rich.replacingOccurrences(of: "title: My Board", with: "title: Renamed") + #expect(output == expected) + + // Prefix and suffix around the edited span are untouched. + let marker = "title: " + let originalPrefix = Fixture.rich.prefix(while: { _ in true }).components(separatedBy: marker)[0] + #expect(output.hasPrefix(originalPrefix)) + #expect(output.hasSuffix("\nMore body — with *markdown*.")) + } + + @Test func settingAKeyPreservesItsTrailingCommentsAndBlankLines() throws { + let text = "---\nschema: 1\nproject: lanework # agent overlay\n\n# a note\norder: 5\n---\nbody\n" + var document = try FrontmatterDocument.parse(text) + document.set("project", to: .string("kanban")) + #expect(document.serialized() + == "---\nschema: 1\nproject: kanban\n\n# a note\norder: 5\n---\nbody\n") + } + + @Test func appendingANewKeyGoesBeforeTheClosingDelimiter() throws { + var document = try FrontmatterDocument.parse(Fixture.minimal) + document.set("modified", to: .date(Date(timeIntervalSince1970: 1_785_084_098))) + #expect(document.serialized() == """ + --- + schema: 1 + order: 1024 + title: Thing + modified: 2026-07-26T16:41:38Z + --- + Body + """) + #expect(document.keys == ["schema", "order", "title", "modified"]) + } + + @Test func appendingToEmptyFrontmatter() throws { + var document = try FrontmatterDocument.parse("---\n---\nbody\n") + document.set("schema", to: .int(1)) + #expect(document.serialized() == "---\nschema: 1\n---\nbody\n") + #expect(document.schema == .valid(1)) + } + + @Test func removingAKeyDeletesOnlyItsLines() throws { + var document = try FrontmatterDocument.parse(Fixture.minimal) + document.remove("order") + #expect(document.serialized() == "---\nschema: 1\ntitle: Thing\n---\nBody") + #expect(document.order == .missing) + #expect(document.keys == ["schema", "title"]) + } + + @Test func removingAKeyKeepsTrailingComments() throws { + var document = try FrontmatterDocument.parse("---\na: 1\n# note about b\nb: 2\n---\nbody\n") + document.remove("a") + #expect(document.serialized() == "---\n# note about b\nb: 2\n---\nbody\n") + } + + @Test func removingAnAbsentKeyIsANoOp() throws { + var document = try FrontmatterDocument.parse(Fixture.minimal) + document.remove("nope") + #expect(document.serialized() == Fixture.minimal) + } + + @Test func editingAKeyWhoseValueWasABlockScalar() throws { + var document = try FrontmatterDocument.parse(Fixture.rich) + document.set("notes", to: .string("flattened")) + #expect(document.serialized().contains("notes: flattened\nfolded: >\n")) + #expect(!document.serialized().contains("line one")) + #expect(document.value(for: "notes") == .string("flattened")) + // Everything after the edited span is untouched. + #expect(document.serialized().hasSuffix("More body — with *markdown*.")) + } + + @Test func editingAKeyAfterAnUnindentedFlowContinuation() throws { + var document = try FrontmatterDocument.parse(Fixture.unindentedFlow) + document.set("last", to: .string("y")) + #expect(document.serialized() == "---\nflow: {a: 1,\nb: 2}\nlast: y\n---\nbody") + } + + @Test func editingAQuotedKeyKeepsItsOriginalSpelling() throws { + var document = try FrontmatterDocument.parse("---\n\"quoted key\": one\n---\nbody\n") + document.set("quoted key", to: .string("two")) + #expect(document.serialized() == "---\n\"quoted key\": two\n---\nbody\n") + } + + @Test func buildingADocumentFromScratch() throws { + var document = FrontmatterDocument(body: "The card body.\n") + document.set("schema", to: .int(1)) + document.set("order", to: .double(1024)) + document.set("title", to: .string("New card")) + let text = document.serialized() + #expect(text == "---\nschema: 1\norder: 1024\ntitle: New card\n---\nThe card body.\n") + #expect(try FrontmatterDocument.parse(text).serialized() == text) + } + + @Test func settingTheBodyReplacesOnlyTheBody() throws { + var document = try FrontmatterDocument.parse(Fixture.rich) + document.body = "Brand new body.\n" + let output = document.serialized() + #expect(output.hasSuffix("---\nBrand new body.\n")) + #expect(output.hasPrefix("---\n# board settings, hand-written\nschema: 1\n")) + #expect(!output.contains("More body")) + } + + @Test func editsSurviveReparsing() throws { + var document = try FrontmatterDocument.parse(Fixture.rich) + document.set("order", to: .double(2048)) + document.remove("modified-by") + document.set("title", to: .string("Renamed")) + + let reparsed = try FrontmatterDocument.parse(document.serialized()) + #expect(reparsed.order == .valid(2048)) + #expect(reparsed.modifiedBy == .missing) + #expect(reparsed.title == .valid("Renamed")) + #expect(reparsed.unknownFields.map(\.key) + == ["project", "sphere", "labels", "template", "notes", "folded", "tagged", "quoted key"]) + #expect(reparsed.serialized() == document.serialized()) + } +} + +// MARK: - Scalar emission + +struct FrontmatterEmissionTests { + private func emittedTitle(_ value: FrontmatterValue) throws -> String { + var document = try FrontmatterDocument.parse("---\n---\n") + document.set("title", to: value) + return String(document.serialized().dropFirst("---\ntitle: ".count).dropLast("\n---\n".count)) + } + + @Test func numbersAndTimestampsAreEmittedPlain() throws { + #expect(try emittedTitle(.int(42)) == "42") + #expect(try emittedTitle(.double(1024)) == "1024") + #expect(try emittedTitle(.double(1536.5)) == "1536.5") + #expect(try emittedTitle(.bool(true)) == "true") + #expect(try emittedTitle(.date(Date(timeIntervalSince1970: 1_785_084_098))) == "2026-07-26T16:41:38Z") + } + + @Test func ordinaryStringsAreEmittedPlain() throws { + #expect(try emittedTitle(.string("My Board")) == "My Board") + #expect(try emittedTitle(.string("Fix login — round 2")) == "Fix login — round 2") + #expect(try emittedTitle(.string("a/b?c")) == "a/b?c") + } + + @Test(arguments: [ + "", + " padded ", + "true", + "yes", + "null", + "~", + "42", + "1.5", + "2026-07-26T16:41:38Z", + "has: a colon", + "trailing colon:", + "hash # comment", + "#ff8800", + "- leading dash", + "[bracketed]", + "{braced}", + "line\nbreak", + "tab\there", + "quote\"inside", + "back\\slash", + "*anchor", + "&anchor", + "!tagged", + "%directive", + "@at", + "|pipe", + ">gt", + "'single'", + ]) + func stringsNeedingQuotingSurviveAReparse(value: String) throws { + var document = try FrontmatterDocument.parse("---\n---\n") + document.set("title", to: .string(value)) + let reparsed = try FrontmatterDocument.parse(document.serialized()) + #expect(reparsed.title == .valid(value)) + } + + @Test func quotingIsUsedOnlyWhenNeeded() throws { + #expect(try emittedTitle(.string("true")) == "\"true\"") + #expect(try emittedTitle(.string("#ff8800")) == "\"#ff8800\"") + #expect(try emittedTitle(.string("")) == "\"\"") + #expect(try emittedTitle(.string("a\nb")) == "\"a\\nb\"") + // An interior quote is legal in a plain scalar; a leading one is not. + #expect(try emittedTitle(.string("say \"hi\"")) == "say \"hi\"") + #expect(try emittedTitle(.string("\"quoted\"")) == "\"\\\"quoted\\\"\"") + #expect(try emittedTitle(.string("back\\slash")) == "back\\slash") + } + + @Test func rawValuesAreWrittenVerbatim() throws { + var document = try FrontmatterDocument.parse("---\nschema: 1\n---\nbody\n") + document.set("template", to: .raw("{order: 7}")) + #expect(document.serialized() == "---\nschema: 1\ntemplate: {order: 7}\n---\nbody\n") + #expect(document.value(for: "template") + == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(7))])) + } +} diff --git a/project.yml b/project.yml index 5a3a0b2..d5bf8c9 100644 --- a/project.yml +++ b/project.yml @@ -6,6 +6,11 @@ options: xcodeVersion: "26.0" defaultConfig: Debug +packages: + Yams: + url: https://github.com/jpsim/Yams.git + from: 6.0.0 + settings: base: SWIFT_VERSION: "6.0" @@ -19,6 +24,8 @@ targets: platform: macOS sources: - Kanban + dependencies: + - package: Yams postBuildScripts: - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' name: Update Build Info @@ -42,6 +49,9 @@ targets: platform: macOS sources: - KanbanTests + - path: Fixtures + type: folder + buildPhase: resources dependencies: - target: Kanban settings: