Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Yamlify/Reader/Utf8YamlReader.Parsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 42 additions & 9 deletions src/Yamlify/Reader/Utf8YamlReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> _valueSpan;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -566,12 +569,13 @@ private static string DecodeSingleQuotedString(ReadOnlySpan<byte> content)
/// <summary>
/// Processes block scalar content by stripping indentation and applying folding rules.
/// </summary>
private static string ProcessBlockScalarContent(ReadOnlySpan<byte> content, int indent, ScalarStyle style)
private static string ProcessBlockScalarContent(ReadOnlySpan<byte> 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)
{
Expand All @@ -593,6 +597,16 @@ private static string ProcessBlockScalarContent(ReadOnlySpan<byte> 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)
{
Expand Down Expand Up @@ -638,14 +652,33 @@ private static string ProcessBlockScalarContent(ReadOnlySpan<byte> 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();
}

/// <summary>
Expand Down
89 changes: 87 additions & 2 deletions src/Yamlify/Writer/Utf8YamlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -677,10 +677,22 @@ private void WriteScalarValue(ReadOnlySpan<char> 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)'\'');
Expand All @@ -706,6 +718,79 @@ private void WriteScalarValue(ReadOnlySpan<char> value)
}
}

private static bool ContainsNewline(ReadOnlySpan<char> value)
{
foreach (char c in value)
{
if (c == '\n')
{
return true;
}
}
return false;
}

private void WriteLiteralScalarValue(ReadOnlySpan<char> 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<char> value)
{
if (value.IsEmpty) return true;
Expand Down
111 changes: 111 additions & 0 deletions test/Yamlify.Tests/Serialization/PrimitiveSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down