Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="OneOf" Version="3.0.271" />
<PackageVersion Include="PolyType" Version="$(PolyTypeVersion)" />
<PackageVersion Include="PolyType.Roslyn" Version="$(PolyTypeVersion)" />
<PackageVersion Include="PolyType.TestCases" Version="$(PolyTypeVersion)" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
<PackageVersion Include="System.Memory.Data" Version="8.0.1" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) Andrew Arnott. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Composition;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Nerdbank.MessagePack.Analyzers.CodeFixes;

/// <summary>
/// Code fix provider to add DefaultValueAttribute to fields and properties with initializers.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp)]
[Shared]
public class DefaultValueInitializerCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds => [DefaultValueInitializerAnalyzer.MissingDefaultValueAttributeDiagnosticId];

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return;
}

Diagnostic diagnostic = context.Diagnostics[0];
Microsoft.CodeAnalysis.Text.TextSpan diagnosticSpan = diagnostic.Location.SourceSpan;

// Find the member declaration
SyntaxNode? node = root.FindNode(diagnosticSpan);
if (node is null)
{
return;
}

// Find the containing member (field or property)
VariableDeclaratorSyntax? variableDeclarator = node.AncestorsAndSelf().OfType<VariableDeclaratorSyntax>().FirstOrDefault();
PropertyDeclarationSyntax? propertyDeclaration = node.AncestorsAndSelf().OfType<PropertyDeclarationSyntax>().FirstOrDefault();

if (variableDeclarator?.Initializer is EqualsValueClauseSyntax fieldInitializer)
{
// It's a field
if (this.IsConstExpression(fieldInitializer.Value))
{
context.RegisterCodeFix(
CodeAction.Create(
"Add [DefaultValue(...)]",
cancellationToken => this.AddDefaultValueAttributeAsync(context.Document, root, variableDeclarator.Parent?.Parent, fieldInitializer.Value, cancellationToken),
equivalenceKey: nameof(DefaultValueInitializerCodeFix)),
diagnostic);
}
}
else if (propertyDeclaration?.Initializer is EqualsValueClauseSyntax propertyInitializer)
{
// It's a property
if (this.IsConstExpression(propertyInitializer.Value))
{
context.RegisterCodeFix(
CodeAction.Create(
"Add [DefaultValue(...)]",
cancellationToken => this.AddDefaultValueAttributeAsync(context.Document, root, propertyDeclaration, propertyInitializer.Value, cancellationToken),
equivalenceKey: nameof(DefaultValueInitializerCodeFix)),
diagnostic);
}
}
}

private bool IsConstExpression(ExpressionSyntax expression)
{
return expression is LiteralExpressionSyntax
|| expression is MemberAccessExpressionSyntax // For enum values like TestEnum.Second
|| expression is DefaultExpressionSyntax
|| (expression is PrefixUnaryExpressionSyntax unary && this.IsConstExpression(unary.Operand)); // For negative numbers
}

private async Task<Document> AddDefaultValueAttributeAsync(Document document, SyntaxNode root, SyntaxNode? memberDeclaration, ExpressionSyntax initializerValue, CancellationToken cancellationToken)
{
if (memberDeclaration is null)
{
return document;
}

// Create the DefaultValue attribute using the original expression directly
AttributeArgumentSyntax[] args = [SyntaxFactory.AttributeArgument(initializerValue)];

AttributeSyntax attribute = SyntaxFactory.Attribute(
SyntaxFactory.ParseName("System.ComponentModel.DefaultValue"),
SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(args)));

AttributeListSyntax attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute));

SyntaxNode newMemberDeclaration = memberDeclaration switch
{
FieldDeclarationSyntax field => field.AddAttributeLists(attributeList),
PropertyDeclarationSyntax property => property.AddAttributeLists(attributeList),
_ => memberDeclaration,
};

SyntaxNode newRoot = root.ReplaceNode(memberDeclaration, newMemberDeclaration);
return document.WithSyntaxRoot(newRoot);
}
}
1 change: 1 addition & 0 deletions src/Nerdbank.MessagePack.Analyzers.CodeFixes/Usings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Andrew Arnott. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

global using System.Collections.Immutable;
global using Microsoft.CodeAnalysis;
global using Microsoft.CodeAnalysis.Diagnostics;
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ NBMsgPack103 | Migration | Info | Use newer KeyAttribute
NBMsgPack104 | Migration | Info | Remove use of IgnoreMemberAttribute
NBMsgPack105 | Migration | Info | Implement IMessagePackSerializationCallbacks
NBMsgPack106 | Migration | Info | Use ConstructorShapeAttribute
NBMsgPack110 | Usage | Warning | Add DefaultValueAttribute when using non-default initializers
210 changes: 210 additions & 0 deletions src/Nerdbank.MessagePack.Analyzers/DefaultValueInitializerAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Copyright (c) Andrew Arnott. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.CodeAnalysis.CSharp.Syntax;
using PolyType.Roslyn;

namespace Nerdbank.MessagePack.Analyzers;

/// <summary>
/// Analyzer that detects fields and properties with initializers but no DefaultValueAttribute
/// on types that have source-generated shapes.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DefaultValueInitializerAnalyzer : DiagnosticAnalyzer
{
public const string MissingDefaultValueAttributeDiagnosticId = "NBMsgPack110";

public static readonly DiagnosticDescriptor MissingDefaultValueAttributeDescriptor = new(
id: MissingDefaultValueAttributeDiagnosticId,
title: Strings.NBMsgPack110_Title,
messageFormat: Strings.NBMsgPack110_MessageFormat,
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: AnalyzerUtilities.GetHelpLink(MissingDefaultValueAttributeDiagnosticId),
customTags: WellKnownDiagnosticTags.CompilationEnd);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [
MissingDefaultValueAttributeDescriptor,
];

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);

context.RegisterCompilationStartAction(context =>
{
if (!ReferenceSymbols.TryCreate(context.Compilation, out ReferenceSymbols? referenceSymbols))
{
return;
}

KnownSymbols knownSymbols = new(context.Compilation);

// Get the DefaultValue attribute symbol
INamedTypeSymbol? defaultValueAttribute = context.Compilation.GetTypeByMetadataName("System.ComponentModel.DefaultValueAttribute");
if (defaultValueAttribute is null)
{
return;
}

// Use PolyType.Roslyn's TypeDataModelGenerator to find all types transitively included in the shape
// TODO: What about finding TypeShapeAttribute, PropertyShapeAttribute, assembly-level attributes, etc.?
// Is the Include method thread-safe?
Comment on lines +54 to +55
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eiriktsarpalis Can you comment on this?

Copy link
Copy Markdown
Owner

@AArnott AArnott Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently, it's not thread safe, and will throw if it detects being called concurrently:

https://github.com/AArnott/PolyType/blob/87921dc2d649b60c3d58b0c117f3a86236e539f9/src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.cs?plain=1#L82C1-L85C14

The other missing functionality requires me to derive from the TypeDataModelGenerator and override a bunch of methods. IMO this should be default behavior since I'd just be teaching it to honor its own PolyType attributes.

Copy link
Copy Markdown
Owner

@AArnott AArnott Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some overrides in Parser from PolyType like ResolveConstructors is anything but trivial, and likely has policy that changes over time. This really isn't something I want to source-copy into my own analyzer.
I respect that you want to allow others to use this with their own attributes, etc. But could you either:

  1. provide default behavior that matches polytype while leaving the methods virtual, OR
  2. provide a derived type that your source generator uses such that I can consume it?

TypeDataModelGenerator generator = new(context.Compilation.Assembly, knownSymbols, context.CancellationToken);

context.RegisterSymbolAction(
symbolContext => this.CollectShapes(symbolContext, generator, referenceSymbols),
SymbolKind.NamedType);

context.RegisterCompilationEndAction(
context =>
{
// Look over all shaped types.
// Get all generated models - these are all the types for which shapes are generated
IEnumerable<TypeDataModel> allModels = generator.GeneratedModels.Values;

// Analyze each type that has a shape generated
foreach (TypeDataModel model in allModels)
{
this.AnalyzeTypeModel(context, model, defaultValueAttribute, referenceSymbols);
}
});
});
}

private void CollectShapes(SymbolAnalysisContext context, TypeDataModelGenerator generator, ReferenceSymbols referenceSymbols)
{
INamedTypeSymbol typeSymbol = (INamedTypeSymbol)context.Symbol;

// Check if this type has a GenerateShapeAttribute or GenerateShapeForAttribute - this is our entry point
foreach (AttributeData attribute in typeSymbol.GetAttributes())
{
// TODO: What about all the other arguments to these attributes?
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eiriktsarpalis Can you comment on this?

if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, referenceSymbols.GenerateShapeForAttribute))
{
if (attribute.ConstructorArguments is [{ Kind: TypedConstantKind.Type, Value: ITypeSymbol shapedType }])
{
generator.IncludeType(shapedType);
}

continue;
}

// Look for generic variant.
if (attribute.AttributeClass?.TypeArguments is [{ } typeArg] && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass.ConstructUnboundGenericType(), referenceSymbols.GenerateShapeForGenericAttribute))
{
generator.IncludeType(typeArg);
continue;
}

// Look for ordinary GenerateShapeAttribute.
if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, referenceSymbols.GenerateShapeAttribute))
{
generator.IncludeType(typeSymbol);
}
}
}

private void AnalyzeTypeModel(CompilationAnalysisContext context, TypeDataModel model, INamedTypeSymbol defaultValueAttribute, ReferenceSymbols referenceSymbols)
{
// Only analyze object types that have properties
if (model is not ObjectDataModel objectModel)
{
return;
}

// Check all properties from the model
foreach (PropertyDataModel property in objectModel.Properties)
{
ISymbol member = property.PropertySymbol;

if (member.IsStatic || member.IsImplicitlyDeclared)
{
continue;
}

if (member is IFieldSymbol field)
{
this.AnalyzeField(context, field, defaultValueAttribute, referenceSymbols);
}
else if (member is IPropertySymbol propertySymbol)
{
this.AnalyzeProperty(context, propertySymbol, defaultValueAttribute, referenceSymbols);
}
}
}

private void AnalyzeField(CompilationAnalysisContext context, IFieldSymbol field, INamedTypeSymbol defaultValueAttribute, ReferenceSymbols referenceSymbols)
{
// Skip static, const, or compiler-generated fields
if (field.IsConst)
{
return;
}

// Check if field has an initializer
if (!this.HasInitializer(field))
{
return;
}

// Check if field already has DefaultValueAttribute
if (field.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, defaultValueAttribute)))
{
return;
}

// Report diagnostic
Location location = field.Locations.FirstOrDefault() ?? Location.None;
context.ReportDiagnostic(Diagnostic.Create(MissingDefaultValueAttributeDescriptor, location, field.Name));
}

private void AnalyzeProperty(CompilationAnalysisContext context, IPropertySymbol property, INamedTypeSymbol defaultValueAttribute, ReferenceSymbols referenceSymbols)
{
// Skip static, indexers, or compiler-generated properties
if (property.IsIndexer)
{
return;
}

// Check if property has an initializer
if (!this.HasInitializer(property))
{
return;
}

// Check if property already has DefaultValueAttribute
if (property.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, defaultValueAttribute)))
{
return;
}

// Report diagnostic
Location location = property.Locations.FirstOrDefault() ?? Location.None;
context.ReportDiagnostic(Diagnostic.Create(MissingDefaultValueAttributeDescriptor, location, property.Name));
}

private bool HasInitializer(ISymbol symbol)
{
// Check if the symbol has a syntax reference (declaration)
foreach (SyntaxReference syntaxRef in symbol.DeclaringSyntaxReferences)
{
SyntaxNode node = syntaxRef.GetSyntax();

if (node is VariableDeclaratorSyntax variableDeclarator && variableDeclarator.Initializer is not null)
{
return true;
}

if (node is PropertyDeclarationSyntax propertyDeclaration && propertyDeclaration.Initializer is not null)
{
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="PolyType.Roslyn" />
</ItemGroup>

</Project>
18 changes: 18 additions & 0 deletions src/Nerdbank.MessagePack.Analyzers/ReferenceSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public record ReferenceSymbols(
INamedTypeSymbol UnusedDataPacket,
INamedTypeSymbol DerivedTypeShapeAttribute,
INamedTypeSymbol GenerateShapeAttribute,
INamedTypeSymbol GenerateShapeForAttribute,
INamedTypeSymbol GenerateShapeForGenericAttribute,
INamedTypeSymbol PropertyShapeAttribute,
INamedTypeSymbol ConstructorShapeAttribute,
INamedTypeSymbol UseComparerAttribute)
Expand Down Expand Up @@ -125,6 +127,20 @@ public static bool TryCreate(Compilation compilation, [NotNullWhen(true)] out Re
return false;
}

INamedTypeSymbol? generateShapeForAttribute = polytypeAssembly.GetTypeByMetadataName("PolyType.GenerateShapeForAttribute");
if (generateShapeForAttribute is null)
{
referenceSymbols = null;
return false;
}

INamedTypeSymbol? generateShapeForGenericAttribute = polytypeAssembly.GetTypeByMetadataName("PolyType.GenerateShapeForAttribute`1")?.ConstructUnboundGenericType();
if (generateShapeForGenericAttribute is null)
{
referenceSymbols = null;
return false;
}

INamedTypeSymbol? propertyShapeAttribute = polytypeAssembly.GetTypeByMetadataName("PolyType.PropertyShapeAttribute");
if (propertyShapeAttribute is null)
{
Expand Down Expand Up @@ -158,6 +174,8 @@ public static bool TryCreate(Compilation compilation, [NotNullWhen(true)] out Re
unusedDataPacket,
derivedTypeShapeAttribute,
generateShapeAttribute,
generateShapeForAttribute,
generateShapeForGenericAttribute,
propertyShapeAttribute,
constructorShapeAttribute,
useComparerAttribute);
Expand Down
6 changes: 6 additions & 0 deletions src/Nerdbank.MessagePack.Analyzers/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,10 @@
<data name="NBMsgPack106_Title" xml:space="preserve">
<value>Use ConstructorShapeAttribute</value>
</data>
<data name="NBMsgPack110_MessageFormat" xml:space="preserve">
<value>Field or property '{0}' has an initializer that may differ from the default value. Consider adding [DefaultValue(...)] attribute to specify the intended default value for serialization.</value>
</data>
<data name="NBMsgPack110_Title" xml:space="preserve">
<value>Add DefaultValueAttribute when using non-default initializers</value>
</data>
</root>
Loading
Loading