Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace Stackworx.Analyzers.Tests;

using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Xunit;
using Verifier =
Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
Stackworx.Analyzers.EnumJsonStringEnumConverterAnalyzer,
Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

public class EnumJsonStringEnumConverterAnalyzerTests
{
[Fact]
public async Task ReportsWarning_EnumWithoutJsonConverterAttribute()
{
const string testCode = @"
using System.Text.Json.Serialization;

public enum {|#0:Status|}
{
Active,
Inactive
}
";

var expected = Verifier.Diagnostic(EnumJsonStringEnumConverterAnalyzer.Rule)
.WithArguments("Status")
.WithLocation(0)
.WithSeverity(DiagnosticSeverity.Warning);

await Verifier.VerifyAnalyzerAsync(testCode, expected);
}

[Fact]
public async Task NoWarning_EnumWithJsonStringEnumConverterAttribute()
{
const string testCode = @"
using System.Text.Json.Serialization;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Status
{
Active,
Inactive
}
";

await Verifier.VerifyAnalyzerAsync(testCode);
}

[Fact]
public async Task ReportsWarning_EnumWithDifferentJsonConverterAttribute()
{
const string testCode = @"
using System.Text.Json.Serialization;

public class CustomEnumConverter : JsonConverter<int>
{
public override int Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
=> reader.GetInt32();

public override void Write(System.Text.Json.Utf8JsonWriter writer, int value, System.Text.Json.JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
}

[JsonConverter(typeof(CustomEnumConverter))]
public enum {|#0:Status|}
{
Active,
Inactive
}
";

var expected = Verifier.Diagnostic(EnumJsonStringEnumConverterAnalyzer.Rule)
.WithArguments("Status")
.WithLocation(0)
.WithSeverity(DiagnosticSeverity.Warning);

await Verifier.VerifyAnalyzerAsync(testCode, expected);
}

[Fact]
public async Task NoWarning_ClassIsNotAnEnum()
{
const string testCode = @"
public class MyClass
{
public string Name { get; set; }
}
";

await Verifier.VerifyAnalyzerAsync(testCode);
}
}
126 changes: 126 additions & 0 deletions Stackworx.Analyzers/EnumJsonStringEnumConverterAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
namespace Stackworx.Analyzers;

using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class EnumJsonStringEnumConverterAnalyzer : DiagnosticAnalyzer
{
private const string DiagnosticId = "SW003";
private const string Title = "Enum must be annotated with [JsonConverter(typeof(JsonStringEnumConverter))]";
private const string MessageFormat = "Enum '{0}' must be annotated with [JsonConverter(typeof(JsonStringEnumConverter))] to ensure string serialization";
private const string Description = "Enums should be annotated with [JsonConverter(typeof(JsonStringEnumConverter))] to ensure they are serialized to their string value instead of their numeric value.";
private const string Category = "Serialization";

public static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: Title,
messageFormat: MessageFormat,
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: Description);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
if (context.Symbol is not INamedTypeSymbol typeSymbol)
{
return;
}

if (typeSymbol.TypeKind != TypeKind.Enum)
{
return;
}

var jsonConverterAttrType = context.Compilation.GetTypeByMetadataName(
"System.Text.Json.Serialization.JsonConverterAttribute");
var jsonStringEnumConverterType = context.Compilation.GetTypeByMetadataName(
"System.Text.Json.Serialization.JsonStringEnumConverter");
// Generic JsonStringEnumConverter<TEnum> introduced in .NET 8
var jsonStringEnumConverterGenericType = context.Compilation.GetTypeByMetadataName(
"System.Text.Json.Serialization.JsonStringEnumConverter`1");

// If System.Text.Json is not referenced, skip analysis
if (jsonConverterAttrType == null || (jsonStringEnumConverterType == null && jsonStringEnumConverterGenericType == null))
{
return;
}

if (!HasJsonStringEnumConverterAttribute(typeSymbol, jsonConverterAttrType, jsonStringEnumConverterType, jsonStringEnumConverterGenericType))
{
var diagnostic = Diagnostic.Create(
Rule,
typeSymbol.Locations.FirstOrDefault(),
typeSymbol.Name);
context.ReportDiagnostic(diagnostic);
}
}

private static bool HasJsonStringEnumConverterAttribute(
INamedTypeSymbol enumSymbol,
INamedTypeSymbol jsonConverterAttrType,
INamedTypeSymbol? jsonStringEnumConverterType,
INamedTypeSymbol? jsonStringEnumConverterGenericType)
{
foreach (var attribute in enumSymbol.GetAttributes())
{
if (attribute.AttributeClass == null)
{
continue;
}

// Check if attribute is or inherits from JsonConverterAttribute
var attrClass = attribute.AttributeClass;
if (!InheritsFrom(attrClass, jsonConverterAttrType))
{
continue;
}

// Check if the constructor argument is typeof(JsonStringEnumConverter)
// or typeof(JsonStringEnumConverter<TEnum>). JsonStringEnumConverter is sealed,
// so we use original definition equality rather than an inheritance walk.
if (attribute.ConstructorArguments.Length == 1 &&
attribute.ConstructorArguments[0].Value is ITypeSymbol converterType)
{
var originalDef = converterType.OriginalDefinition;
if ((jsonStringEnumConverterType != null &&
SymbolEqualityComparer.Default.Equals(originalDef, jsonStringEnumConverterType)) ||
(jsonStringEnumConverterGenericType != null &&
SymbolEqualityComparer.Default.Equals(originalDef, jsonStringEnumConverterGenericType)))
{
return true;
}
}
}

return false;
}

private static bool InheritsFrom(ITypeSymbol type, ITypeSymbol baseType)
{
var current = type;
while (current != null)
{
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, baseType.OriginalDefinition))
{
return true;
}

current = current.BaseType;
}

return false;
}
}
97 changes: 97 additions & 0 deletions docs/docs/rules/sw003-enum-json-string-enum-converter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
sidebar_position: 4
---

# SW003: Enum must have JsonStringEnumConverter

## Overview

**Rule ID:** SW003
**Category:** Serialization
**Severity:** Warning
**Status:** Enabled by default

## Description

This rule ensures that all enums defined in user code are annotated with `[JsonConverter(typeof(JsonStringEnumConverter))]`. Without this attribute, `System.Text.Json` serializes enums as their numeric (integer) values by default, which can lead to brittle APIs and data that is difficult to read or debug.

## When This Rule Triggers

The analyzer reports a diagnostic when:
- An enum is declared without the `[JsonConverter(typeof(JsonStringEnumConverter))]` attribute
- `System.Text.Json` is referenced by the project

## Why This Matters

### The Problem

By default, `System.Text.Json` serializes enums as integers:

```csharp
// ⚠️ Missing attribute - SW003
public enum Status
{
Active, // serializes as 0
Inactive // serializes as 1
}
```

This results in JSON like `{"status": 0}`, which is hard to read, fragile (adding enum members can break consumers), and not self-documenting.

### The Solution

Annotating enums with `[JsonConverter(typeof(JsonStringEnumConverter))]` ensures they are serialized as their string names:

```csharp
// ✅ Correct
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Status
{
Active, // serializes as "Active"
Inactive // serializes as "Inactive"
}
```

This produces `{"status": "Active"}`, which is readable, stable, and self-documenting.

## How to Fix

Add the `[JsonConverter(typeof(JsonStringEnumConverter))]` attribute to your enum:

```csharp
using System.Text.Json.Serialization;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered
}
```

## Configuration

This rule is enabled by default. To disable it, add to your `.editorconfig`:

```ini
[*.cs]
dotnet_diagnostic.SW003.severity = none
```

To change severity to error:

```ini
[*.cs]
dotnet_diagnostic.SW003.severity = error
```

## Related Rules

- None

## See Also

- [JsonStringEnumConverter](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonstringenumconverter)
- [How to serialize and deserialize (marshal and unmarshal) JSON in .NET](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview)