Build BoardWriter — atomic, minimal-touch writes

The single point through which every mutation becomes a filesystem
operation: read fresh from disk (strict byte-faithful UTF-8), refuse
readable-but-uneditable frontmatter shapes, apply the edit through the
span engine, stamp modified / clear modified-by, then temp-file+rename
atomically. Renumber-visible-children is the sole minimal-touch
exception, tombstones untouched. Structured BoardWriteError carries
operation + path + reason for the future banner surface.

Alongside, three edges the card surfaced:
- The loader now decodes byte-faithfully too, so a BOM'd or non-UTF-8
  index.md is rejected at load per the encoding contract, instead of
  loading via NSString's silent BOM strip and then refusing every write.
- An appended frontmatter line adopts the file's prevailing line ending
  (a CRLF file stays uniformly CRLF when a stamp first lands in it).
- Empty-but-not-blank frontmatter ({}, null, ~) is detected as
  uneditable — appending after it would be unparseable YAML.

30 new unit tests; 176 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 16:49:42 -04:00
parent a131399c02
commit 19f67bdb78
6 changed files with 1061 additions and 8 deletions
+89 -4
View File
@@ -31,6 +31,40 @@ public struct FrontmatterDocument: Sendable, Equatable {
/// 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"
@@ -38,14 +72,23 @@ public struct FrontmatterDocument: Sendable, Equatable {
spans = []
values = []
self.body = body
uneditableShape = nil
}
private init(openingDelimiter: String, closingDelimiter: String, spans: [Span], values: [KeyedValue], body: String) {
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
@@ -86,13 +129,15 @@ public struct FrontmatterDocument: Sendable, Equatable {
}
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: Self.makeSpans(lines: yamlLines, keys: keys),
spans: spans,
values: Self.lastWinsValues(keys: keys, mapping: mapping),
body: lines[(closingIndex + 1)...].joined()
body: lines[(closingIndex + 1)...].joined(),
uneditableShape: Self.uneditableShape(keys: keys, spans: spans)
)
}
@@ -157,6 +202,32 @@ public struct FrontmatterDocument: Sendable, Equatable {
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
}
@@ -210,7 +281,11 @@ public struct FrontmatterDocument: Sendable, Equatable {
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)\n", tail: ""))
spans.append(Span(
key: key,
value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\(appendTerminator)",
tail: ""
))
}
let parsed = KeyedValue(key: key, value: value.parsedValue)
@@ -229,6 +304,16 @@ public struct FrontmatterDocument: Sendable, Equatable {
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 {