Skip to content
Closed
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
229 changes: 229 additions & 0 deletions src/SharpYaml.Tests/Serialization/YamlParsableConverterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#nullable enable

using System;
using System.Globalization;
using System.Net;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace SharpYaml.Tests.Serialization;

[TestClass]
public class YamlParsableConverterTests
{
// ---- BCL types that implement IParsable<T> ----

[TestMethod]
public void IPAddress_Roundtrip()
{
var ip = IPAddress.Parse("192.168.1.1");
var yaml = YamlSerializer.Serialize(ip);
StringAssert.Contains(yaml, "192.168.1.1");

var deserialized = YamlSerializer.Deserialize<IPAddress>(yaml);
Assert.AreEqual(ip, deserialized);
}

[TestMethod]
public void IPAddress_IPv6_Roundtrip()
{
var ip = IPAddress.Parse("::1");
var yaml = YamlSerializer.Serialize(ip);

var deserialized = YamlSerializer.Deserialize<IPAddress>(yaml);
Assert.AreEqual(ip, deserialized);
}

// ---- Model with IParsable properties ----

private sealed class ServerConfig
{
public string Name { get; set; } = string.Empty;
public IPAddress? BindAddress { get; set; }
public IPAddress? DnsServer { get; set; }
}

[TestMethod]
public void ModelWithIParsableProperties_Deserialize()
{
var yaml = """
Name: production
BindAddress: 0.0.0.0
DnsServer: 1.1.1.1
""";

var config = YamlSerializer.Deserialize<ServerConfig>(yaml);

Assert.IsNotNull(config);
Assert.AreEqual("production", config.Name);
Assert.AreEqual(IPAddress.Parse("0.0.0.0"), config.BindAddress);
Assert.AreEqual(IPAddress.Parse("1.1.1.1"), config.DnsServer);
}

[TestMethod]
public void ModelWithIParsableProperties_Roundtrip()
{
var config = new ServerConfig
{
Name = "staging",
BindAddress = IPAddress.Loopback,
DnsServer = IPAddress.Parse("8.8.8.8"),
};

var yaml = YamlSerializer.Serialize(config);
var deserialized = YamlSerializer.Deserialize<ServerConfig>(yaml);

Assert.IsNotNull(deserialized);
Assert.AreEqual(config.Name, deserialized.Name);
Assert.AreEqual(config.BindAddress, deserialized.BindAddress);
Assert.AreEqual(config.DnsServer, deserialized.DnsServer);
}

// ---- Custom IParsable<T> type ----

private readonly struct Temperature : IParsable<Temperature>, IFormattable
{
public double Value { get; }
public string Unit { get; }

public Temperature(double value, string unit)
{
Value = value;
Unit = unit;
}

public static Temperature Parse(string s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var result))
{
throw new FormatException($"Cannot parse '{s}' as Temperature.");
}

return result;
}

public static bool TryParse(string? s, IFormatProvider? provider, out Temperature result)
{
result = default;
if (string.IsNullOrWhiteSpace(s))
{
return false;
}

// Parse "36.6C" or "98.6F"
var unitChar = s[^1];
if (unitChar is not ('C' or 'F'))
{
return false;
}

if (!double.TryParse(s.AsSpan(0, s.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
return false;
}

result = new Temperature(value, unitChar.ToString());
return true;
}

public string ToString(string? format, IFormatProvider? formatProvider)
{
return Value.ToString("G", CultureInfo.InvariantCulture) + Unit;
}

public override string ToString() => ToString(null, null);
}

[TestMethod]
public void CustomIParsableType_Roundtrip()
{
var temp = new Temperature(36.6, "C");
var yaml = YamlSerializer.Serialize(temp);
StringAssert.Contains(yaml, "36.6C");

var deserialized = YamlSerializer.Deserialize<Temperature>(yaml);
Assert.AreEqual(36.6, deserialized.Value);
Assert.AreEqual("C", deserialized.Unit);
}

[TestMethod]
public void CustomIParsableType_InvalidValueThrows()
{
var yaml = "not-a-temperature\n";
Assert.Throws<YamlException>(
() => YamlSerializer.Deserialize<Temperature>(yaml));
}

// ---- IParsable takes lower priority than explicit converters ----

[TestMethod]
public void ExplicitConverterTakesPriorityOverIParsable()
{
// IPAddress is IParsable<IPAddress>, but if a custom converter is registered
// it should take priority
var options = new YamlSerializerOptions
{
Converters = [new AlwaysLocalhostConverter()]
};

var yaml = "8.8.8.8\n";
var deserialized = YamlSerializer.Deserialize<IPAddress>(yaml, options);

// Custom converter always returns loopback regardless of input
Assert.AreEqual(IPAddress.Loopback, deserialized);
}

private sealed class AlwaysLocalhostConverter : SharpYaml.Serialization.YamlConverter<IPAddress>
{
public override IPAddress Read(SharpYaml.Serialization.YamlReader reader)
{
reader.Read(); // consume scalar
return IPAddress.Loopback;
}

public override void Write(SharpYaml.Serialization.YamlWriter writer, IPAddress value)
{
writer.WriteScalar("127.0.0.1");
}
}

// ---- IParsable does NOT activate for types that are already handled ----

[TestMethod]
public void BuiltInConvertersTakePriorityOverIParsable()
{
// int implements IParsable<int>, but should use the built-in converter
var yaml = "42\n";
var value = YamlSerializer.Deserialize<int>(yaml);
Assert.AreEqual(42, value);

var roundtrip = YamlSerializer.Serialize(42);
StringAssert.Contains(roundtrip, "42");
}

// ---- Nullable IParsable properties ----

private sealed class NullableConfig
{
public IPAddress? Address { get; set; }
}

[TestMethod]
public void NullableIParsableProperty_WithValue()
{
var yaml = "Address: 10.0.0.1\n";
var config = YamlSerializer.Deserialize<NullableConfig>(yaml);

Assert.IsNotNull(config);
Assert.AreEqual(IPAddress.Parse("10.0.0.1"), config.Address);
}

[TestMethod]
public void NullableIParsableProperty_WithNull()
{
var yaml = "Address:\n";
var config = YamlSerializer.Deserialize<NullableConfig>(yaml);

Assert.IsNotNull(config);
Assert.IsNull(config.Address);
}
}
86 changes: 86 additions & 0 deletions src/SharpYaml/Serialization/Converters/YamlParsableConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// // Copyright (c) Alexandre Mutel. All rights reserved.
// // Licensed under the MIT license.
// // See LICENSE.txt file in the project root for full license information.

#if NET7_0_OR_GREATER
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace SharpYaml.Serialization.Converters;

internal sealed class YamlParsableConverterFactory : YamlConverterFactory
{
public static YamlParsableConverterFactory Instance { get; } = new();

[UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "This code path is only used by reflection-based serialization.")]
public override bool CanConvert(Type typeToConvert)
{
return HasIParsable(typeToConvert);
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This code path is only used by reflection-based serialization.")]
[UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "This code path is only used by reflection-based serialization.")]
public override YamlConverter? CreateConverter(Type typeToConvert, YamlSerializerOptions options)
{
if (!HasIParsable(typeToConvert))
{
return null;
}

var converterType = typeof(YamlParsableConverter<>).MakeGenericType(typeToConvert);
return (YamlConverter)Activator.CreateInstance(converterType)!;
}

private static bool HasIParsable(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type)
{
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IParsable<>)
&& iface.GetGenericArguments()[0] == type)
{
return true;
}
}

return false;
}
}

internal sealed class YamlParsableConverter<T> : YamlConverter<T> where T : IParsable<T>
{
public override T Read(YamlReader reader)
{
if (reader.TokenType != YamlTokenType.Scalar)
{
throw YamlThrowHelper.ThrowExpectedScalar(reader);
}

var text = reader.ScalarValue!;
reader.Read();

if (!T.TryParse(text, CultureInfo.InvariantCulture, out var result))
{
throw new YamlException(reader.SourceName, reader.Start, reader.End, $"Cannot parse '{text}' as {typeof(T).Name}.");
}

return result;
}

public override void Write(YamlWriter writer, T value)
{
string text;
if (value is IFormattable formattable)
{
text = formattable.ToString(null, CultureInfo.InvariantCulture);
}
else
{
text = value?.ToString() ?? string.Empty;
}

writer.WriteScalar(text);
}
}
#endif
7 changes: 7 additions & 0 deletions src/SharpYaml/Serialization/YamlReaderWriterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,13 @@ private static class YamlBuiltInConverters
}
}

#if NET7_0_OR_GREATER
if (YamlParsableConverterFactory.Instance.CanConvert(typeToConvert))
{
return YamlParsableConverterFactory.Instance.CreateConverter(typeToConvert, null!);
}
#endif

var objectConverterType = typeof(YamlObjectConverter<>).MakeGenericType(typeToConvert);
return (YamlConverter)Activator.CreateInstance(objectConverterType)!;
}
Expand Down