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
2 changes: 1 addition & 1 deletion Frontmatter.Test/PropertyMergerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,6 @@ public void CombineFrontmatter_SingleBlockWithRepeatedListItems_RoundTripsTheLis

Assert.IsNotNull(frontmatter);
CollectionAssert.AreEqual(new object[] { "mix", "wait", "mix" }, (System.Collections.ICollection)frontmatter["steps"]);
CollectionAssert.AreEqual(new object[] { "1", "1", "2" }, (System.Collections.ICollection)frontmatter["scores"]);
CollectionAssert.AreEqual(new long[] { 1, 1, 2 }, ((System.Collections.IEnumerable)frontmatter["scores"]).Cast<object>().Select(score => Convert.ToInt64(score, System.Globalization.CultureInfo.InvariantCulture)).ToArray());
}
}
72 changes: 72 additions & 0 deletions Frontmatter.Test/ScalarTypeRoundTripTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using System.Collections.Generic;

using Microsoft.VisualStudio.TestTools.UnitTesting;

using YamlDotNet.Serialization;

/// <summary>
/// Regression tests for #137: a round trip must keep what each scalar means. Quoted strings that look
/// like other types stay strings, plain scalars keep their types, and nulls stay null.
/// </summary>
[TestClass]
public class ScalarTypeRoundTripTests
{
private static readonly string Nl = Environment.NewLine;

private static readonly IDeserializer TypedReader = new DeserializerBuilder()
.WithAttemptingUnquotedStringTypeDeserialization()
.Build();

private static Dictionary<object, object?> ReadHeader(string document)
{
string[] parts = document.Split(["---"], 3, StringSplitOptions.None);
return TypedReader.Deserialize<Dictionary<object, object?>>(parts[1]);
}

[TestMethod]
[DataRow(FrontmatterMergeStrategy.None)]
[DataRow(FrontmatterMergeStrategy.Conservative)]
public void CombineFrontmatter_QuotedLookalikesNullsAndBooleans_KeepTheirTypes(FrontmatterMergeStrategy strategy)
{
string input = $"---{Nl}title: \"true\"{Nl}version: \"1.0\"{Nl}zip: \"01234\"{Nl}null_str: \"null\"{Nl}empty:{Nl}draft: false{Nl}count: 3{Nl}---{Nl}body{Nl}";

string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, strategy);
Dictionary<object, object?> header = ReadHeader(result);

Assert.AreEqual("true", header["title"], result);
Assert.AreEqual("1.0", header["version"], result);
Assert.AreEqual("01234", header["zip"], result);
Assert.AreEqual("null", header["null_str"], result);
Assert.IsTrue(header.TryGetValue("empty", out object? emptyValue), result);
Assert.IsNull(emptyValue, result);
Assert.IsInstanceOfType<bool>(header["draft"], result);
Assert.IsFalse((bool)header["draft"]!, result);
Assert.AreEqual(3L, Convert.ToInt64(header["count"], System.Globalization.CultureInfo.InvariantCulture), result);
Assert.Contains($"draft: false{Nl}", result);
}

[TestMethod]
public void AddFrontmatter_StringsThatLookLikeOtherTypes_AreWrittenQuoted()
{
string result = Frontmatter.AddFrontmatter("body", new Dictionary<string, object> { ["title"] = "true", ["id"] = "007" });
Dictionary<object, object?> header = ReadHeader(result);

Assert.AreEqual("true", header["title"], result);
Assert.AreEqual("007", header["id"], result);
}

[TestMethod]
public void ExtractFrontmatter_PlainAndQuotedScalars_KeepDistinctTypes()
{
Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter($"---{Nl}draft: false{Nl}label: \"false\"{Nl}---{Nl}body{Nl}");

Assert.IsNotNull(frontmatter);
Assert.IsInstanceOfType<bool>(frontmatter["draft"]);
Assert.IsFalse((bool)frontmatter["draft"]);
Assert.AreEqual("false", frontmatter["label"]);
}
}
8 changes: 7 additions & 1 deletion Frontmatter/PropertyMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@

return !isKnownMapping && !hasExactMatch && !isInSameCategory
? key
: PropertyMappings.All.TryGetValue(canonicalName, out string? knownName) ? knownName : canonicalName;

Check warning on line 96 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 96 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 96 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 96 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
}

private static bool IsInSameCategory(string key, string canonicalName)
Expand Down Expand Up @@ -125,6 +125,12 @@
object firstValue = source[firstKey];
if (firstValue == null)
{
// A null has no type to merge on, so every key keeps its own value rather than being dropped.
foreach (string key in originalKeys)
{
target[key] = source[key];
}

return;
}

Expand All @@ -138,7 +144,7 @@
// If types are different, keep all properties separate
foreach (string key in originalKeys)
{
target[key] = source[key] ?? throw new InvalidOperationException($"Value for key {key} is null");
target[key] = source[key];
}

return;
Expand Down Expand Up @@ -187,7 +193,7 @@
/// <param name="key">The key to analyze.</param>
/// <param name="existingKeys">All existing keys in the frontmatter.</param>
/// <returns>The canonical name for the key.</returns>
private static string FindBasicCanonicalName(string key, string[] existingKeys)

Check warning on line 196 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 196 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
// First check if it's a known property
if (PropertyMappings.All.TryGetValue(key, out string? canonicalName))
Expand Down
6 changes: 5 additions & 1 deletion Frontmatter/YamlSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(NullNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.WithAttemptingUnquotedStringTypeDeserialization()
.Build();

/// <summary>
Expand All @@ -35,6 +36,7 @@
private static readonly ISerializer Serializer = new SerializerBuilder()
.WithNamingConvention(NullNamingConvention.Instance)
.ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
.WithQuotingNecessaryStrings()
.Build();

/// <summary>
Expand All @@ -43,7 +45,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 48 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 48 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 48 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 48 in Frontmatter/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Expand Down Expand Up @@ -159,7 +161,9 @@
{
return value switch
{
null => string.Empty,
// A null stays null so it is written back as a null rather than as an empty string. The
// dictionaries this feeds are typed as non-nullable for compatibility, so the null is forgiven.
null => null!,
Dictionary<object, object> dict => dict.ToDictionary(
kvp => kvp.Key?.ToString() ?? string.Empty,
kvp => ConvertValue(kvp.Value)),
Expand Down
Loading