diff --git a/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs b/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs index d6f6100..03ee01f 100644 --- a/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs +++ b/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs @@ -407,10 +407,23 @@ private static void EmitContext(SourceProductionContext context, Compilation com continue; } + // Skip if the member itself has [YamlConverter(typeof(...))] — the converter handles serialization. + if (GetYamlConverterAttributeTypeName(member) is not null) + { + continue; + } + + // Skip if the member type is handled by a converter (type-level attribute or context-level converter). + if (IsTypeHandledByConverter(memberType, model.SourceGenerationOptions.ConverterTypes, compilation)) + { + continue; + } + if (TryGetArrayElementType(memberType, out var arrayElementType) || TryGetSequenceElementType(memberType, out arrayElementType, out _)) { - if (IsKnownScalar(arrayElementType) || indexByType.ContainsKey(arrayElementType)) + if (IsKnownScalar(arrayElementType) || indexByType.ContainsKey(arrayElementType) || + IsTypeHandledByConverter(arrayElementType, model.SourceGenerationOptions.ConverterTypes, compilation)) { continue; } @@ -437,7 +450,8 @@ private static void EmitContext(SourceProductionContext context, Compilation com continue; } - if (IsKnownScalar(dictionaryValueType) || indexByType.ContainsKey(dictionaryValueType)) + if (IsKnownScalar(dictionaryValueType) || indexByType.ContainsKey(dictionaryValueType) || + IsTypeHandledByConverter(dictionaryValueType, model.SourceGenerationOptions.ConverterTypes, compilation)) { continue; } @@ -5879,6 +5893,55 @@ private static string GetIgnoreConditionExpression(ISymbol member) return null; } + /// + /// Checks whether the given type is handled by a converter — either via a [YamlConverter] attribute + /// on the type itself, or via a context-level YamlConverter<T> registration. + /// Nullable<T> value types are unwrapped before checking. + /// + private static bool IsTypeHandledByConverter( + ITypeSymbol typeToCheck, + ImmutableArray converterTypes, + Compilation compilation) + { + // Unwrap Nullable for value types. + var unwrappedType = typeToCheck; + if (typeToCheck is INamedTypeSymbol nullable && nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + unwrappedType = nullable.TypeArguments[0]; + } + + // Check if the type itself has [YamlConverter(typeof(...))]. + if (GetYamlConverterAttributeTypeName(unwrappedType) is not null) + { + return true; + } + + // Check if a context-level converter handles this type. + if (!converterTypes.IsDefaultOrEmpty) + { + var yamlConverterOfT = compilation.GetTypeByMetadataName("SharpYaml.Serialization.YamlConverter`1"); + if (yamlConverterOfT is not null) + { + foreach (var converterType in converterTypes) + { + // Walk up the base type chain looking for YamlConverter. + for (var current = converterType as INamedTypeSymbol; current is not null; current = current.BaseType) + { + if (current.IsGenericType && + SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, yamlConverterOfT) && + current.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(current.TypeArguments[0], unwrappedType)) + { + return true; + } + } + } + } + } + + return false; + } + private static ImmutableArray GetSerializableMembers(INamedTypeSymbol type) { // Arrays/collections/dictionaries are handled by dedicated generated code paths, not as object graphs. diff --git a/src/SharpYaml.Tests/Serialization/YamlSerializerContextGeneratorDiagnosticTests.cs b/src/SharpYaml.Tests/Serialization/YamlSerializerContextGeneratorDiagnosticTests.cs index e0c5dba..a9a050c 100644 --- a/src/SharpYaml.Tests/Serialization/YamlSerializerContextGeneratorDiagnosticTests.cs +++ b/src/SharpYaml.Tests/Serialization/YamlSerializerContextGeneratorDiagnosticTests.cs @@ -147,6 +147,427 @@ internal partial class MappingContext : YamlSerializerContext StringAssert.Contains(result.GeneratedSource, "value is global::Dog"); } + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenContextLevelConverterHandlesMemberType() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class CustomType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class CustomTypeConverter : YamlConverter + { + public override CustomType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomType { Value = value }; + } + + public override void Write(YamlWriter writer, CustomType value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class ModelWithCustomType + { + public CustomType? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithCustomType))] + [YamlSourceGenerationOptions(Converters = [typeof(CustomTypeConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenMemberHasYamlConverterAttribute() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class CustomType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class CustomTypeConverter : YamlConverter + { + public override CustomType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomType { Value = value }; + } + + public override void Write(YamlWriter writer, CustomType value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class ModelWithConverterOnMember + { + [YamlConverter(typeof(CustomTypeConverter))] + public CustomType? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithConverterOnMember))] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenTypeHasYamlConverterAttribute() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class TypeLevelConverter : YamlConverter + { + public override ConverterDecoratedType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new ConverterDecoratedType { Value = value }; + } + + public override void Write(YamlWriter writer, ConverterDecoratedType value) + { + writer.WriteScalar(value.Value); + } + } + + [YamlConverter(typeof(TypeLevelConverter))] + public sealed class ConverterDecoratedType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class ModelWithTypeLevelConverter + { + public ConverterDecoratedType? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithTypeLevelConverter))] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenContextConverterHandlesArrayElementType() + { + const string source = """ + using System.Collections.Generic; + using SharpYaml.Serialization; + + public sealed class CustomType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class CustomTypeConverter : YamlConverter + { + public override CustomType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomType { Value = value }; + } + + public override void Write(YamlWriter writer, CustomType value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class ModelWithList + { + public List? Items { get; set; } + } + + [YamlSerializable(typeof(ModelWithList))] + [YamlSourceGenerationOptions(Converters = [typeof(CustomTypeConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenContextConverterHandlesDictionaryValueType() + { + const string source = """ + using System.Collections.Generic; + using SharpYaml.Serialization; + + public sealed class CustomType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class CustomTypeConverter : YamlConverter + { + public override CustomType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomType { Value = value }; + } + + public override void Write(YamlWriter writer, CustomType value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class ModelWithDictionary + { + public Dictionary? Items { get; set; } + } + + [YamlSerializable(typeof(ModelWithDictionary))] + [YamlSourceGenerationOptions(Converters = [typeof(CustomTypeConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_StillFires_WhenNoConverterHandlesType() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class UnhandledType + { + public string Value { get; set; } = string.Empty; + } + + public sealed class ModelWithUnhandledType + { + public UnhandledType? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithUnhandledType))] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual(DiagnosticSeverity.Error, diagnostics[0].Severity); + StringAssert.Contains(diagnostics[0].GetMessage(), "UnhandledType"); + } + + [TestMethod] + public void SHARPYAML002_StillFires_WhenConverterHandlesDifferentType() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class TypeA + { + public string Value { get; set; } = string.Empty; + } + + public sealed class TypeB + { + public string Value { get; set; } = string.Empty; + } + + public sealed class TypeAConverter : YamlConverter + { + public override TypeA Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new TypeA { Value = value }; + } + + public override void Write(YamlWriter writer, TypeA value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class ModelWithTypeB + { + public TypeB? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithTypeB))] + [YamlSourceGenerationOptions(Converters = [typeof(TypeAConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(1, diagnostics.Length); + StringAssert.Contains(diagnostics[0].GetMessage(), "TypeB"); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenConverterInheritsFromAnotherConverter() + { + const string source = """ + using SharpYaml.Serialization; + + public sealed class CustomType + { + public string Value { get; set; } = string.Empty; + } + + public class BaseCustomTypeConverter : YamlConverter + { + public override CustomType Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomType { Value = value }; + } + + public override void Write(YamlWriter writer, CustomType value) + { + writer.WriteScalar(value.Value); + } + } + + public sealed class DerivedCustomTypeConverter : BaseCustomTypeConverter + { + } + + public sealed class ModelWithCustomType + { + public CustomType? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithCustomType))] + [YamlSourceGenerationOptions(Converters = [typeof(DerivedCustomTypeConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + + [TestMethod] + public void SHARPYAML002_IsSuppressed_WhenContextConverterHandlesNullableValueType() + { + const string source = """ + using SharpYaml.Serialization; + + public struct CustomStruct + { + public int Value { get; set; } + } + + public sealed class CustomStructConverter : YamlConverter + { + public override CustomStruct Read(YamlReader reader) + { + var value = reader.GetScalarValue(); + reader.Read(); + return new CustomStruct { Value = int.Parse(value) }; + } + + public override void Write(YamlWriter writer, CustomStruct value) + { + writer.WriteScalar(value.Value.ToString()); + } + } + + public sealed class ModelWithNullableStruct + { + public CustomStruct? Item { get; set; } + } + + [YamlSerializable(typeof(ModelWithNullableStruct))] + [YamlSourceGenerationOptions(Converters = [typeof(CustomStructConverter)])] + internal partial class TestContext : YamlSerializerContext + { + } + """; + + var result = RunGenerator(source); + + var diagnostics = result.Diagnostics + .Where(static d => d.Id == "SHARPYAML002") + .ToArray(); + + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static d => d.GetMessage()))); + } + private static (Compilation OutputCompilation, Diagnostic[] Diagnostics, string GeneratedSource) RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview);