A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
640 lines
31 KiB
Swift
640 lines
31 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"
|
|
|
|
/// **A lane folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes; 01-storage-format.md
|
|
/// § Frontmatter's lane table) — `collapsed: true`, and nothing else.
|
|
///
|
|
/// Document state exactly like `width`, deliberately rather than window state: the files *are* the
|
|
/// board, so an agent folds a lane away by writing one key and the app follows on the next reload.
|
|
///
|
|
/// **Only ever written as `true`, and removed to expand** (the remove-at-default family beside the
|
|
/// empty rename's `title`, the None well's `background`, and a one-unit `width`): an absent key is
|
|
/// the default reading, so a `collapsed: false` the app wrote would be noise a default lane has to
|
|
/// carry. A hand-written `false` is legal, reads as expanded, and is preserved until the app itself
|
|
/// next edits the key.
|
|
public static let collapsed = "collapsed"
|
|
|
|
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 `background` mapping's subkeys** (01-storage-format.md § Frontmatter — the field is a
|
|
/// mapping and nothing else, ruled 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either
|
|
/// half absent, and any other subkey an author writes tolerated and carried through
|
|
/// (`FrontmatterDocument.setStyleValue`). A bare scalar has no reading at all.
|
|
///
|
|
/// **Named here without joining `schemaOwned`**, and for a plainer reason than `remote`'s: that
|
|
/// set is what `unknownFields` subtracts from the document's *top-level* keys, and these two are
|
|
/// inside one. A top-level `color:` or `image:` is somebody else's key and stays an unknown one.
|
|
public enum Background {
|
|
public static let color = "color"
|
|
public static let image = "image"
|
|
}
|
|
|
|
/// 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"
|
|
|
|
/// The reserved tracker keys — `remote` (board, card) and `remote-state` (lane) — of the enhanced
|
|
/// schema's future connectors (01-storage-format.md § Enhanced schema).
|
|
///
|
|
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
|
|
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
|
|
/// the UI)", and `schemaOwned` is exactly the set `unknownFields` subtracts. Nothing about their
|
|
/// ordinary posture changes.
|
|
///
|
|
/// They are named at all because of the one act that does touch them: **an item-level copy severs
|
|
/// tracker identity** (§ Fractal layout ▸ Rules, ruled 2026-07-29) — "two local objects must never
|
|
/// both claim to be the same remote object" — so every folder a paste, an ⌥-drag duplicate, a
|
|
/// cross-board copy or a template instantiation materializes drops both keys, at every level.
|
|
/// Whole-board forks (Duplicate, Save as Template) carry them verbatim, as always.
|
|
public static let remote = "remote"
|
|
public static let remoteState = "remote-state"
|
|
|
|
/// **A comment's self-reported author** (01-storage-format.md § Enhanced schema, the
|
|
/// `kind: comment` field table): "lenient — self-reported *content*, not overlay: **unlike
|
|
/// `modified-by` it survives app writes**; the app writes the macOS account's full name, agents
|
|
/// write their own, tracker sync writes the remote author verbatim, and missing renders
|
|
/// unattributed".
|
|
///
|
|
/// **Named here without joining `schemaOwned`**, `remote`'s precedent and its reason turned
|
|
/// around: the key belongs to the comment field table and to no other kind, so on a board, a lane
|
|
/// or a card it is an ordinary unknown key — and `schemaOwned` is exactly the set the card
|
|
/// window's Details section subtracts. Listing it would *hide* a hand-added `author:` from the one
|
|
/// surface that exists to show hand-added keys; a comment has no such surface to be affected
|
|
/// either way.
|
|
///
|
|
/// Nothing clears it, anywhere: `updateIndex` clears `modified-by` and only that.
|
|
public static let author = "author"
|
|
|
|
public static let schemaOwned: Set<String> = [
|
|
schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background,
|
|
icon, iconColor, kind,
|
|
]
|
|
}
|