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 /// A frontmatter shape the span editor cannot address — the reason a document reads fine /// but refuses to be written (see `uneditableShape`). public enum UneditableShape: Sendable, Equatable, CustomStringConvertible { /// A top-level mapping key is not a scalar the editor can address — YAML's explicit-key /// syntax (`? [a, b]`), whose key is a sequence or mapping rather than a name. case nonScalarKey /// A top-level key has no line of its own that opens it: the whole-frontmatter flow /// mapping (`{schema: 1, order: 1024}`) and its kin (`key : value`, whose spacing the /// span matcher cannot key on). The value reads fine; there is no line to rewrite. /// Also covers the empty-but-not-blank block (`{}`, `null`, `~`) — zero keys, yet /// appending one after that text would be unparseable YAML. case keyWithoutOwnLine public var description: String { switch self { case .nonScalarKey: "a top-level key is not a scalar the editor can address" case .keyWithoutOwnLine: "a top-level key has no line of its own" } } } /// Why this document's frontmatter cannot be edited in place, or nil when it can be — the /// settled **readable-but-uneditable** rule (01-storage-format.md § Frontmatter, the /// byte-identical round-trip contract): a shape the surgical editor cannot key by spans /// still loads, reads, and renders normally, but every app write to that file refuses /// loudly (`BoardWriter`) rather than risk silently corrupting it. /// /// Computed at parse, from the same two facts `set`/`remove` depend on: that every /// top-level key is a scalar, and that every occurrence of one found a line of its own to /// own. Deliberately conservative — anything the span matcher could not address refuses /// writes, even where a cleverer editor might have coped. `init(body:)` documents have no /// text to preserve and are always editable. public let uneditableShape: UneditableShape? /// 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 uneditableShape = nil } private init( openingDelimiter: String, closingDelimiter: String, spans: [Span], values: [KeyedValue], body: String, uneditableShape: UneditableShape? ) { self.openingDelimiter = openingDelimiter self.closingDelimiter = closingDelimiter self.spans = spans self.values = values self.body = body self.uneditableShape = uneditableShape } // MARK: - Parsing /// Reads a file. YAML is composed to validate and interpret it; the text itself is retained /// verbatim, so the parse only decides how the document *reads*, never how it is written. /// /// **Duplicate top-level keys: last one wins** (01-storage-format.md § Frontmatter) — a /// deliberate divergence from strict YAML, which rejects the file outright. The key's last /// occurrence supplies the value, earlier twins are invisible to every read, and each /// occurrence still keeps its own span, so the file round-trips byte-identically. `keys` and /// `fields` present that effective view: one entry per key, at the position of the occurrence /// that won (`a`, `b`, `a` reads as `b`, `a`) — which is also the order the file itself ends /// up in once `set` collapses the twins. A duplicate *nested* inside a value's own mapping is /// out of scope: it stays `unparseableYAML`, as does everything else libYAML rejects. 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, placeholders) = try Self.composeToleratingDuplicateKeys(yamlLines) 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 { pair in pair.key.string.map { placeholders[$0] ?? $0 } } let spans = Self.makeSpans(lines: yamlLines, keys: keys) return FrontmatterDocument( openingDelimiter: first, closingDelimiter: lines[closingIndex], spans: spans, values: Self.lastWinsValues(keys: keys, mapping: mapping), body: lines[(closingIndex + 1)...].joined(), uneditableShape: Self.uneditableShape(keys: keys, spans: spans) ) } /// Composes the YAML block, tolerating repeated top-level keys. /// /// libYAML refuses a mapping with a duplicated key, but its error is informative enough to /// disarm: it names the duplicated keys and marks the *first* occurrence of one of them. That /// occurrence's key is renamed to a placeholder and the block re-composed, until nothing is /// duplicated. Renaming leaves the line count — and therefore every subsequent mark, and the /// whole span layout — identical to the original, so the composed mapping still lines up with /// the file's lines one for one; the returned table maps each placeholder back to the key it /// stands for. Anything that is not a duplicate at column 0 (a nested duplicate included) is /// reported as `unparseableYAML`, unchanged. private static func composeToleratingDuplicateKeys( _ lines: [String] ) throws(FrontmatterError) -> (node: Node?, placeholders: [String: String]) { var work = lines var placeholders: [String: String] = [:] let prefix = Self.placeholderPrefix(avoiding: lines.joined()) while true { do { return (try Yams.compose(yaml: work.joined()), placeholders) } catch let error as YamlError { guard case let .duplicatedKeysInMapping(duplicates, context) = error else { throw FrontmatterError.unparseableYAML(message: "\(error)", line: Self.fileLine(of: error)) } // Marks are 1-based and count only the YAML block, which the rename never resizes. let index = context.mark.line - 1 guard placeholders.count < lines.count, work.indices.contains(index), let key = duplicates.first(where: { Self.lineStartsKey(work[index], key: $0) }) else { throw FrontmatterError.unparseableYAML(message: "\(error)", line: Self.fileLine(of: error)) } let placeholder = prefix + String(placeholders.count) let line = work[index] placeholders[placeholder] = key work[index] = placeholder + String(line.dropFirst(Self.keyText(of: line, key: key).count)) } catch { throw FrontmatterError.unparseableYAML(message: "\(error)", line: nil) } } } /// A key stem that appears nowhere in the file, so a renamed duplicate can never collide with /// a key the author actually wrote. private static func placeholderPrefix(avoiding text: String) -> String { var prefix = "__lanework_duplicate_" while text.contains(prefix) { prefix += "_" } return prefix } /// One entry per distinct key, carrying the *last* occurrence's value and sitting where that /// occurrence sits — the last-wins reading of a file with repeated keys. private static func lastWinsValues(keys: [String?], mapping: Node.Mapping) -> [KeyedValue] { var values: [KeyedValue] = [] for (key, pair) in zip(keys, mapping) { guard let key else { continue } values.removeAll { $0.key == key } values.append(KeyedValue(key: key, value: YAMLValue(pair.value))) } return values } /// Whether the parse produced a document `set`/`remove` can edit by span, and if not, why /// (see `uneditableShape`). Two questions, in the order they can be answered: /// /// - A `nil` entry in `keys` is a top-level mapping key YAML resolved to something other /// than a string — the editor has no name to match a line against at all. /// - Otherwise every occurrence must have claimed a span of its own. `makeSpans` only opens /// a span on a line that literally starts the key it expects next, so a shortfall means /// some occurrence lives inside a line the matcher could not key — the whole-frontmatter /// flow mapping's keys, `key : value` spacing — and a `set` of it would append a second, /// contradictory entry instead of rewriting the one on disk. /// A third fact matters beyond the two above: **no unkeyed span may carry content**. A /// block whose YAML resolves to an *empty* mapping can still have text on its lines — /// `{}`, `null`, `~` — which lands in an unkeyed span's tail because no key ever claims /// it. Zero keys against zero keyed spans passes both counts, yet appending a key after /// that text (`{}` + `schema: 1`) is unparseable YAML — so any unkeyed span holding a /// line that is not a comment or blank makes the document uneditable too. Comment-only /// and blank preambles stay editable: appending after them is exactly what `set` is for. private static func uneditableShape(keys: [String?], spans: [Span]) -> UneditableShape? { if keys.contains(where: { $0 == nil }) { return .nonScalarKey } if spans.filter({ $0.key != nil }).count != keys.count { return .keyWithoutOwnLine } if spans.contains(where: { $0.key == nil && !lines(of: $0.text).allSatisfy(isTailLine) }) { return .keyWithoutOwnLine } return nil } public func serialized() -> String { openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body } // MARK: - Generic access /// Top-level keys in document order — the effective, last-wins view: a key written twice /// appears once, where its winning (last) occurrence sits (see `parse`). 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, the surrounding /// whitespace, and any trailing inline comment — the comment is the line's, not the value's. /// Read from the winning (last) occurrence, like every other read. public func rawValue(for key: String) -> String? { spans.last { $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. /// /// Two rules from 01-storage-format.md § Frontmatter land here: /// /// - **Duplicates collapse.** The winning (last) occurrence is rewritten and every earlier /// twin is deleted in the same call — the app owns the keys it writes, and a stale twin left /// behind would resurrect itself the moment the winner were removed. /// - **Comments survive.** Comments on their own lines are never touched, and an inline /// comment trailing the rewritten value line is re-spliced after the new value at its /// original distance (`order: 3072 # keep at top` → `order: 4096 # keep at top`). That /// re-splice is best-effort — see `splitInlineComment(_:keyText:)`. public mutating func set(_ key: String, to value: FrontmatterValue) { if let index = spans.lastIndex(where: { $0.key == key }) { spans[index].value = Self.rewritten(spans[index].value, key: key, to: value) for twin in (0 ..< index).reversed() where spans[twin].key == key { clearSpan(at: twin) } } else { spans.append(Span( key: key, value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\(appendTerminator)", 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 — *every* occurrence of it, so a removed key cannot be /// resurrected by an earlier twin. 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) { for index in spans.indices.reversed() where spans[index].key == key { clearSpan(at: index) } values.removeAll { $0.key == key } } /// The line ending an *appended* key adopts. Rewritten lines keep their own ending /// (`rewritten(_:key:to:)`); an appended line has no prior ending to keep, so it follows /// the file's prevailing one — read off the opening delimiter, the one line every parsed /// document is guaranteed to have. A CRLF file stays uniformly CRLF when a stamp lands in /// it for the first time; "preserved per line, never normalized" /// (01-storage-format.md § Fractal layout ▸ Rules) extended to the line that never existed. private var appendTerminator: String { openingDelimiter.hasSuffix("\r\n") ? "\r\n" : "\n" } /// Drops one span's value lines, keeping its trailing comments and blank lines. private mutating func clearSpan(at index: Int) { if spans[index].tail.isEmpty { spans.remove(at: index) } else { spans[index] = Span(key: nil, value: "", tail: spans[index].tail) } } // 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. `keys` lists every top-level occurrence, duplicates /// included, so each twin gets its own span and the block still serializes back verbatim. 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) } // MARK: - Rewriting a value line /// The replacement text for a span's value lines: `key: newValue`, plus whatever inline /// comment the old lines carried, ending the way they ended (a CRLF file stays CRLF). private static func rewritten(_ spanValue: String, key: String, to value: FrontmatterValue) -> String { let keyText = Self.keyText(of: spanValue, key: key) let split = Self.splitInlineComment(spanValue, keyText: keyText) return "\(keyText): \(value.yamlText)\(split.comment)\(split.terminator)" } /// A span's value lines split three ways: the text that carries the value, the trailing inline /// comment — its leading whitespace included, so it re-splices at the distance the author left /// — and the line's own terminator. `comment` is empty when the line has none. /// /// Both sides of the split matter. Writing re-attaches the comment after the new value; /// reading drops it, because a comment is not part of the value the author wrote. /// /// **Best-effort by design** (01-storage-format.md § Frontmatter). Guaranteed for the /// realistic case — a value written on one line as a plain, single-quoted, or double-quoted /// scalar — where YAML's own rule decides: `#` opens a comment only when preceded by /// whitespace and not inside a quoted scalar, so the `#` in `title: "a # b"` is value text and /// stays with the value. Pathological shapes keep the whole line as value text instead of /// making the editor guess: a value spanning several lines (block scalar, multi-line flow /// collection) is not scanned at all, and neither is a single-line flow collection whose /// quotes do not balance. private static func splitInlineComment( _ spanValue: String, keyText: String ) -> (value: String, comment: String, terminator: String) { // A CRLF pair is one `Character`, so both endings have to be named explicitly. var characters = Array(spanValue) var terminator = "\n" if characters.last == "\r\n" { characters.removeLast() terminator = "\r\n" } else if characters.last == "\n" { characters.removeLast() } // Only a value written on a single line is scanned; anything taller keeps no comment. guard !characters.contains(where: { $0 == "\n" || $0 == "\r\n" }) else { return (String(characters), "", terminator) } let header = Array(keyText + ":") guard characters.starts(with: header) else { return (String(characters), "", terminator) } var valueStart = header.count while valueStart < characters.count, isSpaceOrTab(characters[valueStart]) { valueStart += 1 } guard let hash = commentIndex(in: characters, valueStart: valueStart) else { return (String(characters), "", terminator) } var start = hash while start > 0, isSpaceOrTab(characters[start - 1]) { start -= 1 } return (String(characters[.. Int? { guard valueStart < characters.count else { return nil } var index = valueStart switch characters[valueStart] { case "\"", "'": guard let end = endOfQuoted(characters, from: valueStart) else { return nil } index = end case "[", "{": return flowCommentIndex(characters, from: valueStart) default: break } while index < characters.count { if characters[index] == "#", index > 0, isSpaceOrTab(characters[index - 1]) { return index } index += 1 } return nil } /// One past the closing quote of the quoted scalar starting at `start`, or nil if it does not /// close on this line. `\"` escapes inside double quotes, `''` inside single ones. private static func endOfQuoted(_ characters: [Character], from start: Int) -> Int? { let quote = characters[start] var index = start + 1 while index < characters.count { if quote == "\"", characters[index] == "\\" { index += 2 continue } if characters[index] == quote { if quote == "'", index + 1 < characters.count, characters[index + 1] == "'" { index += 2 continue } return index + 1 } index += 1 } return nil } /// A flow collection on one line: a quote here really does open a quoted scalar, so a `#` /// inside one is value text. private static func flowCommentIndex(_ characters: [Character], from start: Int) -> Int? { var index = start while index < characters.count { switch characters[index] { case "\"", "'": guard let end = endOfQuoted(characters, from: index) else { return nil } index = end case "#" where index > 0 && isSpaceOrTab(characters[index - 1]): return index default: index += 1 } } return nil } private static func isSpaceOrTab(_ character: Character) -> Bool { character == " " || character == "\t" } /// The value a span's lines carry, with the `key:` header, the surrounding whitespace, and any /// trailing inline comment taken off. /// /// Dropping the comment is strictly a read-side decision: a comment is not part of the value /// the author wrote, so it must not leak into a coerced reading (`title: 2048 # note` reads /// as `2048`), into `.malformed(raw:)`, or — worst — back out through a later `set`, which /// would splice the comment on a second time. The bytes on disk are untouched. private static func valueText(of spanValue: String, key: String) -> String { let keyText = Self.keyText(of: spanValue, key: key) let value = Self.splitInlineComment(spanValue, keyText: keyText).value let header = keyText + ":" guard value.hasPrefix(header) else { return value.trimmingCharacters(in: .whitespacesAndNewlines) } return value.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" /// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to /// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch /// when absent (`IntegrityRules.healOnTouch`), and never stripped. public static let kind = "kind" public static let schemaOwned: Set = [ schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor, kind, ] }