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:
@@ -69,6 +69,19 @@ private enum Fixture {
|
||||
---
|
||||
body
|
||||
"""
|
||||
|
||||
/// A hand-edited file that says `title` twice — strict YAML would reject it; the editor reads
|
||||
/// the last one and keeps both on disk (01-storage-format.md § Frontmatter).
|
||||
static let duplicated = """
|
||||
---
|
||||
schema: 1
|
||||
title: First
|
||||
order: 1024
|
||||
title: Second
|
||||
---
|
||||
body
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
private func parseError(_ text: String) -> FrontmatterError? {
|
||||
@@ -122,6 +135,11 @@ struct FrontmatterRoundTripTests {
|
||||
"---\nschema: 1\n---\nbody without newline",
|
||||
// CRLF throughout
|
||||
"---\r\nschema: 1\r\n---\r\nbody\r\n",
|
||||
// duplicate top-level keys — every occurrence keeps its own span
|
||||
Fixture.duplicated,
|
||||
"---\ntitle: a\ntitle: b\ntitle: c\n---\nbody\n",
|
||||
// a duplicate whose earlier twin owns a comment and a block scalar
|
||||
"---\nnotes: |\n one\n# about notes\nnotes: two\n---\nbody\n",
|
||||
])
|
||||
func serializingAnUntouchedDocumentIsByteIdentical(text: String) throws {
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
@@ -165,10 +183,13 @@ struct FrontmatterMalformedTests {
|
||||
"---\nfoo: [1, 2\n---\nbody\n",
|
||||
"---\nschema: 1\n\tindented-with-tab: 2\n---\nbody\n",
|
||||
"---\nschema: 1\n bad: indent\n---\nbody\n",
|
||||
"---\ndupe: 1\ndupe: 2\n---\nbody\n",
|
||||
"---\n\"unterminated: 1\n---\nbody\n",
|
||||
"---\nfoo: {a: 1\n---\nbody\n",
|
||||
"---\n*undefined-alias\n---\nbody\n",
|
||||
// last-wins rescues duplicates at the top level only — nested ones stay a hard failure
|
||||
"---\nouter:\n a: 1\n a: 2\n---\nbody\n",
|
||||
"---\nflow: {a: 1, a: 2}\n---\nbody\n",
|
||||
"---\ndupe: 1\ndupe: 2\nouter:\n a: 1\n a: 2\n---\nbody\n",
|
||||
])
|
||||
func unparseableYAML(text: String) {
|
||||
guard case .unparseableYAML = parseError(text) else {
|
||||
@@ -215,6 +236,155 @@ struct FrontmatterMalformedTests {
|
||||
#expect(parseError(text) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict YAML rejects a repeated mapping key; the editor deliberately does not.
|
||||
@Test func duplicateTopLevelKeysAreNotAParseError() throws {
|
||||
for text in [
|
||||
Fixture.duplicated,
|
||||
"---\ndupe: 1\ndupe: 2\n---\nbody\n",
|
||||
"---\ntitle: a\ntitle: b\ntitle: c\n---\nbody\n",
|
||||
] {
|
||||
#expect(parseError(text) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Duplicate keys (last one wins)
|
||||
|
||||
/// 01-storage-format.md § Frontmatter: a key written twice reads as its last occurrence, the
|
||||
/// earlier ones preserved verbatim on disk and invisible; an app write of that key rewrites the
|
||||
/// winner and removes the twins; a removal removes them all.
|
||||
struct FrontmatterDuplicateKeyTests {
|
||||
@Test func theLastOccurrenceIsTheOneThatReads() throws {
|
||||
let document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
#expect(document.title == .valid("Second"))
|
||||
#expect(document.value(for: "title") == .string("Second"))
|
||||
#expect(document.rawValue(for: "title") == "Second")
|
||||
#expect(document.contains("title"))
|
||||
}
|
||||
|
||||
@Test func threeOccurrencesStillReadAsTheLast() throws {
|
||||
let document = try FrontmatterDocument.parse("---\ntitle: a\ntitle: b\ntitle: c\n---\nbody\n")
|
||||
#expect(document.title == .valid("c"))
|
||||
#expect(document.keys == ["title"])
|
||||
}
|
||||
|
||||
/// The effective view has one entry per key, sitting where the winner sits — the order the
|
||||
/// file itself takes once `set` collapses the twins.
|
||||
@Test func iterationPresentsTheEffectiveView() throws {
|
||||
let document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
#expect(document.keys == ["schema", "order", "title"])
|
||||
#expect(document.fields.map(\.key) == ["schema", "order", "title"])
|
||||
#expect(document.fields.last?.rawValue == "Second")
|
||||
}
|
||||
|
||||
@Test func bothOccurrencesSurviveAnUntouchedRoundTrip() throws {
|
||||
let document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
let output = document.serialized()
|
||||
#expect(output == Fixture.duplicated)
|
||||
#expect(output.contains("title: First"))
|
||||
#expect(output.contains("title: Second"))
|
||||
}
|
||||
|
||||
@Test func settingRewritesTheWinnerAndDeletesTheTwins() throws {
|
||||
var document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
document.set("title", to: .string("Third"))
|
||||
#expect(document.serialized() == "---\nschema: 1\norder: 1024\ntitle: Third\n---\nbody\n")
|
||||
#expect(document.title == .valid("Third"))
|
||||
}
|
||||
|
||||
@Test func settingCollapsesThreeOccurrencesToOne() throws {
|
||||
var document = try FrontmatterDocument.parse("---\ntitle: a\ntitle: b\ntitle: c\n---\nbody\n")
|
||||
document.set("title", to: .string("d"))
|
||||
#expect(document.serialized() == "---\ntitle: d\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// A stale twin left behind would resurrect itself the moment the winner were removed.
|
||||
@Test func removingTakesEveryOccurrence() throws {
|
||||
var document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
document.remove("title")
|
||||
#expect(document.serialized() == "---\nschema: 1\norder: 1024\n---\nbody\n")
|
||||
#expect(document.title == .missing)
|
||||
#expect(!document.contains("title"))
|
||||
#expect(document.keys == ["schema", "order"])
|
||||
}
|
||||
|
||||
@Test func removingTakesAllThreeOccurrences() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: a\ntitle: b\ntitle: c\n---\nbody\n")
|
||||
document.remove("title")
|
||||
#expect(document.serialized() == "---\nschema: 1\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// Own-line comments outlive the twin they were written above (01-storage-format.md).
|
||||
@Test func aCommentAboveTheFirstOccurrenceSurvivesTheCollapse() throws {
|
||||
let text = "---\n# the original title\ntitle: First\ntitle: Second\n---\nbody\n"
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
|
||||
document.set("title", to: .string("Third"))
|
||||
#expect(document.serialized() == "---\n# the original title\ntitle: Third\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// Same rule when the comment trails the earlier twin rather than heading it.
|
||||
@Test func aCommentBelowTheFirstOccurrenceSurvivesTheCollapse() throws {
|
||||
var document = try FrontmatterDocument.parse("---\ntitle: First\n# still relevant\ntitle: Second\n---\nbody\n")
|
||||
document.remove("title")
|
||||
#expect(document.serialized() == "---\n# still relevant\n---\nbody\n")
|
||||
}
|
||||
|
||||
@Test func twoDifferentKeysMayEachBeDuplicated() throws {
|
||||
let text = "---\na: 1\nb: 1\na: 2\nb: 2\n---\nbody\n"
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.value(for: "a") == .int(2))
|
||||
#expect(document.value(for: "b") == .int(2))
|
||||
#expect(document.keys == ["a", "b"])
|
||||
|
||||
document.set("a", to: .int(3))
|
||||
#expect(document.serialized() == "---\nb: 1\na: 3\nb: 2\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// A duplicated twin whose value is a block scalar still spans its own lines.
|
||||
@Test func aDuplicateWithAMultiLineTwinCollapsesCleanly() throws {
|
||||
let text = "---\nnotes: |\n one\n# about notes\nnotes: two\n---\nbody\n"
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.value(for: "notes") == .string("two"))
|
||||
|
||||
document.set("notes", to: .string("three"))
|
||||
#expect(document.serialized() == "---\n# about notes\nnotes: three\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// The twins need not be spelled alike — YAML reads `"title"` and `title` as the same key,
|
||||
/// and each occurrence is rewritten (or dropped) in its own spelling.
|
||||
@Test func twinsSpelledDifferentlyAreStillTheSameKey() throws {
|
||||
let text = "---\n\"title\": First\ntitle: Second\n---\nbody\n"
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.title == .valid("Second"))
|
||||
|
||||
document.set("title", to: .string("Third"))
|
||||
#expect(document.serialized() == "---\ntitle: Third\n---\nbody\n")
|
||||
}
|
||||
|
||||
@Test func duplicatesInACRLFFileRoundTripAndCollapse() throws {
|
||||
let text = "---\r\ntitle: First\r\norder: 1\r\ntitle: Second\r\n---\r\nbody\r\n"
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
#expect(document.serialized() == text)
|
||||
#expect(document.title == .valid("Second"))
|
||||
|
||||
document.set("title", to: .string("Third"))
|
||||
#expect(document.serialized() == "---\r\norder: 1\r\ntitle: Third\r\n---\r\nbody\r\n")
|
||||
}
|
||||
|
||||
@Test func duplicatesSurviveReparsing() throws {
|
||||
var document = try FrontmatterDocument.parse(Fixture.duplicated)
|
||||
document.set("order", to: .double(2048))
|
||||
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||
#expect(reparsed.serialized() == document.serialized())
|
||||
#expect(reparsed.title == .valid("Second"))
|
||||
#expect(reparsed.order == .valid(2048))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic access
|
||||
@@ -436,7 +606,7 @@ struct FrontmatterEditTests {
|
||||
var document = try FrontmatterDocument.parse(text)
|
||||
document.set("project", to: .string("kanban"))
|
||||
#expect(document.serialized()
|
||||
== "---\nschema: 1\nproject: kanban\n\n# a note\norder: 5\n---\nbody\n")
|
||||
== "---\nschema: 1\nproject: kanban # agent overlay\n\n# a note\norder: 5\n---\nbody\n")
|
||||
}
|
||||
|
||||
@Test func appendingANewKeyGoesBeforeTheClosingDelimiter() throws {
|
||||
@@ -538,6 +708,112 @@ struct FrontmatterEditTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline comments on rewritten lines
|
||||
|
||||
/// 01-storage-format.md § Frontmatter, "App rewrites preserve comments": an inline comment on a
|
||||
/// rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain
|
||||
/// single-line scalars, allowed to drop in pathological shapes.
|
||||
struct FrontmatterInlineCommentTests {
|
||||
private func edited(_ frontmatter: String, _ key: String, _ value: FrontmatterValue) throws -> String {
|
||||
var document = try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
|
||||
document.set(key, to: value)
|
||||
return String(document.serialized().dropFirst("---\n".count).dropLast("\n---\nbody\n".count))
|
||||
}
|
||||
|
||||
@Test func aPlainScalarKeepsItsCommentAndItsSpacing() throws {
|
||||
#expect(try edited("order: 3072 # keep at top", "order", .double(4096))
|
||||
== "order: 4096 # keep at top")
|
||||
#expect(try edited("order: 3072 # tight", "order", .double(4096)) == "order: 4096 # tight")
|
||||
#expect(try edited("order: 3072\t# tabbed", "order", .double(4096)) == "order: 4096\t# tabbed")
|
||||
}
|
||||
|
||||
@Test func aValueLineWithoutACommentIsRewrittenAsBefore() throws {
|
||||
#expect(try edited("order: 3072", "order", .double(4096)) == "order: 4096")
|
||||
#expect(try edited("title: My Board", "title", .string("Renamed")) == "title: Renamed")
|
||||
}
|
||||
|
||||
/// A `#` inside a quoted scalar is value text, not a comment — it must not be re-spliced.
|
||||
@Test func aHashInsideAQuotedScalarIsNotAComment() throws {
|
||||
#expect(try edited("title: \"hash # inside\"", "title", .string("Renamed")) == "title: Renamed")
|
||||
#expect(try edited("title: 'hash # inside'", "title", .string("Renamed")) == "title: Renamed")
|
||||
#expect(try edited("background: \"#ff8800\"", "background", .string("slate")) == "background: slate")
|
||||
}
|
||||
|
||||
/// …but a real comment *after* such a value still is one.
|
||||
@Test func aCommentAfterAQuotedScalarIsStillSpliced() throws {
|
||||
#expect(try edited("title: \"hash # inside\" # real note", "title", .string("Renamed"))
|
||||
== "title: Renamed # real note")
|
||||
#expect(try edited("title: 'it''s # fine' # real note", "title", .string("Renamed"))
|
||||
== "title: Renamed # real note")
|
||||
}
|
||||
|
||||
/// An apostrophe mid-scalar does not open a quoted scalar, so the comment after it is found.
|
||||
@Test func aPlainScalarWithAnApostropheKeepsItsComment() throws {
|
||||
#expect(try edited("title: it's fine # note", "title", .string("Renamed"))
|
||||
== "title: Renamed # note")
|
||||
#expect(try edited("title: say \"hi\" # note", "title", .string("Renamed"))
|
||||
== "title: Renamed # note")
|
||||
}
|
||||
|
||||
@Test func aSingleLineFlowCollectionKeepsItsComment() throws {
|
||||
#expect(try edited("labels: [a, \"b # c\"] # note", "labels", .raw("[x]"))
|
||||
== "labels: [x] # note")
|
||||
#expect(try edited("template: {order: 3} # picker slot", "template", .raw("{order: 7}"))
|
||||
== "template: {order: 7} # picker slot")
|
||||
}
|
||||
|
||||
@Test func aCommentOnAnEmptyValueIsKept() throws {
|
||||
#expect(try edited("title: # to be filled in", "title", .string("Named"))
|
||||
== "title: Named # to be filled in")
|
||||
}
|
||||
|
||||
@Test func theCommentSurvivesSuccessiveSets() throws {
|
||||
var document = try FrontmatterDocument.parse("---\norder: 3072 # keep at top\n---\nbody\n")
|
||||
document.set("order", to: .double(4096))
|
||||
document.set("order", to: .double(8192))
|
||||
#expect(document.serialized() == "---\norder: 8192 # keep at top\n---\nbody\n")
|
||||
}
|
||||
|
||||
@Test func theCommentSurvivesAReparse() throws {
|
||||
var document = try FrontmatterDocument.parse("---\norder: 3072 # keep at top\n---\nbody\n")
|
||||
document.set("order", to: .double(4096))
|
||||
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||
#expect(reparsed.order == .valid(4096))
|
||||
#expect(reparsed.serialized() == document.serialized())
|
||||
}
|
||||
|
||||
/// The winning occurrence's own comment is what carries over; a collapsed twin's inline
|
||||
/// comment goes with the twin.
|
||||
@Test func theWinningOccurrencesCommentIsTheOneKept() throws {
|
||||
var document = try FrontmatterDocument.parse("---\norder: 1 # old\norder: 2 # current\n---\nbody\n")
|
||||
document.set("order", to: .double(3))
|
||||
#expect(document.serialized() == "---\norder: 3 # current\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// Pathological shapes are allowed to lose the comment rather than have the editor guess.
|
||||
/// Pinning the chosen behavior: a value spanning several lines is not scanned at all.
|
||||
@Test func aMultiLineValueDropsItsHeaderComment() throws {
|
||||
var document = try FrontmatterDocument.parse("---\nnotes: | # about the notes\n line one\n---\nbody\n")
|
||||
document.set("notes", to: .string("flattened"))
|
||||
#expect(document.serialized() == "---\nnotes: flattened\n---\nbody\n")
|
||||
|
||||
var flow = try FrontmatterDocument.parse("---\nflow: {a: 1,\nb: 2} # spread out\nlast: x\n---\nbody\n")
|
||||
flow.set("flow", to: .raw("{a: 9}"))
|
||||
#expect(flow.serialized() == "---\nflow: {a: 9}\nlast: x\n---\nbody\n")
|
||||
}
|
||||
|
||||
/// Comments on their own lines were always preserved and still are — including the one that
|
||||
/// heads the frontmatter and the one that trails it.
|
||||
@Test func ownLineCommentsAreUntouchedByARewrite() throws {
|
||||
var document = try FrontmatterDocument.parse(Fixture.rich)
|
||||
document.set("title", to: .string("Renamed"))
|
||||
let output = document.serialized()
|
||||
#expect(output.hasPrefix("---\n# board settings, hand-written\nschema: 1\n"))
|
||||
#expect(output.contains("\n# trailing note\n---\n"))
|
||||
#expect(output.contains("project: lanework # agent overlay\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scalar emission
|
||||
|
||||
struct FrontmatterEmissionTests {
|
||||
|
||||
Reference in New Issue
Block a user