diff --git a/src/D2L.CodeStyle.Analyzers/Diagnostics.cs b/src/D2L.CodeStyle.Analyzers/Diagnostics.cs index 263daaa9..6eaa8ed8 100644 --- a/src/D2L.CodeStyle.Analyzers/Diagnostics.cs +++ b/src/D2L.CodeStyle.Analyzers/Diagnostics.cs @@ -774,5 +774,14 @@ public static class Diagnostics { defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + public static readonly DiagnosticDescriptor PrimaryClassConstructorIntroducesMutability = new DiagnosticDescriptor( + id: "D2L0105", + title: "Primary class constructors introduce mutability into classes via their parameters", + messageFormat: "Primary class constructors introduce mutability into classes via their parameters", + category: "Immutability", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); } } diff --git a/src/D2L.CodeStyle.Analyzers/Immutability/ImmutabilityAnalyzer.cs b/src/D2L.CodeStyle.Analyzers/Immutability/ImmutabilityAnalyzer.cs index 9c10fd29..f0cbf3fc 100644 --- a/src/D2L.CodeStyle.Analyzers/Immutability/ImmutabilityAnalyzer.cs +++ b/src/D2L.CodeStyle.Analyzers/Immutability/ImmutabilityAnalyzer.cs @@ -32,7 +32,9 @@ public sealed partial class ImmutabilityAnalyzer : DiagnosticAnalyzer { Diagnostics.MissingTransitiveImmutableAttribute, Diagnostics.InconsistentMethodAttributeApplication, - Diagnostics.UnappliedConditionalImmutability + Diagnostics.UnappliedConditionalImmutability, + + Diagnostics.PrimaryClassConstructorIntroducesMutability ); private readonly ImmutableHashSet m_additionalImmutableTypes; diff --git a/src/D2L.CodeStyle.Analyzers/Immutability/ImmutableDefinitionChecker.cs b/src/D2L.CodeStyle.Analyzers/Immutability/ImmutableDefinitionChecker.cs index 371c7da2..98a9c084 100644 --- a/src/D2L.CodeStyle.Analyzers/Immutability/ImmutableDefinitionChecker.cs +++ b/src/D2L.CodeStyle.Analyzers/Immutability/ImmutableDefinitionChecker.cs @@ -51,6 +51,10 @@ public bool CheckDeclaration( INamedTypeSymbol type ) { .Where( m => !m.IsStatic ); if( type.TypeKind == TypeKind.Class ) { + if( HasPrimaryClassConstructorMutability( type ) ) { + result = false; + } + // Check that the base class is immutable for classes var baseClassOk = m_context.IsImmutable( new ImmutabilityQuery( @@ -81,6 +85,35 @@ out var diag return result; } + public bool HasPrimaryClassConstructorMutability( INamedTypeSymbol @class ) { + foreach( var constructor in @class.InstanceConstructors ) { + foreach( var syntaxRef in constructor.DeclaringSyntaxReferences ) { + var syntax = syntaxRef.GetSyntax( m_cancellationToken ); + + // Primary constructors for classes introduce mutability. + if( syntax is not ClassDeclarationSyntax ctor ) { + continue; + } + + // Unimportant edge case + if( ctor.ParameterList.Parameters.Count == 0 ) { + return false; + } + + m_diagnosticSink( + Diagnostic.Create( + Diagnostics.PrimaryClassConstructorIntroducesMutability, + ctor.ParameterList.GetLocation() + ) + ); + + return true; + } + } + + return false; + } + /// /// Check that a member (e.g. field or property) always produces immutable /// values. diff --git a/tests/D2L.CodeStyle.Analyzers.Test/Specs/ImmutabilityAnalyzer.cs b/tests/D2L.CodeStyle.Analyzers.Test/Specs/ImmutabilityAnalyzer.cs index c5b0089a..fbee59a8 100644 --- a/tests/D2L.CodeStyle.Analyzers.Test/Specs/ImmutabilityAnalyzer.cs +++ b/tests/D2L.CodeStyle.Analyzers.Test/Specs/ImmutabilityAnalyzer.cs @@ -1461,4 +1461,14 @@ static ClassWithCapturedMutability() { SometimesBad2 = static () => 2; } } + + // This one doesn't create a diagnostic because there are no parameters. + [Immutable] + class ClassWithTrivialPrimaryConstructor(); + + [Immutable] + class ClassWithPrimaryConstructor /* PrimaryClassConstructorIntroducesMutability */ ( int X ) /**/; + + [Immutable] + record class RecordClassCanHavePrimaryConstructor(int X); }