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,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/`.
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 + "\""
|
||||
}
|
||||
}
|
||||
@@ -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: ", ") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]))
|
||||
}
|
||||
}
|
||||
+10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user