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:
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user