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
86 changes: 86 additions & 0 deletions Frontmatter.Test/LineEndingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that every writer emits the input document's line ending, whatever the host's is.
/// </summary>
[TestClass]
public class LineEndingTests
{
private static string Lines(string newLine, params string[] lines) => string.Join(newLine, lines) + newLine;

private static void AssertOnlyLineEnding(string newLine, string text)
{
string withoutLineEndings = text.Replace(newLine, string.Empty, StringComparison.Ordinal);
Assert.DoesNotContain("\r", withoutLineEndings);
Assert.DoesNotContain("\n", withoutLineEndings);
}

[TestMethod]
[DataRow("\r\n", DisplayName = "CRLF")]
[DataRow("\n", DisplayName = "LF")]
public void CombineFrontmatter_KeepsTheInputLineEnding(string nl)
{
string input = Lines(nl, "---", "title: A", "---", "---", "author: B", "---", "line1", "line2");

string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None);

Assert.AreEqual(Lines(nl, "---", "title: A", "author: B", "---", "line1", "line2"), result);
AssertOnlyLineEnding(nl, result);
}

[TestMethod]
[DataRow("\r\n", DisplayName = "CRLF")]
[DataRow("\n", DisplayName = "LF")]
public void AddFrontmatter_DocumentWithoutHeader_KeepsTheInputLineEnding(string nl)
{
string input = Lines(nl, "line1", "line2");

string result = Frontmatter.AddFrontmatter(input, new Dictionary<string, object> { ["title"] = "T", ["author"] = "B" });

Assert.AreEqual(Lines(nl, "---", "title: T", "author: B", "---", "line1", "line2"), result);
AssertOnlyLineEnding(nl, result);
}

[TestMethod]
[DataRow("\r\n", DisplayName = "CRLF")]
[DataRow("\n", DisplayName = "LF")]
public void AddFrontmatter_DocumentWithHeader_KeepsTheInputLineEnding(string nl)
{
string input = Lines(nl, "---", "title: A", "---", "line1", "line2");

string result = Frontmatter.AddFrontmatter(input, new Dictionary<string, object> { ["author"] = "B" });

Assert.AreEqual(Lines(nl, "---", "title: A", "author: B", "---", "line1", "line2"), result);
AssertOnlyLineEnding(nl, result);
}

[TestMethod]
[DataRow("\r\n", DisplayName = "CRLF")]
[DataRow("\n", DisplayName = "LF")]
public void ReplaceFrontmatter_KeepsTheInputLineEnding(string nl)
{
string input = Lines(nl, "---", "title: A", "---", "line1", "line2");

string result = Frontmatter.ReplaceFrontmatter(input, new Dictionary<string, object> { ["title"] = "B", ["tags"] = new List<object> { "x", "y" } });

Assert.StartsWith($"---{nl}title: B{nl}tags:{nl}", result);
Assert.EndsWith($"{nl}---{nl}line1{nl}line2{nl}", result);
AssertOnlyLineEnding(nl, result);
}

[TestMethod]
[DataRow("\r\n", DisplayName = "CRLF")]
[DataRow("\n", DisplayName = "LF")]
public void RemoveFrontmatter_KeepsTheInputLineEnding(string nl)
{
string input = Lines(nl, "---", "title: A", "---", "line1", "line2");

string result = Frontmatter.RemoveFrontmatter(input);

Assert.AreEqual(Lines(nl, "line1", "line2"), result);
}
}
62 changes: 55 additions & 7 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;

/// <summary>
/// Provides methods for processing and manipulating YAML frontmatter in markdown files.
Expand Down Expand Up @@ -110,9 +111,9 @@
combinedFrontmatterObject = SortFrontmatterProperties(combinedFrontmatterObject);
}

string combinedFrontmatter = YamlSerializer.SerializeYamlObject(combinedFrontmatterObject).Trim();
string nl = NewLineOf(input);
string combinedFrontmatter = WithNewLines(YamlSerializer.SerializeYamlObject(combinedFrontmatterObject).Trim(), nl);

string nl = Environment.NewLine;
string result = $"{FrontmatterDelimiter}{nl}{combinedFrontmatter}{nl}{FrontmatterDelimiter}{nl}{body}";

// Cache the processed result
Expand All @@ -137,7 +138,7 @@
}

List<Dictionary<string, object>> frontmatterObjects = ExtractFrontmatterObjects(input, out _);
return frontmatterObjects.Count > 0 ? frontmatterObjects.First() : null;

Check warning on line 141 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"

Check warning on line 141 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"
}

/// <summary>
Expand Down Expand Up @@ -197,8 +198,8 @@
return ReplaceFrontmatter(input, combined);
}

string yamlFrontmatter = YamlSerializer.SerializeYamlObject(frontmatter).Trim();
string nl = Environment.NewLine;
string nl = NewLineOf(input);
string yamlFrontmatter = WithNewLines(YamlSerializer.SerializeYamlObject(frontmatter).Trim(), nl);
return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{TrimBody(input)}{nl}";
}

Expand All @@ -219,8 +220,8 @@
}

ExtractFrontmatterObjects(input, out string body);
string yamlFrontmatter = YamlSerializer.SerializeYamlObject(frontmatter).Trim();
string nl = Environment.NewLine;
string nl = NewLineOf(input);
string yamlFrontmatter = WithNewLines(YamlSerializer.SerializeYamlObject(frontmatter).Trim(), nl);
return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{TrimBody(body)}{nl}";
}

Expand All @@ -240,7 +241,7 @@
}

ExtractFrontmatterObjects(input, out string body);
return TrimBody(body) + Environment.NewLine;
return TrimBody(body) + NewLineOf(input);
}

/// <summary>
Expand Down Expand Up @@ -284,7 +285,7 @@
}

// Then add any remaining properties that weren't in the standard order
foreach (KeyValuePair<string, object> property in frontmatter)

Check warning on line 288 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 288 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!sortedFrontmatter.ContainsKey(property.Key))
{
Expand Down Expand Up @@ -334,7 +335,7 @@
continue;
}

if (YamlSerializer.TryParseYamlObject(section, out Dictionary<string, object>? frontmatterObject) && frontmatterObject != null)

Check warning on line 338 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Change this condition so that it does not always evaluate to 'True'.

Check warning on line 338 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Change this condition so that it does not always evaluate to 'True'.
{
frontmatterObjects.Add(frontmatterObject);
}
Expand Down Expand Up @@ -482,7 +483,7 @@
lines.Add((start, i));
if (c == '\r' && i + 1 < input.Length && input[i + 1] == '\n')
{
i++;

Check warning on line 486 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 486 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
}

start = i + 1;
Expand Down Expand Up @@ -534,6 +535,53 @@
private static bool IsClosingDelimiterLine(string line) =>
IsDelimiterLine(line) || line.TrimEnd() == DocumentEndMarker;

/// <summary>
/// Finds the line ending a document uses, so a rewritten document keeps it rather than mixing in the
/// host's.
/// </summary>
/// <param name="input">The document to inspect.</param>
/// <returns>
/// The document's first line ending, CRLF, LF or CR, or <see cref="Environment.NewLine"/> when it has none.
/// </returns>
private static string NewLineOf(string input)
{
int index = input.IndexOfAny(['\r', '\n']);
if (index < 0)
{
return Environment.NewLine;
}

if (input[index] == '\n')
{
return "\n";
}

return index + 1 < input.Length && input[index + 1] == '\n' ? "\r\n" : "\r";
}

/// <summary>
/// Rewrites every line ending in a piece of text to the given one.
/// </summary>
/// <param name="text">The text to rewrite, such as serialized YAML.</param>
/// <param name="newLine">The line ending to use.</param>
/// <returns>The text with only <paramref name="newLine"/> line endings.</returns>
private static string WithNewLines(string text, string newLine)
{
List<(int Start, int End)> lines = SplitLines(text);
StringBuilder builder = new(text.Length);
for (int i = 0; i < lines.Count; i++)
{
if (i > 0)
{
builder.Append(newLine);
}

builder.Append(text, lines[i].Start, lines[i].End - lines[i].Start);
}

return builder.ToString();
}

/// <summary>
/// Combines two frontmatter dictionaries into a single dictionary.
/// </summary>
Expand All @@ -552,7 +600,7 @@
}

// Then, add properties from dictionary b that don't exist in a
foreach (KeyValuePair<string, object> kvp in b)

Check warning on line 603 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 603 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!combinedFrontmatterObject.ContainsKey(kvp.Key))
{
Expand Down
Loading