diff --git a/Frontmatter.Test/LineEndingTests.cs b/Frontmatter.Test/LineEndingTests.cs new file mode 100644 index 0000000..8f6f77c --- /dev/null +++ b/Frontmatter.Test/LineEndingTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that every writer emits the input document's line ending, whatever the host's is. +/// +[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 { ["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 { ["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 { ["title"] = "B", ["tags"] = new List { "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); + } +} diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs index 76b1f83..04dcb52 100644 --- a/Frontmatter/Frontmatter.cs +++ b/Frontmatter/Frontmatter.cs @@ -5,6 +5,7 @@ namespace ktsu.Frontmatter; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text; /// /// Provides methods for processing and manipulating YAML frontmatter in markdown files. @@ -110,9 +111,9 @@ public static string CombineFrontmatter(string input, FrontmatterNaming property 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 @@ -197,8 +198,8 @@ public static string AddFrontmatter(string input, Dictionary fro 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}"; } @@ -219,8 +220,8 @@ public static string ReplaceFrontmatter(string input, Dictionary } 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}"; } @@ -240,7 +241,7 @@ public static string RemoveFrontmatter(string input) } ExtractFrontmatterObjects(input, out string body); - return TrimBody(body) + Environment.NewLine; + return TrimBody(body) + NewLineOf(input); } /// @@ -534,6 +535,53 @@ private static string TrimBody(string body) private static bool IsClosingDelimiterLine(string line) => IsDelimiterLine(line) || line.TrimEnd() == DocumentEndMarker; + /// + /// Finds the line ending a document uses, so a rewritten document keeps it rather than mixing in the + /// host's. + /// + /// The document to inspect. + /// + /// The document's first line ending, CRLF, LF or CR, or when it has none. + /// + 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"; + } + + /// + /// Rewrites every line ending in a piece of text to the given one. + /// + /// The text to rewrite, such as serialized YAML. + /// The line ending to use. + /// The text with only line endings. + 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(); + } + /// /// Combines two frontmatter dictionaries into a single dictionary. ///