From 3e0d5976d00d534c7ac8e41ea39eea85c50b5a0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:27:27 +0000 Subject: [PATCH] Write a rewritten document's header in the document's own line endings [patch] CombineFrontmatter, AddFrontmatter, ReplaceFrontmatter and RemoveFrontmatter built the delimiters, the YAML lines and the trailing newline from Environment.NewLine while copying the body verbatim, so a CRLF document on Linux (or an LF one on Windows) came back with mixed line endings. Each writer now uses the input's first line ending, falling back to Environment.NewLine when it has none, and rewrites the serialized YAML to match. Fixes ktsu-dev/Frontmatter#158 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAEodt8KsqJyMYzdfoAfo --- Frontmatter.Test/LineEndingTests.cs | 86 +++++++++++++++++++++++++++++ Frontmatter/Frontmatter.cs | 62 ++++++++++++++++++--- 2 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 Frontmatter.Test/LineEndingTests.cs 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. ///