diff --git a/Directory.Build.props b/Directory.Build.props index 7b2a5e57b..c1fe51d9d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 11.0.0-beta.82+8.0.303 + 11.0.0-beta.83+8.0.303 Steven T. Cramer TimeWarp State $(TimeWarpStateVersion) diff --git a/Source/TimeWarp.State.Analyzer/AnalyzerReleases.Unshipped.md b/Source/TimeWarp.State.Analyzer/AnalyzerReleases.Unshipped.md index 92c2decd8..9ddfc4036 100644 --- a/Source/TimeWarp.State.Analyzer/AnalyzerReleases.Unshipped.md +++ b/Source/TimeWarp.State.Analyzer/AnalyzerReleases.Unshipped.md @@ -6,5 +6,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- StateInheritanceRule | Design | Error | StateInheritanceAnalyzer +StateReadOnlyPublicPropertiesRule | Design | Error | StateReadOnlyPublicPropertiesAnalyzer TW0001 | TimeWarp.State | Error | TimeWarpStateActionAnalyzer TWD001 | Debug | Info | TimeWarpStateActionAnalyzer diff --git a/Source/TimeWarp.State.Analyzer/GlobalUsings.cs b/Source/TimeWarp.State.Analyzer/GlobalUsings.cs new file mode 100644 index 000000000..0035fdd49 --- /dev/null +++ b/Source/TimeWarp.State.Analyzer/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Microsoft.CodeAnalysis.Diagnostics; +global using System.Collections.Immutable; +global using System.Linq; diff --git a/Source/TimeWarp.State.Analyzer/StateInheritanceAnalyzer.cs b/Source/TimeWarp.State.Analyzer/StateInheritanceAnalyzer.cs index b6c4e6ba0..6f120bf57 100644 --- a/Source/TimeWarp.State.Analyzer/StateInheritanceAnalyzer.cs +++ b/Source/TimeWarp.State.Analyzer/StateInheritanceAnalyzer.cs @@ -1,10 +1,6 @@ namespace TimeWarp.State.Analyzer; -using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; -using System.Collections.Immutable; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class StateInheritanceAnalyzer : DiagnosticAnalyzer diff --git a/Source/TimeWarp.State.Analyzer/StateReadOnlyPublicPropertiesAnalyzer.cs b/Source/TimeWarp.State.Analyzer/StateReadOnlyPublicPropertiesAnalyzer.cs new file mode 100644 index 000000000..6c09ed462 --- /dev/null +++ b/Source/TimeWarp.State.Analyzer/StateReadOnlyPublicPropertiesAnalyzer.cs @@ -0,0 +1,73 @@ +namespace TimeWarp.State.Analyzer; + +using Microsoft.CodeAnalysis.CSharp; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class StateReadOnlyPublicPropertiesAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "StateReadOnlyPublicPropertiesRule"; + + private static readonly LocalizableString Title = "Public property in State class should be read-only"; + private static readonly LocalizableString MessageFormat = "The public property '{0}' in State-derived class should be read-only"; + private static readonly LocalizableString Description = "Public properties in classes inheriting from State should be read-only to enforce immutability."; + private const string Category = "Design"; + + private static readonly DiagnosticDescriptor Rule = new(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description); + + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } } + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + if (!InheritsFromState(classDeclaration, context.SemanticModel)) + return; + + foreach (MemberDeclarationSyntax member in classDeclaration.Members) + { + if (member is PropertyDeclarationSyntax propertyDeclaration) + { + AnalyzeProperty(propertyDeclaration, context); + } + } + } + + private static bool InheritsFromState(ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel) + { + INamedTypeSymbol? classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration); + if (classSymbol == null) + return false; + + INamedTypeSymbol? baseType = classSymbol.BaseType; + while (baseType != null) + { + if (baseType.Name == "State" && baseType.TypeArguments.Length == 1) + return true; + baseType = baseType.BaseType; + } + + return false; + } + + private static void AnalyzeProperty(PropertyDeclarationSyntax propertyDeclaration, SyntaxNodeAnalysisContext context) + { + if (!propertyDeclaration.Modifiers.Any(SyntaxKind.PublicKeyword)) return; + + AccessorDeclarationSyntax? setter = + propertyDeclaration.AccessorList?.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.SetAccessorDeclaration)); + + if (setter != null && !setter.Modifiers.Any(SyntaxKind.PrivateKeyword)) + { + var diagnostic = Diagnostic.Create(Rule, propertyDeclaration.Identifier.GetLocation(), propertyDeclaration.Identifier.Text); + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/Source/TimeWarp.State.Analyzer/TimeWarpStateActionAnalyzer.cs b/Source/TimeWarp.State.Analyzer/TimeWarpStateActionAnalyzer.cs index 8e4bea8bd..bb817bc8d 100644 --- a/Source/TimeWarp.State.Analyzer/TimeWarpStateActionAnalyzer.cs +++ b/Source/TimeWarp.State.Analyzer/TimeWarpStateActionAnalyzer.cs @@ -1,10 +1,6 @@ namespace TimeWarp.State.Analyzer; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.CSharp.Syntax; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class TimeWarpStateActionAnalyzer : DiagnosticAnalyzer diff --git a/Tests/TimeWarp.State.Analyzer.Tests/StateReadOnlyPublicPropertiesAnalyzer_Tests.cs b/Tests/TimeWarp.State.Analyzer.Tests/StateReadOnlyPublicPropertiesAnalyzer_Tests.cs new file mode 100644 index 000000000..980284049 --- /dev/null +++ b/Tests/TimeWarp.State.Analyzer.Tests/StateReadOnlyPublicPropertiesAnalyzer_Tests.cs @@ -0,0 +1,121 @@ +// // ReSharper disable InconsistentNaming +// namespace StateReadOnlyPublicPropertiesAnalyzer_; +// +// public class Should_Trigger_StateReadOnlyPublicPropertiesRule +// { +// public static async Task Given_PublicPropertyWithPublicSetter() +// { +// const string TestCode = +// """ +// using System.Threading.Tasks; +// using TimeWarp.State; +// +// public class SampleState : State +// { +// public int PublicProperty { get; set; } +// +// public override void Initialize() { } +// } +// """; +// +// var expectedDiagnostic = new DiagnosticResult("StateReadOnlyPublicPropertiesRule", DiagnosticSeverity.Warning) +// .WithSpan(6, 16, 6, 30) +// .WithArguments("PublicProperty"); +// +// var analyzerTest = new CSharpAnalyzerTest +// { +// TestCode = TestCode +// }; +// +// analyzerTest.ExpectedDiagnostics.Add(expectedDiagnostic); +// +// const string TimeWarpStateAssemblyPath = @"TimeWarp.State.dll"; +// analyzerTest.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(TimeWarpStateAssemblyPath)); +// +// await analyzerTest.RunAsync(); +// } +// +// public static async Task Given_PublicPropertyWithProtectedSetter() +// { +// const string TestCode = +// """ +// using System.Threading.Tasks; +// using TimeWarp.State; +// +// public class SampleState : State +// { +// public int PublicProperty { get; protected set; } +// +// public override void Initialize() { } +// } +// """; +// +// var expectedDiagnostic = new DiagnosticResult("StateReadOnlyPublicPropertiesRule", DiagnosticSeverity.Warning) +// .WithSpan(6, 16, 6, 30) +// .WithArguments("PublicProperty"); +// +// var analyzerTest = new CSharpAnalyzerTest +// { +// TestCode = TestCode +// }; +// +// analyzerTest.ExpectedDiagnostics.Add(expectedDiagnostic); +// +// const string TimeWarpStateAssemblyPath = @"TimeWarp.State.dll"; +// analyzerTest.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(TimeWarpStateAssemblyPath)); +// +// await analyzerTest.RunAsync(); +// } +// +// public static async Task Given_PublicPropertyWithPrivateSetter() +// { +// const string TestCode = +// """ +// using System.Threading.Tasks; +// using TimeWarp.State; +// +// public class SampleState : State +// { +// public int PublicProperty { get; private set; } +// +// public override void Initialize() { } +// } +// """; +// +// var analyzerTest = new CSharpAnalyzerTest +// { +// TestCode = TestCode +// }; +// +// const string TimeWarpStateAssemblyPath = @"TimeWarp.State.dll"; +// analyzerTest.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(TimeWarpStateAssemblyPath)); +// +// await analyzerTest.RunAsync(); +// } +// +// public static async Task Given_PublicReadOnlyProperty() +// { +// const string TestCode = +// """ +// using System.Threading.Tasks; +// using TimeWarp.State; +// +// public class SampleState : State +// { +// public int PublicProperty { get; } +// +// public override void Initialize() { } +// } +// """; +// +// var analyzerTest = new CSharpAnalyzerTest +// { +// TestCode = TestCode +// }; +// +// const string TimeWarpStateAssemblyPath = @"TimeWarp.State.dll"; +// analyzerTest.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(TimeWarpStateAssemblyPath)); +// +// await analyzerTest.RunAsync(); +// } +// } diff --git a/Tests/TimeWarp.State.Analyzer.Tests/BlazorStateActionAnalyser_Tests.cs b/Tests/TimeWarp.State.Analyzer.Tests/TimeWarpStateActionAnalyser_Tests.cs similarity index 100% rename from Tests/TimeWarp.State.Analyzer.Tests/BlazorStateActionAnalyser_Tests.cs rename to Tests/TimeWarp.State.Analyzer.Tests/TimeWarpStateActionAnalyser_Tests.cs