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

namespace ktsu.Frontmatter.Test;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that a document opening with a <c>---</c> rule that never closes is treated as having no frontmatter.
/// </summary>
[TestClass]
public class UnclosedOpeningRuleTests
{
private static readonly string Nl = Environment.NewLine;

private static readonly string Document = $"---{Nl}{Nl}# Heading{Nl}{Nl}Text{Nl}";

[TestMethod]
public void HasFrontmatter_OpeningRuleNeverCloses_IsFalse()
{
Assert.IsFalse(Frontmatter.HasFrontmatter($"---{Nl}{Nl}# Heading{Nl}"));
Assert.IsFalse(Frontmatter.HasFrontmatter(Document));
}

[TestMethod]
public void ExtractFrontmatter_OpeningRuleNeverCloses_IsNull()
{
Assert.IsNull(Frontmatter.ExtractFrontmatter(Document));
}

[TestMethod]
public void AddFrontmatter_OpeningRuleNeverCloses_WritesAHeaderInFrontOfTheRule()
{
string result = Frontmatter.AddFrontmatter(Document, new Dictionary<string, object> { ["title"] = "T" });

Assert.AreEqual($"---{Nl}title: T{Nl}---{Nl}---{Nl}{Nl}# Heading{Nl}{Nl}Text{Nl}", result);

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(result);
Assert.IsNotNull(frontmatter);
Assert.HasCount(1, frontmatter);
Assert.AreEqual("T", frontmatter["title"]);
Assert.AreEqual($"---{Nl}{Nl}# Heading{Nl}{Nl}Text", Frontmatter.ExtractBody(result));
}

[TestMethod]
public void RemoveFrontmatter_OpeningRuleNeverCloses_LeavesTheDocumentAlone()
{
Assert.AreEqual(Document, Frontmatter.RemoveFrontmatter(Document));
}
}
24 changes: 20 additions & 4 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,35 @@
}

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

Check warning on line 140 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 140 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 140 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>
/// Checks if a markdown document contains frontmatter.
/// </summary>
/// <param name="input">The markdown document content as a string.</param>
/// <remarks>
/// Only a closed block counts. A document that opens with a <c>---</c> line that never closes, such as
/// a markdown thematic break, has no frontmatter, so <see cref="AddFrontmatter"/> writes a header in front
/// of it instead of leaving it alone.
/// </remarks>
/// <returns>True if the document contains frontmatter, false otherwise.</returns>
/// <exception cref="ArgumentNullException">Thrown when input is null.</exception>
public static bool HasFrontmatter(string input)
{
Ensure.NotNull(input);

return TrySplitFrontmatterBlocks(input, out _, out _);
}

/// <summary>
/// Checks whether the first line of a document is a frontmatter delimiter, whether or not a closing
/// delimiter follows it.
/// </summary>
/// <param name="input">The document to inspect.</param>
/// <returns>True if the document's first line is a delimiter, false otherwise.</returns>
private static bool OpensWithDelimiter(string input)
{
// The opening delimiter follows the same rule as the closing one, so trailing whitespace an editor
// left behind does not hide the header. A leading byte order mark is skipped, since callers that
// decode bytes or streams themselves keep it where File.ReadAllText would strip it.
Expand Down Expand Up @@ -284,7 +300,7 @@
}

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

Check warning on line 303 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 303 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 303 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 All @@ -311,8 +327,9 @@
/// <param name="input">The markdown document content as a string.</param>
/// <param name="body">Output parameter that will contain the markdown body without frontmatter.</param>
/// <param name="hasUnreadableBlock">
/// True when the document opens with a frontmatter delimiter but a non-blank block failed to parse, or no
/// closed block could be found. Such text is missing from the returned objects but not from the document.
/// True when a non-blank frontmatter block failed to parse. Such text is missing from the returned objects
/// but not from the document. An opening delimiter that never closes is not frontmatter, so it is not
/// reported here.
/// </param>
/// <returns>A collection of dictionaries representing each frontmatter section that parsed.</returns>
private static List<Dictionary<string, object>> ExtractFrontmatterObjects(string input, out string body, out bool hasUnreadableBlock)
Expand All @@ -322,7 +339,6 @@

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

Expand All @@ -334,7 +350,7 @@
continue;
}

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

Check warning on line 353 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 @@ -385,7 +401,7 @@
blocks = [];
body = input;

if (!HasFrontmatter(input))
if (!OpensWithDelimiter(input))
{
return false;
}
Expand Down Expand Up @@ -482,7 +498,7 @@
lines.Add((start, i));
if (c == '\r' && i + 1 < input.Length && input[i + 1] == '\n')
{
i++;

Check warning on line 501 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 501 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 @@ -552,7 +568,7 @@
}

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

Check warning on line 571 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