From 5a459f0ee438dd5b9133eb02d62b43af0b14e9d0 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 16:10:32 -0400 Subject: [PATCH] Exclude inline comments from raw value reads rawValue and the coercion/malformed paths now stop at the quote-aware inline-comment boundary, so 'title: 2048 # note' coerces to "2048" and a read-then-write no longer compounds the comment (pinned idempotent). Strictly read-side; round-trip bytes untouched. +6 tests (124 total). Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/FrontmatterDocument.swift | 61 ++++++++++++++------- KanbanTests/FrontmatterTests.swift | 69 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index e580484..c90eeb0 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -185,8 +185,9 @@ 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. Read from the winning (last) occurrence, like every other read. + /// 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) } } @@ -203,7 +204,7 @@ public struct FrontmatterDocument: Sendable, Equatable { /// - **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:)`. + /// 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) @@ -320,22 +321,29 @@ public struct FrontmatterDocument: Sendable, Equatable { /// 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)" + let split = Self.splitInlineComment(spanValue, keyText: keyText) + return "\(keyText): \(value.yamlText)\(split.comment)\(split.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. + /// 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 - /// 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) { + /// 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" @@ -346,17 +354,21 @@ public struct FrontmatterDocument: Sendable, Equatable { 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) } + 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 ("", terminator) } + 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 ("", terminator) } + 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...]), terminator) + return (String(characters[.. String { - let header = keyText(of: spanValue, key: key) + ":" - guard spanValue.hasPrefix(header) else { - return spanValue.trimmingCharacters(in: .whitespacesAndNewlines) + 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 spanValue.dropFirst(header.count).trimmingCharacters(in: .whitespacesAndNewlines) + return value.dropFirst(header.count).trimmingCharacters(in: .whitespacesAndNewlines) } private static func fileLine(of error: YamlError) -> Int? { diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index a985b96..04a6556 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -459,6 +459,35 @@ struct FrontmatterAccessTests { #expect(document.rawValue(for: "labels") == "[a, b, c]") #expect(document.rawValue(for: "schema") == "1") } + + /// A trailing comment belongs to the line, not to the value — reads stop at it. + @Test func rawValueStopsAtAnInlineComment() throws { + let document = try FrontmatterDocument.parse(Fixture.rich) + #expect(document.rawValue(for: "project") == "lanework") + } + + /// …but a `#` inside a quoted scalar is value text, and a multi-line value is never scanned. + @Test func rawValueKeepsAHashThatIsNotAComment() throws { + let text = """ + --- + quoted: "hash # inside" + single: 'hash # inside' + both: "hash # inside" # and a real one + flow: [a, "b # c"] # note + block: | + text # not a comment out here either + --- + body + + """ + let document = try FrontmatterDocument.parse(text) + #expect(document.rawValue(for: "quoted") == "\"hash # inside\"") + #expect(document.rawValue(for: "single") == "'hash # inside'") + #expect(document.rawValue(for: "both") == "\"hash # inside\"") + #expect(document.rawValue(for: "flow") == "[a, \"b # c\"]") + #expect(document.rawValue(for: "block") == "|\n text # not a comment out here either") + #expect(document.serialized() == text) + } } // MARK: - Strict fields @@ -526,6 +555,32 @@ struct FrontmatterLenientFieldTests { #expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z")) } + /// The comment on a value line belongs to the line, not to the value: the source text a + /// wrong-typed scalar coerces to stops where the comment starts. + @Test func aTrailingCommentIsNotPartOfACoercedValue() throws { + #expect(try document("title: 2048 # note").title == .valid("2048")) + #expect(try document("title: true # note").title == .valid("true")) + #expect(try document("background: 42\t# tabbed").background == .valid("42")) + #expect(try document("icon: 2026-07-26T16:41:38Z # when").icon == .valid("2026-07-26T16:41:38Z")) + } + + /// A `#` inside a quoted scalar is value text — the quotes are what make `#ff8800` writable + /// at all, so a read must not treat it as a comment. + @Test func aHashInsideAQuotedValueIsNotTrimmed() throws { + #expect(try document("title: \"2048 # note\"").title == .valid("2048 # note")) + #expect(try document("background: \"#ff8800\"").background == .valid("#ff8800")) + #expect(try document("background: \"#ff8800\" # brand orange").background == .valid("#ff8800")) + } + + /// A sequence or mapping has no scalar reading at all; the raw text it falls back to stops at + /// the comment like every other read. + @Test func malformedRawStopsAtAnInlineComment() throws { + #expect(try document("background: [red, blue] # a palette").background == .malformed(raw: "[red, blue]")) + #expect(try document("title: {a: 1} # a mapping").title == .malformed(raw: "{a: 1}")) + #expect(try document("width: wide # roughly").width == .malformed(raw: "wide")) + #expect(try document("order: banana # not a number").order == .malformed(raw: "banana")) + } + @Test func quotedStringLenientValuesAreUnaffectedByCoercion() throws { #expect(try document("title: \"2048\"").title == .valid("2048")) #expect(try document("title: \"true\"").title == .valid("true")) @@ -774,6 +829,20 @@ struct FrontmatterInlineCommentTests { #expect(document.serialized() == "---\norder: 8192 # keep at top\n---\nbody\n") } + /// Reading a value and writing it straight back must leave one comment, not two — the read + /// stops at the comment, the write re-splices it. + @Test func aReadThenWriteDoesNotCompoundTheComment() throws { + var document = try FrontmatterDocument.parse("---\ntitle: 2048 # note\n---\nbody\n") + #expect(document.title == .valid("2048")) + + document.set("title", to: .string(document.title.value ?? "")) + #expect(document.serialized() == "---\ntitle: \"2048\" # note\n---\nbody\n") + + document.set("title", to: .string(document.title.value ?? "")) + #expect(document.serialized() == "---\ntitle: \"2048\" # note\n---\nbody\n") + #expect(try FrontmatterDocument.parse(document.serialized()).title == .valid("2048")) + } + @Test func theCommentSurvivesAReparse() throws { var document = try FrontmatterDocument.parse("---\norder: 3072 # keep at top\n---\nbody\n") document.set("order", to: .double(4096))