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

namespace ktsu.Frontmatter.Test;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that frontmatter delimiters are recognised only as whole lines at the top of a document.
/// </summary>
[TestClass]
public class DelimiterLineTests
{
private static readonly string Nl = Environment.NewLine;

[TestMethod]
public void ExtractFrontmatter_BodyHasHorizontalRulesAroundKeyValueLine_LeavesItInTheBody()
{
string input = $"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}";

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(input);
string body = Frontmatter.ExtractBody(input);

Assert.IsNotNull(frontmatter);
Assert.HasCount(1, frontmatter);
Assert.AreEqual("A", frontmatter["title"]);
Assert.AreEqual($"Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More", body);
}

[TestMethod]
public void CombineFrontmatter_BodyHasHorizontalRulesAroundKeyValueLine_DoesNotMergeItIntoTheHeader()
{
string input = $"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}";

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

Assert.AreEqual($"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}", result);
}

[TestMethod]
public void ExtractFrontmatter_ValueContainsDelimiterMidLine_KeepsTheWholeHeader()
{
string input = $"---{Nl}text: foo---{Nl}title: A{Nl}---{Nl}Body{Nl}";

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(input);
string body = Frontmatter.ExtractBody(input);

Assert.IsNotNull(frontmatter);
Assert.HasCount(2, frontmatter);
Assert.AreEqual("foo---", frontmatter["text"]);
Assert.AreEqual("A", frontmatter["title"]);
Assert.AreEqual("Body", body);
}

[TestMethod]
public void CombineFrontmatter_TwoConsecutiveBlocks_CombinesThemWithoutLeavingTheSecondInTheBody()
{
string input = $"---{Nl}title: A{Nl}---{Nl}---{Nl}author: B{Nl}---{Nl}Body{Nl}";

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

Assert.AreEqual($"---{Nl}title: A{Nl}author: B{Nl}---{Nl}Body{Nl}", result);
}

[TestMethod]
public void ExtractFrontmatter_ClosingDelimiterEndsTheDocument_ReadsTheFrontmatter()
{
string input = $"---{Nl}title: A{Nl}---";

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(input);

Assert.IsNotNull(frontmatter);
Assert.HasCount(1, frontmatter);
Assert.AreEqual("A", frontmatter["title"]);
}

[TestMethod]
public void AddFrontmatter_ClosingDelimiterEndsTheDocument_KeepsTheExistingProperties()
{
string input = $"---{Nl}title: A{Nl}---";

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

Assert.IsNotNull(frontmatter);
Assert.HasCount(2, frontmatter);
Assert.AreEqual("A", frontmatter["title"]);
Assert.AreEqual("B", frontmatter["author"]);
}

[TestMethod]
public void AddFrontmatter_ExistingFrontmatterIsUnreadable_ReturnsTheDocumentUnchanged()
{
string input = $"---{Nl}title: [unclosed{Nl}---{Nl}Body{Nl}";

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

Assert.AreEqual(input, result);
}

[TestMethod]
public void AddFrontmatter_ExistingFrontmatterIsEmpty_AddsTheProperties()
{
string input = $"---{Nl}---{Nl}Body{Nl}";

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

Assert.AreEqual($"---{Nl}author: B{Nl}---{Nl}Body{Nl}", result);
}
}
114 changes: 106 additions & 8 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
return input;
}

Dictionary<string, object> combinedFrontmatterObject = frontmatterObjects.First();

Check warning on line 87 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 87 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"
foreach (Dictionary<string, object> frontmatterObject in frontmatterObjects.Skip(1))
{
combinedFrontmatterObject = CombineFrontmatterObjects(combinedFrontmatterObject, frontmatterObject);
Expand Down Expand Up @@ -135,7 +135,7 @@
}

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

Check warning on line 138 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 138 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

/// <summary>
Expand Down Expand Up @@ -171,6 +171,15 @@
{
// Document already has frontmatter, use CombineFrontmatter instead
Dictionary<string, object>? existing = ExtractFrontmatter(input);

// Frontmatter that is present but could not be read is left alone rather than overwritten, so a
// gap in parsing loses nothing. Only a genuinely empty block is treated as having no properties.
if (existing == null
&& (!TrySplitFrontmatterBlocks(input, out List<string> blocks, out _) || blocks.Exists(block => !string.IsNullOrWhiteSpace(block))))
{
return input;
}

Dictionary<string, object> combined = CombineFrontmatterObjects(existing ?? [], frontmatter);
return ReplaceFrontmatter(input, combined);
}
Expand Down Expand Up @@ -262,7 +271,7 @@
}

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

Check warning on line 274 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 274 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!sortedFrontmatter.ContainsKey(property.Key))
{
Expand Down Expand Up @@ -294,8 +303,8 @@

return index < 0
? Environment.NewLine
: input[index] == '\n' ? "\n"

Check warning on line 306 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 306 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
: index + 1 < input.Length && input[index + 1] == '\n' ? "\r\n"

Check warning on line 307 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 307 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
: "\r";
}

Expand All @@ -309,20 +318,15 @@
private static List<Dictionary<string, object>> ExtractFrontmatterObjects(string input, out string body)
{
List<Dictionary<string, object>> frontmatterObjects = [];
body = input;

if (!HasFrontmatter(input))
if (!TrySplitFrontmatterBlocks(input, out List<string> blocks, out body))
{
return frontmatterObjects;
}

string documentNewLine = DetectNewLine(input);
string[] sections = input.Split([FrontmatterDelimiter + documentNewLine], StringSplitOptions.None);
body = string.Join(FrontmatterDelimiter + documentNewLine, sections.Skip(2));

for (int i = 1; i < sections.Length; i += 2)
foreach (string block in blocks)
{
string section = sections[i].Trim();
string section = block.Trim();
if (string.IsNullOrWhiteSpace(section))
{
continue;
Expand All @@ -337,6 +341,100 @@
return frontmatterObjects;
}

/// <summary>
/// Splits the frontmatter blocks at the top of a document from its body.
/// </summary>
/// <remarks>
/// Delimiters are recognised only as whole lines, so a <c>---</c> inside a value or a markdown
/// horizontal rule in the body is never mistaken for one. The first line opens a block, which closes
/// at the next delimiter line, including one at the very end of the document. Further blocks are
/// consumed only while each opens on the line straight after the previous one closed; the body is
/// everything after the last block consumed.
/// </remarks>
/// <param name="input">The markdown document content as a string.</param>
/// <param name="blocks">The raw text of each frontmatter block, in document order.</param>
/// <param name="body">The document after the last frontmatter block, or the whole input when there is none.</param>
/// <returns>True if at least one closed frontmatter block was found, false otherwise.</returns>
private static bool TrySplitFrontmatterBlocks(string input, out List<string> blocks, out string body)
{
blocks = [];
body = input;

if (!HasFrontmatter(input))
{
return false;
}

List<(int Start, int End)> lines = SplitLines(input);
bool IsDelimiterAt(int index) => IsDelimiterLine(input[lines[index].Start..lines[index].End]);

int next = 0;
while (next < lines.Count && IsDelimiterAt(next))
{
int close = next + 1;
while (close < lines.Count && !IsDelimiterAt(close))
{
close++;
}

if (close == lines.Count)
{
break;
}

blocks.Add(close == next + 1
? string.Empty
: input[lines[next + 1].Start..lines[close - 1].End]);
next = close + 1;
}

if (blocks.Count == 0)
{
return false;
}

body = next < lines.Count ? input[lines[next].Start..] : string.Empty;
return true;
}

/// <summary>
/// Finds the lines of a document, recognising CRLF, LF and CR endings wherever they occur.
/// </summary>
/// <param name="input">The document to split.</param>
/// <returns>The start and end offset of each line, excluding its line ending.</returns>
private static List<(int Start, int End)> SplitLines(string input)
{
List<(int Start, int End)> lines = [];
int start = 0;

for (int i = 0; i < input.Length; i++)
{
char c = input[i];
if (c is not '\r' and not '\n')
{
continue;
}

lines.Add((start, i));
if (c == '\r' && i + 1 < input.Length && input[i + 1] == '\n')
{
i++;

Check warning on line 421 in Frontmatter/Frontmatter.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Frontmatter&issues=AaDdvFw5MBdIn6dpBfTW&open=AaDdvFw5MBdIn6dpBfTW&pullRequest=135
}

start = i + 1;
}

lines.Add((start, input.Length));
return lines;
}

/// <summary>
/// Checks whether a line is a frontmatter delimiter, allowing trailing whitespace.
/// </summary>
/// <param name="line">The line to check, without its line ending.</param>
/// <returns>True if the line is a frontmatter delimiter, false otherwise.</returns>
private static bool IsDelimiterLine(string line) => line.TrimEnd() == FrontmatterDelimiter;

/// <summary>
/// Combines two frontmatter dictionaries into a single dictionary.
/// </summary>
Expand Down
Loading