Engine — duplicate keys last-wins, inline-comment re-splice
Duplicates: Yams' duplicate-key error is disarmed by placeholder- renaming first occurrences and re-composing (line count preserved, so span layout stays exact); reads take the last occurrence, set() rewrites the winner and clears earlier twins, remove() clears all. Top-level only — nested duplicates stay YAML errors (doc updated). Re-splice: an inline comment on a rewritten scalar line survives, with quote-aware detection so a # inside quotes is never a comment. +31 tests (118 total). Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -50,6 +50,17 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
|
||||
// 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 }
|
||||
@@ -58,14 +69,7 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
}
|
||||
|
||||
let yamlLines = Array(lines[1 ..< closingIndex])
|
||||
let node: Node?
|
||||
do {
|
||||
node = try Yams.compose(yaml: yamlLines.joined())
|
||||
} catch let error as YamlError {
|
||||
throw .unparseableYAML(message: "\(error)", line: Self.fileLine(of: error))
|
||||
} catch {
|
||||
throw .unparseableYAML(message: "\(error)", line: nil)
|
||||
}
|
||||
let (node, placeholders) = try Self.composeToleratingDuplicateKeys(yamlLines)
|
||||
|
||||
let mapping: Node.Mapping
|
||||
switch node {
|
||||
@@ -81,27 +85,86 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
let keys = mapping.map { $0.key.string }
|
||||
let values = zip(keys, mapping).compactMap { key, pair -> KeyedValue? in
|
||||
key.map { KeyedValue(key: $0, value: YAMLValue(pair.value)) }
|
||||
}
|
||||
let keys = mapping.map { pair in pair.key.string.map { placeholders[$0] ?? $0 } }
|
||||
|
||||
return FrontmatterDocument(
|
||||
openingDelimiter: first,
|
||||
closingDelimiter: lines[closingIndex],
|
||||
spans: Self.makeSpans(lines: yamlLines, keys: keys),
|
||||
values: values,
|
||||
values: Self.lastWinsValues(keys: keys, mapping: mapping),
|
||||
body: lines[(closingIndex + 1)...].joined()
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
public func serialized() -> String {
|
||||
openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body
|
||||
}
|
||||
|
||||
// MARK: - Generic access
|
||||
|
||||
/// Top-level keys in document order.
|
||||
/// 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] {
|
||||
@@ -122,18 +185,29 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
values.first { $0.key == key }?.value
|
||||
}
|
||||
|
||||
/// The key's value text exactly as written, minus the `key:` header and surrounding whitespace.
|
||||
/// The key's value text exactly as written, minus the `key:` header and surrounding
|
||||
/// whitespace. Read from the winning (last) occurrence, like every other read.
|
||||
public func rawValue(for key: String) -> String? {
|
||||
spans.first { $0.key == key }.map { Self.valueText(of: $0.value, key: key) }
|
||||
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 `inlineComment(of:keyText:)`.
|
||||
public mutating func set(_ key: String, to value: FrontmatterValue) {
|
||||
if let index = spans.firstIndex(where: { $0.key == key }) {
|
||||
let keyText = Self.keyText(of: spans[index].value, key: key)
|
||||
spans[index].value = "\(keyText): \(value.yamlText)\n"
|
||||
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)\n", tail: ""))
|
||||
}
|
||||
@@ -146,19 +220,23 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the key's value lines. Trailing comments and blank lines survive — they read as
|
||||
/// 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) {
|
||||
if let index = spans.firstIndex(where: { $0.key == key }) {
|
||||
if spans[index].tail.isEmpty {
|
||||
spans.remove(at: index)
|
||||
} else {
|
||||
spans[index] = Span(key: nil, value: "", tail: spans[index].tail)
|
||||
}
|
||||
}
|
||||
for index in spans.indices.reversed() where spans[index].key == key { clearSpan(at: index) }
|
||||
values.removeAll { $0.key == key }
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
@@ -176,7 +254,8 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
|
||||
/// 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.
|
||||
/// 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?
|
||||
@@ -235,6 +314,118 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
||||
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 (comment, terminator) = Self.inlineComment(of: spanValue, keyText: keyText)
|
||||
return "\(keyText): \(value.yamlText)\(comment)\(terminator)"
|
||||
}
|
||||
|
||||
/// The trailing inline comment on a span, its leading whitespace included so it re-splices at
|
||||
/// the distance the author left, along with the line's own terminator.
|
||||
///
|
||||
/// **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
|
||||
/// survives as part of the old value rather than being re-spliced. Pathological shapes lose
|
||||
/// the comment 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 inlineComment(of spanValue: String, keyText: 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 ("", terminator) }
|
||||
let header = Array(keyText + ":")
|
||||
guard characters.starts(with: header) else { return ("", terminator) }
|
||||
|
||||
var valueStart = header.count
|
||||
while valueStart < characters.count, isSpaceOrTab(characters[valueStart]) { valueStart += 1 }
|
||||
guard let hash = commentIndex(in: characters, valueStart: valueStart) else { return ("", terminator) }
|
||||
|
||||
var start = hash
|
||||
while start > 0, isSpaceOrTab(characters[start - 1]) { start -= 1 }
|
||||
return (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"
|
||||
}
|
||||
|
||||
private static func valueText(of spanValue: String, key: String) -> String {
|
||||
let header = keyText(of: spanValue, key: key) + ":"
|
||||
guard spanValue.hasPrefix(header) else {
|
||||
|
||||
Reference in New Issue
Block a user