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
This commit is contained in:
2026-07-26 16:10:32 -04:00
parent 4085dd7a46
commit 5a459f0ee4
2 changed files with 110 additions and 20 deletions
+41 -20
View File
@@ -185,8 +185,9 @@ public struct FrontmatterDocument: Sendable, Equatable {
values.first { $0.key == key }?.value values.first { $0.key == key }?.value
} }
/// The key's value text exactly as written, minus the `key:` header and surrounding /// The key's value text exactly as written, minus the `key:` header, the surrounding
/// whitespace. Read from the winning (last) occurrence, like every other read. /// 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? { public func rawValue(for key: String) -> String? {
spans.last { $0.key == key }.map { Self.valueText(of: $0.value, key: key) } 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 /// - **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 /// 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 /// 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) { public mutating func set(_ key: String, to value: FrontmatterValue) {
if let index = spans.lastIndex(where: { $0.key == key }) { if let index = spans.lastIndex(where: { $0.key == key }) {
spans[index].value = Self.rewritten(spans[index].value, key: key, to: value) 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). /// 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 { private static func rewritten(_ spanValue: String, key: String, to value: FrontmatterValue) -> String {
let keyText = Self.keyText(of: spanValue, key: key) let keyText = Self.keyText(of: spanValue, key: key)
let (comment, terminator) = Self.inlineComment(of: spanValue, keyText: keyText) let split = Self.splitInlineComment(spanValue, keyText: keyText)
return "\(keyText): \(value.yamlText)\(comment)\(terminator)" return "\(keyText): \(value.yamlText)\(split.comment)\(split.terminator)"
} }
/// The trailing inline comment on a span, its leading whitespace included so it re-splices at /// A span's value lines split three ways: the text that carries the value, the trailing inline
/// the distance the author left, along with the line's own terminator. /// 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 /// **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 /// 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 /// 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 /// 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 /// stays with the value. Pathological shapes keep the whole line as value text instead of
/// the comment instead of making the editor guess: a value spanning several lines (block /// making the editor guess: a value spanning several lines (block scalar, multi-line flow
/// scalar, multi-line flow collection) is not scanned at all, and neither is a single-line /// collection) is not scanned at all, and neither is a single-line flow collection whose
/// flow collection whose quotes do not balance. /// quotes do not balance.
private static func inlineComment(of spanValue: String, keyText: String) -> (comment: String, terminator: String) { 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. // A CRLF pair is one `Character`, so both endings have to be named explicitly.
var characters = Array(spanValue) var characters = Array(spanValue)
var terminator = "\n" var terminator = "\n"
@@ -346,17 +354,21 @@ public struct FrontmatterDocument: Sendable, Equatable {
characters.removeLast() characters.removeLast()
} }
// Only a value written on a single line is scanned; anything taller keeps no comment. // 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 + ":") 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 var valueStart = header.count
while valueStart < characters.count, isSpaceOrTab(characters[valueStart]) { valueStart += 1 } 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 var start = hash
while start > 0, isSpaceOrTab(characters[start - 1]) { start -= 1 } while start > 0, isSpaceOrTab(characters[start - 1]) { start -= 1 }
return (String(characters[start...]), terminator) 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 /// Where this line's comment begins, or nil if it has none. The value's opening character
@@ -426,12 +438,21 @@ public struct FrontmatterDocument: Sendable, Equatable {
character == " " || character == "\t" 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 { private static func valueText(of spanValue: String, key: String) -> String {
let header = keyText(of: spanValue, key: key) + ":" let keyText = Self.keyText(of: spanValue, key: key)
guard spanValue.hasPrefix(header) else { let value = Self.splitInlineComment(spanValue, keyText: keyText).value
return spanValue.trimmingCharacters(in: .whitespacesAndNewlines) 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? { private static func fileLine(of error: YamlError) -> Int? {
+69
View File
@@ -459,6 +459,35 @@ struct FrontmatterAccessTests {
#expect(document.rawValue(for: "labels") == "[a, b, c]") #expect(document.rawValue(for: "labels") == "[a, b, c]")
#expect(document.rawValue(for: "schema") == "1") #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 // 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")) #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 { @Test func quotedStringLenientValuesAreUnaffectedByCoercion() throws {
#expect(try document("title: \"2048\"").title == .valid("2048")) #expect(try document("title: \"2048\"").title == .valid("2048"))
#expect(try document("title: \"true\"").title == .valid("true")) #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") #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 { @Test func theCommentSurvivesAReparse() throws {
var document = try FrontmatterDocument.parse("---\norder: 3072 # keep at top\n---\nbody\n") var document = try FrontmatterDocument.parse("---\norder: 3072 # keep at top\n---\nbody\n")
document.set("order", to: .double(4096)) document.set("order", to: .double(4096))