From 945e7c59b696bdcd6ff8411707e9a314c520c075 Mon Sep 17 00:00:00 2001 From: glucaci Date: Wed, 21 Jan 2026 15:21:06 +0100 Subject: [PATCH] feat: implement block scalar chomping handling for serialization and deserialization --- src/Yamlify/Reader/Utf8YamlReader.Parsing.cs | 6 + src/Yamlify/Reader/Utf8YamlReader.cs | 51 ++++++-- src/Yamlify/Writer/Utf8YamlWriter.cs | 89 +++++++++++++- .../PrimitiveSerializationTests.cs | 111 ++++++++++++++++++ 4 files changed, 246 insertions(+), 11 deletions(-) diff --git a/src/Yamlify/Reader/Utf8YamlReader.Parsing.cs b/src/Yamlify/Reader/Utf8YamlReader.Parsing.cs index d6bc7fe..3e58e0c 100644 --- a/src/Yamlify/Reader/Utf8YamlReader.Parsing.cs +++ b/src/Yamlify/Reader/Utf8YamlReader.Parsing.cs @@ -2157,6 +2157,9 @@ private bool ParseLiteralBlockScalar() // Store the content indentation for later processing in GetString() _blockScalarIndent = contentIndent; + // Store the chomping indicator for later processing in GetString() + _blockScalarChomping = chomping; + int valueStart = _consumed; // Collect all lines with proper indentation @@ -2216,6 +2219,9 @@ private bool ParseFoldedBlockScalar() // Store the content indentation for later processing in GetString() _blockScalarIndent = contentIndent; + // Store the chomping indicator for later processing in GetString() + _blockScalarChomping = chomping; + int valueStart = _consumed; while (_consumed < _buffer.Length) diff --git a/src/Yamlify/Reader/Utf8YamlReader.cs b/src/Yamlify/Reader/Utf8YamlReader.cs index c2153c5..e873cc0 100644 --- a/src/Yamlify/Reader/Utf8YamlReader.cs +++ b/src/Yamlify/Reader/Utf8YamlReader.cs @@ -30,6 +30,9 @@ public ref partial struct Utf8YamlReader // Block scalar indentation tracking (for stripping leading spaces) private int _blockScalarIndent; + // Block scalar chomping indicator (for handling trailing newlines) + private ChompingIndicator _blockScalarChomping; + // Current token value storage private ReadOnlySpan _valueSpan; @@ -262,9 +265,9 @@ public bool Read() } // For block scalars, we need to strip the content indentation - if (_scalarStyle is ScalarStyle.Literal or ScalarStyle.Folded && _blockScalarIndent > 0) + if (_scalarStyle is ScalarStyle.Literal or ScalarStyle.Folded) { - return ProcessBlockScalarContent(_valueSpan, _blockScalarIndent, _scalarStyle); + return ProcessBlockScalarContent(_valueSpan, _blockScalarIndent, _scalarStyle, _blockScalarChomping); } // For double-quoted strings, decode escape sequences @@ -566,12 +569,13 @@ private static string DecodeSingleQuotedString(ReadOnlySpan content) /// /// Processes block scalar content by stripping indentation and applying folding rules. /// - private static string ProcessBlockScalarContent(ReadOnlySpan content, int indent, ScalarStyle style) + private static string ProcessBlockScalarContent(ReadOnlySpan content, int indent, ScalarStyle style, ChompingIndicator chomping) { var result = new System.Text.StringBuilder(); int pos = 0; bool firstLine = true; bool previousWasEmpty = false; + int trailingNewlines = 0; while (pos < content.Length) { @@ -593,6 +597,16 @@ private static string ProcessBlockScalarContent(ReadOnlySpan content, int int lineEnd = pos; bool isEmptyLine = lineStart == lineEnd; + // Track trailing newlines for chomping + if (isEmptyLine) + { + trailingNewlines++; + } + else + { + trailingNewlines = 0; + } + // Handle line content if (style == ScalarStyle.Folded) { @@ -638,14 +652,33 @@ private static string ProcessBlockScalarContent(ReadOnlySpan content, int } } - // Apply chomping: default is Clip (single trailing newline) - // For now, just add a trailing newline for non-empty content - if (result.Length > 0 && !result.ToString().EndsWith('\n')) + // Apply chomping based on indicator + // Strip (-): remove all trailing newlines + // Clip (default): single trailing newline + // Keep (+): preserve all trailing newlines + var resultStr = result.ToString(); + + switch (chomping) { - result.Append('\n'); + case ChompingIndicator.Strip: + // Remove all trailing newlines + return resultStr.TrimEnd('\n', '\r'); + + case ChompingIndicator.Keep: + // Preserve trailing newlines from input (already captured as empty lines) + // Add a final newline for the last line if not already present + if (resultStr.Length > 0 && !resultStr.EndsWith('\n')) + { + return resultStr + '\n'; + } + return resultStr; + + case ChompingIndicator.Clip: + default: + // Exactly one trailing newline + var trimmed = resultStr.TrimEnd('\n', '\r'); + return trimmed.Length > 0 ? trimmed + '\n' : trimmed; } - - return result.ToString(); } /// diff --git a/src/Yamlify/Writer/Utf8YamlWriter.cs b/src/Yamlify/Writer/Utf8YamlWriter.cs index 293c361..6405acd 100644 --- a/src/Yamlify/Writer/Utf8YamlWriter.cs +++ b/src/Yamlify/Writer/Utf8YamlWriter.cs @@ -677,10 +677,22 @@ private void WriteScalarValue(ReadOnlySpan value) WriteRaw("''"u8); return; } - + + // Check if we should use literal block style for multi-line strings + // Use literal block style when: + // 1. Not in flow context (block scalars not allowed in flow) + // 2. String contains actual newlines (\n) + // 3. DefaultScalarStyle is Any (auto-detect) or Literal + if (!_inFlowContext && ContainsNewline(value) && + (_options.DefaultScalarStyle is ScalarStyle.Any or ScalarStyle.Literal)) + { + WriteLiteralScalarValue(value); + return; + } + // Determine if quoting is needed bool needsQuoting = NeedsQuoting(value); - + if (needsQuoting) { WriteRaw((byte)'\''); @@ -706,6 +718,79 @@ private void WriteScalarValue(ReadOnlySpan value) } } + private static bool ContainsNewline(ReadOnlySpan value) + { + foreach (char c in value) + { + if (c == '\n') + { + return true; + } + } + return false; + } + + private void WriteLiteralScalarValue(ReadOnlySpan value) + { + // Determine the chomping indicator based on trailing newlines + // - No indicator (clip): single trailing newline is kept, additional ones are stripped + // - '-' (strip): all trailing newlines are stripped + // - '+' (keep): all trailing newlines are preserved + bool endsWithNewline = value.Length > 0 && value[^1] == '\n'; + bool hasMultipleTrailingNewlines = value.Length > 1 && value[^1] == '\n' && + (value[^2] == '\n' || (value[^2] == '\r' && value.Length > 2 && value[^3] == '\n')); + + WriteRaw((byte)'|'); + if (!endsWithNewline) + { + WriteRaw((byte)'-'); // Strip chomping - no trailing newline + } + else if (hasMultipleTrailingNewlines) + { + WriteRaw((byte)'+'); // Keep chomping - preserve multiple trailing newlines + } + // Otherwise, default clip chomping (single trailing newline) + WriteNewLine(); + + // Split the value into lines and write each with proper indentation + int start = 0; + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '\n') + { + WriteIndent(_currentDepth); + var line = value[start..i]; + // Remove trailing \r if present (handle \r\n) + if (line.Length > 0 && line[^1] == '\r') + { + line = line[..^1]; + } + WriteRaw(Encoding.UTF8.GetBytes(line.ToArray())); + WriteNewLine(); + start = i + 1; + } + } + + // Write the last line if there's content after the last newline + if (start < value.Length) + { + var lastLine = value[start..]; + // Remove trailing \r if present + if (lastLine.Length > 0 && lastLine[^1] == '\r') + { + lastLine = lastLine[..^1]; + } + if (lastLine.Length > 0) + { + WriteIndent(_currentDepth); + WriteRaw(Encoding.UTF8.GetBytes(lastLine.ToArray())); + // Note: No WriteNewLine() here - the next property will start on a new line anyway + // For strip chomping, we explicitly don't want a trailing newline in the content + // For clip/keep, trailing newlines are handled by the newlines in the original value + } + } + } + private static bool NeedsQuoting(ReadOnlySpan value) { if (value.IsEmpty) return true; diff --git a/test/Yamlify.Tests/Serialization/PrimitiveSerializationTests.cs b/test/Yamlify.Tests/Serialization/PrimitiveSerializationTests.cs index ea55427..377095d 100644 --- a/test/Yamlify.Tests/Serialization/PrimitiveSerializationTests.cs +++ b/test/Yamlify.Tests/Serialization/PrimitiveSerializationTests.cs @@ -382,6 +382,117 @@ public void SerializeWithMultilineString() Assert.NotNull(yaml); } + [Fact] + public void SerializeWithMultilineString_UsesLiteralBlockStyle() + { + var obj = new SimpleClass { Name = "Line1\nLine2\nLine3", Value = 42, IsActive = true }; + + var yaml = YamlSerializer.Serialize(obj, TestSerializerContext.Default.SimpleClass); + + // Should use literal block style (|) for multi-line strings, not quoted style + Assert.Contains("name: |-", yaml); + Assert.Contains(" Line1", yaml); + Assert.Contains(" Line2", yaml); + Assert.Contains(" Line3", yaml); + } + + [Fact] + public void SerializeWithMultilineString_WithTrailingNewline_UsesLiteralBlockStyleWithClip() + { + var obj = new SimpleClass { Name = "Line1\nLine2\n", Value = 42, IsActive = true }; + + var yaml = YamlSerializer.Serialize(obj, TestSerializerContext.Default.SimpleClass); + + // Should use literal block style with clip (default, single trailing newline preserved) + Assert.Contains("name: |", yaml); + Assert.DoesNotContain("name: |-", yaml); + Assert.DoesNotContain("name: |+", yaml); + } + + [Fact] + public void ReadBlockScalarWithStripChomping_NoTrailingNewline() + { + // Directly test the reader with a block scalar using strip chomping + var yaml = """ + name: |- + First line + Second line + Third line + """u8; + + var reader = new Utf8YamlReader(yaml); + string? nameValue = null; + while (reader.Read()) + { + if (reader.TokenType == YamlTokenType.Scalar) + { + var value = reader.GetString(); + if (value == "name") + { + reader.Read(); // move to value + nameValue = reader.GetString(); + break; + } + } + } + + // The strip chomping indicator (-) should remove trailing newlines + Assert.Equal("First line\nSecond line\nThird line", nameValue); + } + + [Fact] + public void ReadBlockScalarWithStripChomping_FollowedByOtherContent() + { + // Test that strip chomping works when there's more content after the block scalar + var yaml = """ + name: |- + First line + Second line + Third line + value: 42 + """u8; + + var reader = new Utf8YamlReader(yaml); + string? nameValue = null; + while (reader.Read()) + { + if (reader.TokenType == YamlTokenType.Scalar) + { + var value = reader.GetString(); + if (value == "name") + { + reader.Read(); // move to value + nameValue = reader.GetString(); + break; + } + } + } + + // The strip chomping indicator (-) should remove trailing newlines + Assert.Equal("First line\nSecond line\nThird line", nameValue); + } + + [Fact] + public void RoundtripMultilineString_PreservesContent() + { + var original = new SimpleClass { Name = "First line\nSecond line\nThird line", Value = 42, IsActive = true }; + + var yaml = YamlSerializer.Serialize(original, TestSerializerContext.Default.SimpleClass); + + // The YAML should use |- (strip chomping) since the string doesn't end with newline + Assert.Contains("name: |-", yaml); + + // Make sure the generated YAML looks correct - check for proper indentation + Assert.Contains(" First line", yaml); + Assert.Contains(" Second line", yaml); + Assert.Contains(" Third line", yaml); + + var deserialized = YamlSerializer.Deserialize(yaml, TestSerializerContext.Default.SimpleClass); + + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + } + [Fact] public void DeserializeMultilinePlainScalar_FoldsToSingleLine() {