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