Skip to content

Commit

Permalink
Add generated enum converters
Browse files Browse the repository at this point in the history
  • Loading branch information
sliekens committed Dec 7, 2024
1 parent c2e56e9 commit 9e9887e
Show file tree
Hide file tree
Showing 70 changed files with 453 additions and 143 deletions.
211 changes: 153 additions & 58 deletions GW2SDK.Generators/EnumJsonConverterGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,78 +8,173 @@ namespace GW2SDK.Generators;
[Generator]
public class EnumJsonConverterGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new EnumSyntaxReceiver());
}

public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not EnumSyntaxReceiver receiver)
{
return;
}

var enums = receiver.EnumDeclarations;

var registrations = new StringBuilder();
foreach (var enumDeclaration in enums)
{
var enumName = enumDeclaration.Identifier.Text;
var namespaceName = GetNamespace(enumDeclaration);
string fullyQualifiedEnumName;
if (string.IsNullOrEmpty(namespaceName))
{
context.ReportDiagnostic(Diagnostic.Create(new DiagnosticDescriptor(
"GWSDK001", "Missing Namespace", $"Enum '{enumName}' is missing a namespace.", "EnumJsonConverterGenerator", DiagnosticSeverity.Warning, true), enumDeclaration.GetLocation()));
fullyQualifiedEnumName = $"global::{enumName}";
}
else
{
fullyQualifiedEnumName = $"{namespaceName}.{enumName}";
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new EnumSyntaxReceiver());
}

public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not EnumSyntaxReceiver receiver)
{
return;
}

var enumTypes = new List<string>();

foreach (var enumDeclaration in receiver.EnumDeclarations)
{
var model = context.Compilation.GetSemanticModel(enumDeclaration.SyntaxTree);
var enumSymbol = model.GetDeclaredSymbol(enumDeclaration);
if (enumSymbol is not INamedTypeSymbol namedTypeSymbol)
{
continue;
}

if (fullyQualifiedEnumName.StartsWith("GuildWars2", StringComparison.Ordinal))
if (namedTypeSymbol.DeclaredAccessibility != Accessibility.Public)
{
registrations.AppendLine($" Register<{fullyQualifiedEnumName}>();");
continue;
}
}

var sourceBuilder = new StringBuilder($$"""
namespace GuildWars2;
internal partial class ExtensibleEnumJsonConverterFactory
var namespaceName = enumSymbol.ContainingNamespace.ToDisplayString();
if (!namespaceName.StartsWith("GuildWars2"))
{
continue;
}

var enumName = enumSymbol.Name;
enumTypes.Add($"{namespaceName}.{enumName}");
var source = GenerateEnumJsonConverter(enumName, namespaceName, namedTypeSymbol);
context.AddSource(
$"{namespaceName}.{enumName}JsonConverter.g.cs",
SourceText.From(source, Encoding.UTF8)
);
}

var factorySource = GenerateExtensibleEnumJsonConverterFactory(enumTypes);
context.AddSource(
"GuildWars2.ExtensibleEnumJsonConverterFactory.g.cs",
SourceText.From(factorySource, Encoding.UTF8)
);
}

private string GenerateEnumJsonConverter(string enumName, string namespaceName, INamedTypeSymbol enumSymbol)
{
var enumValues = enumSymbol.GetMembers()
.Where(m => m.Kind == SymbolKind.Field)
.OfType<IFieldSymbol>()
.Where(f => f.ConstantValue != null)
.Select(f => f.Name)
.ToList();

var readCases = new StringBuilder();
foreach (var value in enumValues)
{
readCases.AppendLine($$"""
if (reader.ValueTextEquals(nameof({{enumName}}.{{value}})))
{
return {{enumName}}.{{value}};
}
""");
}

var writeCases = new StringBuilder();
foreach (var value in enumValues)
{
writeCases.AppendLine($"""
{enumName}.{value} => nameof({enumName}.{value}),
""");
}

return $$"""
#nullable enable
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace {{namespaceName}};
internal sealed class {{enumName}}JsonConverter : JsonConverter<{{enumName}}>
{
private static partial void RegisterEnums()
public override {{enumName}} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
{{readCases}}
throw new JsonException();
}
public override void Write(Utf8JsonWriter writer, {{enumName}} value, JsonSerializerOptions options)
{
{{registrations}}
static void Register<TEnum>() where TEnum : struct, Enum
writer.WriteStringValue(value switch
{
Converters[typeof(TEnum)] = new ExtensibleEnumJsonConverter<TEnum>();
}
{{writeCases}}
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null)
});
}
}
""");
context.AddSource("EnumJsonConverters.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
}
""";
}

private string GenerateExtensibleEnumJsonConverterFactory(List<string> enumTypes)
{
var cases = new StringBuilder();
foreach (var enumType in enumTypes)
{
cases.AppendLine($$"""
if (enumType == typeof({{enumType}}))
{
return new ExtensibleEnumJsonConverter<{{enumType}}>();
}
private string GetNamespace(EnumDeclarationSyntax enumDeclaration)
{
var namespaceDeclaration = enumDeclaration.Ancestors().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
return namespaceDeclaration?.Name.ToString() ?? string.Empty;
}
""");
}

private class EnumSyntaxReceiver : ISyntaxReceiver
{
public List<EnumDeclarationSyntax> EnumDeclarations { get; } = [];
return $$"""
#nullable enable
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is EnumDeclarationSyntax enumDeclaration)
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GuildWars2;
internal sealed class ExtensibleEnumJsonConverterFactory : JsonConverterFactory
{
EnumDeclarations.Add(enumDeclaration);
public override bool CanConvert(Type typeToConvert)
{
if (!typeToConvert.IsGenericType)
{
return false;
}
return typeToConvert.GetGenericTypeDefinition() == typeof(Extensible<>);
}
public override JsonConverter? CreateConverter(
Type typeToConvert,
JsonSerializerOptions options
)
{
var enumType = typeToConvert.GetGenericArguments()[0];
{{cases}}
return null;
}
}
}
}
""";
}

private class EnumSyntaxReceiver : ISyntaxReceiver
{
public List<EnumDeclarationSyntax> EnumDeclarations { get; } = [];

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is EnumDeclarationSyntax enumDeclaration)
{
EnumDeclarations.Add(enumDeclaration);
}
}
}
}
28 changes: 28 additions & 0 deletions GW2SDK.Tests/Features/EnumJsonSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json;
using GuildWars2.Hero.Accounts;

namespace GuildWars2.Tests.Features;

public class EnumJsonSerializer
{
[Fact]
public void Has_json_conversion()
{
var product = ProductName.GuildWars2;
var json = JsonSerializer.Serialize(product);
var actual = JsonSerializer.Deserialize<ProductName>(json);
Assert.Equal(product, actual);
}

[Fact]
public void Throws_for_undefined_values()
{
Assert.Throws<ArgumentOutOfRangeException>(
() =>
{
var product = (ProductName)69;
_ = JsonSerializer.Serialize(product);
}
);
}
}
20 changes: 19 additions & 1 deletion GW2SDK.Tests/Features/ExtensibleEnum.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using GuildWars2.Hero.Accounts;
using System.Text.Json;
using GuildWars2.Hero.Accounts;
using GuildWars2.Items;

namespace GuildWars2.Tests.Features;
Expand Down Expand Up @@ -115,4 +116,21 @@ public void Converts_unknown_names_to_null_when_enum_has_a_default_value()
var actual = extensible.ToEnum();
Assert.Null(actual);
}

[Fact]
public void Has_json_conversion()
{
Extensible<ProductName> extensible = ProductName.GuildWars2;
var json = JsonSerializer.Serialize(extensible);
var actual = JsonSerializer.Deserialize<Extensible<ProductName>>(json);
Assert.Equal(extensible, actual);
}
[Fact]
public void Has_json_conversion_for_undefined_values()
{
Extensible<ProductName> extensible = "GuildWars3";
var json = JsonSerializer.Serialize(extensible);
var actual = JsonSerializer.Deserialize<Extensible<ProductName>>(json);
Assert.Equal(extensible, actual);
}
}
26 changes: 26 additions & 0 deletions GW2SDK.Tests/PatternsAndPractices/JsonConverterTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
using GuildWars2.Tests.TestInfrastructure;

namespace GuildWars2.Tests.PatternsAndPractices;

public class JsonConverterTest(AssemblyFixture fixture) : IClassFixture<AssemblyFixture>
{
[Fact]
public void AllEnumsShouldHaveJsonConverterAttribute()
{
// Get all enum types in the assembly
var enumTypes = fixture.Assembly.GetTypes()
.Where(t => t is { IsEnum: true, IsPublic: true, Namespace: not null } && t.Namespace.StartsWith("GuildWars2"));

Assert.All(enumTypes,
enumType =>
{
var hasJsonConverterAttribute =
enumType.GetCustomAttributes(typeof(JsonConverterAttribute), false).Any();
Assert.True(
hasJsonConverterAttribute,
$"Enum {enumType.Name} does not have a JsonConverterAttribute."
);
});
}
}
5 changes: 4 additions & 1 deletion GW2SDK/Features/Authorization/Permission.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
namespace GuildWars2.Authorization;
using System.Text.Json.Serialization;

namespace GuildWars2.Authorization;

/// <summary>Represents the permissions available for Guild Wars 2 authorization.</summary>
[PublicAPI]
[JsonConverter(typeof(PermissionJsonConverter))]
public enum Permission
{
/// <summary>Grants access to the account information.</summary>
Expand Down
2 changes: 2 additions & 0 deletions GW2SDK/Features/Chat/SelectedTrait.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.ComponentModel;
using System.Text.Json.Serialization;

namespace GuildWars2.Chat;

/// <summary>Represents a selected trait.</summary>
[PublicAPI]
[DefaultValue(None)]
[JsonConverter(typeof(SelectedTraitJsonConverter))]
public enum SelectedTrait
{
// THE VALUES OF THIS ENUM ARE USED IN THE BINARY FORMAT OF THE LINK
Expand Down
2 changes: 2 additions & 0 deletions GW2SDK/Features/Exploration/Maps/MapKind.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.ComponentModel;
using System.Text.Json.Serialization;

namespace GuildWars2.Exploration.Maps;

/// <summary>The kind of maps in Guild Wars 2.</summary>
[PublicAPI]
[DefaultValue(Unknown)]
[JsonConverter(typeof(MapKindJsonConverter))]
public enum MapKind
{
/// <summary>An unknown map kind.</summary>
Expand Down
17 changes: 14 additions & 3 deletions GW2SDK/Features/ExtensibleEnumJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ namespace GuildWars2;
/// A JSON converter for the Extensible struct with a specific enum type.
/// </summary>
/// <typeparam name="TEnum">The type of the enum.</typeparam>
internal class ExtensibleEnumJsonConverter<TEnum> : JsonConverter<Extensible<TEnum>> where TEnum : struct, Enum
internal class ExtensibleEnumJsonConverter<TEnum>
: JsonConverter<Extensible<TEnum>> where TEnum : struct, Enum
{
/// <inheritdoc />
public override Extensible<TEnum> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var name = reader.GetString();
return new Extensible<TEnum>(name!);
if (name is null)
{
ThrowHelper.ThrowInvalidOperationException("Expected a string but got null.");
}

return new Extensible<TEnum>(name);
}

/// <inheritdoc />
Expand All @@ -26,7 +32,12 @@ public override void Write(Utf8JsonWriter writer, Extensible<TEnum> value, JsonS
public override Extensible<TEnum> ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var name = reader.GetString();
return new Extensible<TEnum>(name!);
if (name is null)
{
ThrowHelper.ThrowInvalidOperationException("Expected a string but got null.");
}

return new Extensible<TEnum>(name);
}

/// <inheritdoc />
Expand Down
Loading

0 comments on commit 9e9887e

Please sign in to comment.