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
1 change: 0 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="HashDepot" Version="2.0.3" />
<PackageVersion Include="ktsu.Extensions" Version="1.6.18" />
<PackageVersion Include="ktsu.FuzzySearch" Version="1.3.11" />
<PackageVersion Include="Microsoft.Testing.Extensions.CrashDump" Version="1.7.2" />
Expand Down
43 changes: 43 additions & 0 deletions Frontmatter.Test/CombineFrontmatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,47 @@ public void CombineFrontmatter_WithComplexPropertyValues_PreservesStructure()
Assert.AreEqual("tag1", tags[0]);
Assert.AreEqual("tag2", tags[1]);
}

/// <summary>
/// The two documents below are distinct but share an FNV-1a-32 hash (0x12657B7E), the function the
/// processed-document cache used to key on. Since both are processed under the same options, the
/// combined key collides too, and the second document is served the first one's output.
/// </summary>
[TestMethod]
public void CombineFrontmatter_WithHashCollidingDocuments_ProcessesEachIndependently()
{
// Arrange - "\n" rather than Environment.NewLine so the bytes hashed are the same on every
// platform; DetectNewLine reads the document's own convention, so both still parse.
string first = "---\ntitle: Release Notes 281277\n---\n";
string second = "---\ntitle: Release Notes 1084130\n---\n";
Assert.AreNotEqual(first, second, "The two documents must be distinct for this test to mean anything");

// Act
string firstResult = Frontmatter.CombineFrontmatter(first);
string secondResult = Frontmatter.CombineFrontmatter(second);

// Assert
Assert.Contains("Release Notes 281277", firstResult, "The first document should keep its own title");
Assert.Contains("Release Notes 1084130", secondResult, "The second document should keep its own title");
Assert.DoesNotContain("Release Notes 281277", secondResult, "The second document must not be served the first document's cached result");
}

/// <summary>
/// Guards the cache itself: combining the same document twice must still agree. Passes both before
/// and after the collision fix by design, so a later change cannot quietly make the cache incoherent.
/// </summary>
[TestMethod]
public void CombineFrontmatter_WithTheSameDocumentTwice_ReturnsEqualResults()
{
// Arrange
string input = "---\ntitle: Cache Coherence Document\n---\nBody text.\n";

// Act
string firstResult = Frontmatter.CombineFrontmatter(input);
string secondResult = Frontmatter.CombineFrontmatter(input);

// Assert
Assert.AreEqual(firstResult, secondResult);
Assert.Contains("Cache Coherence Document", firstResult, "The result should carry the document's own title");
}
}
49 changes: 49 additions & 0 deletions Frontmatter.Test/YamlSerializerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,53 @@ public void SerializeYamlObject_WithCachedValues_ReturnsCachedResult()
Assert.Contains("Modified Title", secondResult, "Result should contain 'Modified Title'");
Assert.IsFalse(secondResult.Equals(firstResult), "Second result should not equal first result after modification");
}

/// <summary>
/// The two inputs below are distinct but share an FNV-1a-32 hash (0x27196CB9), the function the
/// parse cache used to key on. A cache keyed on that hash alone hands the second input the first
/// input's parse, so the cache must compare the text itself.
/// </summary>
[TestMethod]
public void TryParseYamlObject_WithHashCollidingInputs_ParsesEachIndependently()
{
// Arrange - both are single-line documents, so newline conventions do not enter into it
string first = "title: Release Notes 19";
string second = "title: Release Notes 386342";
Assert.AreNotEqual(first, second, "The two inputs must be distinct for this test to mean anything");

// Act
bool firstParsed = YamlSerializer.TryParseYamlObject(first, out Dictionary<string, object>? firstResult);
bool secondParsed = YamlSerializer.TryParseYamlObject(second, out Dictionary<string, object>? secondResult);

// Assert
Assert.IsTrue(firstParsed, "The first input is valid YAML and should parse");
Assert.IsTrue(secondParsed, "The second input is valid YAML and should parse");
Assert.IsNotNull(firstResult);
Assert.IsNotNull(secondResult);
Assert.AreEqual("Release Notes 19", firstResult["title"]);
Assert.AreEqual("Release Notes 386342", secondResult["title"], "The second input must not be served the first input's cached parse");
}

/// <summary>
/// Guards the cache itself: parsing the same text twice must still agree. Passes both before and
/// after the collision fix by design, so a later change cannot quietly make the cache incoherent.
/// </summary>
[TestMethod]
public void TryParseYamlObject_WithTheSameInputTwice_ReturnsEqualResults()
{
// Arrange
string input = "title: Cache Coherence\nauthor: Cache Test Author";

// Act
bool firstParsed = YamlSerializer.TryParseYamlObject(input, out Dictionary<string, object>? firstResult);
bool secondParsed = YamlSerializer.TryParseYamlObject(input, out Dictionary<string, object>? secondResult);

// Assert
Assert.IsTrue(firstParsed);
Assert.IsTrue(secondParsed);
Assert.IsNotNull(firstResult);
Assert.IsNotNull(secondResult);
Assert.AreEqual(firstResult["title"], secondResult["title"]);
Assert.AreEqual(firstResult["author"], secondResult["author"]);
}
}
8 changes: 5 additions & 3 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
private const string FrontmatterDelimiter = "---";

/// <summary>
/// Cache for processed frontmatter to avoid repeated processing of identical content
/// Cache for processed frontmatter to avoid repeated processing of identical content.
/// Keyed by the document text together with the option flags it was processed under, so a cache
/// hit means the input really is identical rather than merely hashing alike.
/// </summary>
private static readonly ConcurrentDictionary<uint, string> ProcessedFrontmatterCache = new();
private static readonly ConcurrentDictionary<(string Content, uint Options), string> ProcessedFrontmatterCache = new();

/// <summary>
/// Combines multiple frontmatter sections in a markdown document into a single frontmatter section.
Expand Down Expand Up @@ -65,7 +67,7 @@

// Generate a unique cache key based on the content and options
uint optionsHash = (uint)propertyNamingMode | ((uint)orderMode << 8) | ((uint)mergeStrategy << 16);
uint cacheKey = HashUtil.CreateCacheKey(input, optionsHash);
(string Content, uint Options) cacheKey = (input, optionsHash);

// Try to get from cache first
if (ProcessedFrontmatterCache.TryGetValue(cacheKey, out string? cachedResult))
Expand All @@ -82,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"
foreach (Dictionary<string, object> frontmatterObject in frontmatterObjects.Skip(1))
{
combinedFrontmatterObject = CombineFrontmatterObjects(combinedFrontmatterObject, frontmatterObject);
Expand Down Expand Up @@ -133,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 @@ -260,7 +262,7 @@
}

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

Check warning on line 265 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 @@ -292,8 +294,8 @@

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

Check warning on line 297 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 298 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 Down Expand Up @@ -326,7 +328,7 @@
continue;
}

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

Check warning on line 331 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Change this condition so that it does not always evaluate to 'True'.
{
frontmatterObjects.Add(frontmatterObject);
}
Expand All @@ -353,7 +355,7 @@
}

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

Check warning on line 358 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!combinedFrontmatterObject.ContainsKey(kvp.Key))
{
Expand Down
1 change: 0 additions & 1 deletion Frontmatter/Frontmatter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

<ItemGroup>
<PackageReference Include="Polyfill" PrivateAssets="all" />
<PackageReference Include="HashDepot" />
<PackageReference Include="ktsu.Extensions" />
<PackageReference Include="ktsu.FuzzySearch" />
<PackageReference Include="YamlDotNet" />
Expand Down
76 changes: 0 additions & 76 deletions Frontmatter/HashUtil.cs

This file was deleted.

13 changes: 6 additions & 7 deletions Frontmatter/YamlSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
public static class YamlSerializer
{
/// <summary>
/// Cache for parsed YAML to avoid repeated parsing
/// Cache for parsed YAML to avoid repeated parsing.
/// Keyed by the YAML text itself, compared ordinally, so a cache hit means the input really is
/// identical rather than merely hashing alike.
/// </summary>
private static readonly ConcurrentDictionary<uint, Dictionary<string, object>> ParsedYamlCache = new();
private static readonly ConcurrentDictionary<string, Dictionary<string, object>> ParsedYamlCache = new(StringComparer.Ordinal);

/// <summary>
/// Reusable deserializer instance
Expand All @@ -41,7 +43,7 @@
/// <param name="input">The YAML string to parse.</param>
/// <param name="result">When this method returns, contains the deserialized dictionary if parsing succeeded, or null if parsing failed.</param>
/// <returns>true if the YAML was successfully parsed; otherwise, false.</returns>
public static bool TryParseYamlObject(string input, [NotNullWhen(true)] out Dictionary<string, object>? result)

Check warning on line 46 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 46 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
result = null;

Expand All @@ -50,11 +52,8 @@
return false;
}

// Compute a hash of the content for the cache key
uint cacheKey = HashUtil.ComputeHash(input);

// Try to get from cache first
if (ParsedYamlCache.TryGetValue(cacheKey, out Dictionary<string, object>? cachedResult))
if (ParsedYamlCache.TryGetValue(input, out Dictionary<string, object>? cachedResult))
{
// Create a deep copy of the cached dictionary to prevent mutations from affecting other copies
result = [];
Expand Down Expand Up @@ -97,7 +96,7 @@
cacheResult[pair.Key] = DeepCloneValue(pair.Value);
}

ParsedYamlCache.TryAdd(cacheKey, cacheResult);
ParsedYamlCache.TryAdd(input, cacheResult);
return true;
}
}
Expand Down
Loading