From 03d21654e1aacaa252bf3e14033dbae800077ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Sat, 3 Oct 2026 18:34:36 +0100 Subject: [PATCH] Fix literal block scalars losing a newline as the last node WriteLiteralString() drops one trailing newline from 'strip' and 'keep' chomping styles on the assumption that whatever gets emitted next will start with the '\n' that separates it from this node, and that extra character stands in for the one we dropped. That holds as long as something else follows, but when the literal scalar is the last thing written before the document ends (e.g. the last value in a map, or the last entry in a sequence, with nothing after), no such newline ever gets written, and the content silently comes out one line break short on the next load. Repro: emit a map whose only/last value is Literal << "a\nb\n" and compare the round-tripped string against the original - it loses the trailing break, and with two or more trailing breaks it loses one of those too ('keep' style). WriteLiteralString now reports whether it dropped such a newline, and Emitter remembers that via a small flag, clearing it as soon as any further node is prepared (which is exactly when the existing "borrow" mechanism actually supplies the break). If nothing else is written, c_str() adds the missing line break itself before returning the buffer, so the scalar stands on its own either way. Added two regression tests covering a literal as the final map value and as the final sequence entry; the rest of the existing emitter suite, including the sequence case that already relied on the follow-up newline, still passes unchanged. --- include/yaml-cpp/emitter.h | 7 +++++++ src/emitter.cpp | 16 ++++++++++++++-- src/emitterutils.cpp | 21 ++++++++++++++++----- src/emitterutils.h | 3 ++- test/integration/emitter_test.cpp | 23 +++++++++++++++++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/include/yaml-cpp/emitter.h b/include/yaml-cpp/emitter.h index 8ff94d171..e79d5afe0 100644 --- a/include/yaml-cpp/emitter.h +++ b/include/yaml-cpp/emitter.h @@ -149,6 +149,13 @@ class YAML_CPP_API Emitter { private: std::unique_ptr m_pState; ostream_wrapper m_stream; + + // Set when the last thing written was a literal block scalar whose + // chomping style ('strip' or 'keep') still owes the stream a trailing + // line break. Normally the next node emitted supplies that break as a + // side effect of starting its own line, but if nothing else is emitted, + // c_str() has to add it itself so the scalar round-trips correctly. + mutable bool m_literalNeedsTrailingNewline = false; }; template diff --git a/src/emitter.cpp b/src/emitter.cpp index bcae0bcd8..2308463a1 100644 --- a/src/emitter.cpp +++ b/src/emitter.cpp @@ -18,7 +18,13 @@ Emitter::Emitter(std::ostream& stream) Emitter::~Emitter() = default; -const char* Emitter::c_str() const { return m_stream.str(); } +const char* Emitter::c_str() const { + if (m_literalNeedsTrailingNewline) { + const_cast(this)->m_stream << "\n"; + m_literalNeedsTrailingNewline = false; + } + return m_stream.str(); +} std::size_t Emitter::size() const { return m_stream.pos(); } @@ -282,6 +288,11 @@ void Emitter::EmitNewline() { // Put the stream in a state so we can simply write the next node // E.g., if we're in a sequence, write the "- " void Emitter::PrepareNode(EmitterNodeType::value child) { + // Whatever we're about to emit starts its own line (or continues the + // current one), so any line break a previous literal block scalar was + // relying on us to provide is no longer something c_str() needs to add. + m_literalNeedsTrailingNewline = false; + switch (m_pState->CurGroupNodeType()) { case EmitterNodeType::NoType: PrepareTopNode(child); @@ -750,7 +761,8 @@ Emitter& Emitter::Write(const char* str, std::size_t size) { break; case StringFormat::Literal: Utils::WriteLiteralString(m_stream, str, size, - m_pState->CurIndent() + m_pState->GetIndent()); + m_pState->CurIndent() + m_pState->GetIndent(), + &m_literalNeedsTrailingNewline); break; } diff --git a/src/emitterutils.cpp b/src/emitterutils.cpp index 57f41ac38..3aeddcfad 100644 --- a/src/emitterutils.cpp +++ b/src/emitterutils.cpp @@ -395,7 +395,7 @@ bool WriteDoubleQuotedString(ostream_wrapper& out, const char* str, std::size_t } bool WriteLiteralString(ostream_wrapper& out, const char* str, std::size_t size, - std::size_t indent) { + std::size_t indent, bool* needs_trailing_newline) { // depending on the numbers of new lines at the end of the string // we need to use 'clip (-)', 'strip (default)' or 'keep' (+) annotation. // if there is no newline at the end, we need 'clip' @@ -405,18 +405,29 @@ bool WriteLiteralString(ostream_wrapper& out, const char* str, std::size_t size, // see YAML spec 1.2 chapter '8.1.1.2 Block Chomping Indicator' for more information // https://yaml.org/spec/1.2.2/#8112-block-chomping-indicator - - // The chomping depends on the number of new lines. - // The output following this 'WriteLiteralString' call will add an additional '\n', - // because of this we need to remove one in the case of 'strip' and 'keep'. + if (needs_trailing_newline) + *needs_trailing_newline = false; + + // The chomping depends on the number of new lines. Whatever is emitted + // after this call normally starts with the '\n' that separates it from + // this node, and that's the one we rely on for the final line break of + // the 'strip' and 'keep' styles below, so we drop one from what we write + // here. But when this literal happens to be the very last thing emitted + // (no sibling or closing node follows it), that '\n' never materializes + // and the caller needs to know a line break is still owed so it can add + // one itself once the document is finalized. if (size == 0 || str[size-1] != '\n') { // clip out << "|-\n"; } else if (size == 1 || str[size-2] != '\n') { // strip out << "|\n"; size -= 1; + if (needs_trailing_newline) + *needs_trailing_newline = true; } else { // 'keep' out << "|+\n"; size -= 1; + if (needs_trailing_newline) + *needs_trailing_newline = true; } int codePoint; for (const char* i = str; diff --git a/src/emitterutils.h b/src/emitterutils.h index 328f782b4..23e160b8a 100644 --- a/src/emitterutils.h +++ b/src/emitterutils.h @@ -41,7 +41,8 @@ bool WriteSingleQuotedString(ostream_wrapper& out, const char* str, std::size_t bool WriteDoubleQuotedString(ostream_wrapper& out, const char* str, std::size_t size, StringEscaping::value stringEscaping); bool WriteLiteralString(ostream_wrapper& out, const char* str, std::size_t size, - std::size_t indent); + std::size_t indent, + bool* needs_trailing_newline = nullptr); bool WriteChar(ostream_wrapper& out, char ch, StringEscaping::value stringEscapingStyle); bool WriteComment(ostream_wrapper& out, const char* str, std::size_t size, diff --git a/test/integration/emitter_test.cpp b/test/integration/emitter_test.cpp index 8dc3d6d26..c39757589 100644 --- a/test/integration/emitter_test.cpp +++ b/test/integration/emitter_test.cpp @@ -465,6 +465,29 @@ TEST_F(EmitterTest, LiteralWithAndWithoutTrailingEmptyLines) { "- something"); } +TEST_F(EmitterTest, LiteralTrailingNewlinesRoundTripAsLastMapValue) { + // A literal scalar using the 'strip' or 'keep' chomping style used to + // lose a trailing newline whenever it was the very last thing written, + // because the emitter counted on whatever came after it to supply one + // more '\n'. With nothing following, that line break was never written. + out << BeginMap; + out << Key << "data" << Value << Literal << "hello\nworld\n\n"; + out << EndMap; + + Node reparsed = Load(out.c_str()); + EXPECT_EQ(reparsed["data"].as(), "hello\nworld\n\n"); +} + +TEST_F(EmitterTest, LiteralTrailingNewlinesRoundTripAsLastSeqEntry) { + out << BeginSeq; + out << "before"; + out << Literal << "hello\n"; + out << EndSeq; + + Node reparsed = Load(out.c_str()); + EXPECT_EQ(reparsed[1].as(), "hello\n"); +} + TEST_F(EmitterTest, SingleQuotedWithCarriageReturn) { out << BeginMap; out << Key << "key" << Value << SingleQuoted << "a\rb";