The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
581 lines
27 KiB
Swift
581 lines
27 KiB
Swift
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[..<start]), String(characters[start...]), terminator)
|
|
}
|
|
|
|
/// Where this line's comment begins, or nil if it has none. The value's opening character
|
|
/// says how much of the line is off limits: a quoted scalar is skipped whole, a flow
|
|
/// collection is scanned with its quotes tracked, and a plain scalar cannot contain ` #` at
|
|
/// all — that sequence is exactly what ends it.
|
|
private static func commentIndex(in characters: [Character], valueStart: Int) -> 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<String> = [
|
|
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
|
iconColor, kind,
|
|
]
|
|
}
|