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
}
/// 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[..<start]), String(characters[start...]), terminator)
}
/// 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"
}
/// 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 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? {