diff --git a/Machine.Specifications.sln b/Machine.Specifications.sln index 60b1dbbe..afd25678 100644 --- a/Machine.Specifications.sln +++ b/Machine.Specifications.sln @@ -1,22 +1,10 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +VisualStudioVersion = 17.12.35527.113 d17.12 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Machine.Specifications", "src\Machine.Specifications\Machine.Specifications.csproj", "{EC054D80-8858-4A61-9FD9-0185EA3F4643}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{1D85E618-71A9-43F2-B76D-95DC161E2A13}" - ProjectSection(SolutionItems) = preProject - build.ps1 = build.ps1 - build.sh = build.sh - .github\workflows\build.yml = .github\workflows\build.yml - CONTRIBUTING.md = CONTRIBUTING.md - .config\dotnet-tools.json = .config\dotnet-tools.json - GitVersion.yml = GitVersion.yml - .github\workflows\publish.yml = .github\workflows\publish.yml - README.md = README.md - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Machine.Specifications.Runner.Utility", "src\Machine.Specifications.Runner.Utility\Machine.Specifications.Runner.Utility.csproj", "{7E11A6CD-5900-4FFD-AE68-231BDA2436BA}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Machine.Specifications.Runner.Utility.Specs", "src\Machine.Specifications.Runner.Utility.Specs\Machine.Specifications.Runner.Utility.Specs.csproj", "{068B7D50-95A9-4510-96DC-59AB697D6C5F}" @@ -33,6 +21,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Machine.Specifications.Core EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Machine.Specifications.Fixtures", "src\Machine.Specifications.Fixtures\Machine.Specifications.Fixtures.csproj", "{2934AB5B-7506-4150-B61B-FE16EBB13D50}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Machine.Specifications.Analyzers", "src\Machine.Specifications.Analyzers\Machine.Specifications.Analyzers.csproj", "{0CFB2FE5-BDAF-4B7B-9E05-F05491B05CDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Machine.Specifications.Analyzers.Tests", "src\Machine.Specifications.Analyzers.Tests\Machine.Specifications.Analyzers.Tests.csproj", "{402EF260-6140-4633-880B-18BEBF7B9DA2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +67,14 @@ Global {2934AB5B-7506-4150-B61B-FE16EBB13D50}.Debug|Any CPU.Build.0 = Debug|Any CPU {2934AB5B-7506-4150-B61B-FE16EBB13D50}.Release|Any CPU.ActiveCfg = Release|Any CPU {2934AB5B-7506-4150-B61B-FE16EBB13D50}.Release|Any CPU.Build.0 = Release|Any CPU + {0CFB2FE5-BDAF-4B7B-9E05-F05491B05CDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CFB2FE5-BDAF-4B7B-9E05-F05491B05CDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CFB2FE5-BDAF-4B7B-9E05-F05491B05CDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CFB2FE5-BDAF-4B7B-9E05-F05491B05CDA}.Release|Any CPU.Build.0 = Release|Any CPU + {402EF260-6140-4633-880B-18BEBF7B9DA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {402EF260-6140-4633-880B-18BEBF7B9DA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {402EF260-6140-4633-880B-18BEBF7B9DA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {402EF260-6140-4633-880B-18BEBF7B9DA2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Machine.Specifications.Analyzers.Tests/AnalyzerVerifier.cs b/src/Machine.Specifications.Analyzers.Tests/AnalyzerVerifier.cs new file mode 100644 index 00000000..2ed0b4fa --- /dev/null +++ b/src/Machine.Specifications.Analyzers.Tests/AnalyzerVerifier.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Machine.Specifications.Analyzers.Tests; + +public static class AnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + public static DiagnosticResult Diagnostic() + { + return CSharpAnalyzerVerifier.Diagnostic(); + } + + public static DiagnosticResult Diagnostic(string diagnosticId) + { + return CSharpAnalyzerVerifier.Diagnostic(diagnosticId); + } + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + { + return CSharpAnalyzerVerifier.Diagnostic(descriptor); + } + + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source + }; + + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(CancellationToken.None); + } + + private class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies = VerifierHelper.MspecAssemblies; + SolutionTransforms.Add(VerifierHelper.GetNullableTransform); + } + } +} diff --git a/src/Machine.Specifications.Analyzers.Tests/CodeFixVerifier.cs b/src/Machine.Specifications.Analyzers.Tests/CodeFixVerifier.cs new file mode 100644 index 00000000..fd60d4c4 --- /dev/null +++ b/src/Machine.Specifications.Analyzers.Tests/CodeFixVerifier.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Machine.Specifications.Analyzers.Tests +{ + public static class CodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + { + public static DiagnosticResult Diagnostic() + { + return CSharpCodeFixVerifier.Diagnostic(); + } + + public static DiagnosticResult Diagnostic(string diagnosticId) + { + return CSharpCodeFixVerifier.Diagnostic(diagnosticId); + } + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + { + return CSharpCodeFixVerifier.Diagnostic(descriptor); + } + + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source + }; + + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(CancellationToken.None); + } + + public static async Task VerifyCodeFixAsync(string source, string fixedSource) + { + await VerifyCodeFixAsync(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); + } + + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + { + await VerifyCodeFixAsync(source, new[] {expected}, fixedSource); + } + + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource + }; + + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(CancellationToken.None); + } + + private class Test : CSharpCodeFixTest + { + public Test() + { + ReferenceAssemblies = VerifierHelper.MspecAssemblies; + SolutionTransforms.Add(VerifierHelper.GetNullableTransform); + } + } + } +} diff --git a/src/Machine.Specifications.Analyzers.Tests/Machine.Specifications.Analyzers.Tests.csproj b/src/Machine.Specifications.Analyzers.Tests/Machine.Specifications.Analyzers.Tests.csproj new file mode 100644 index 00000000..f5030e7a --- /dev/null +++ b/src/Machine.Specifications.Analyzers.Tests/Machine.Specifications.Analyzers.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + latest + false + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Machine.Specifications.Analyzers.Tests/Maintainability/AccessModifierShouldNotBeUsedTests.cs b/src/Machine.Specifications.Analyzers.Tests/Maintainability/AccessModifierShouldNotBeUsedTests.cs new file mode 100644 index 00000000..275b80c2 --- /dev/null +++ b/src/Machine.Specifications.Analyzers.Tests/Maintainability/AccessModifierShouldNotBeUsedTests.cs @@ -0,0 +1,287 @@ +using System.Threading.Tasks; +using Xunit; +using Verify = Machine.Specifications.Analyzers.Tests.CodeFixVerifier< + Machine.Specifications.Analyzers.Maintainability.AccessModifierShouldNotBeUsedAnalyzer, + Machine.Specifications.Analyzers.Maintainability.AccessModifierShouldNotBeUsedCodeFixProvider>; + +namespace Machine.Specifications.Analyzers.Tests.Maintainability; + +public class AccessModifierShouldNotBeUsedTests +{ + [Fact] + public async Task NoErrorsInValidSource() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + static string value; + + It should_do_something = () => + true.ShouldBeTrue(); + + class inner_specs + { + Establish context = () => + value = string.Empty; + } + } +}"; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task RemovesClassAccessModifier() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + public class {|#0:SpecsClass|} + { + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + var expected = Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(0) + .WithArguments("SpecsClass"); + + await Verify.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task RemovesFieldAccessModifier() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + private It {|#0:should_do_something|} = () => + true.ShouldBeTrue(); + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + var expected = Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(0) + .WithArguments("should_do_something"); + + await Verify.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task RemovesFieldAndClassAccessModifiers() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + public class {|#0:SpecsClass|} + { + private It {|#1:should_do_something|} = () => + true.ShouldBeTrue(); + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + var expected = new[] + { + Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(0) + .WithArguments("SpecsClass"), + + Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(1) + .WithArguments("should_do_something") + }; + + await Verify.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task RemovesInnerFieldAndClassAccessModifiers() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + private static string {|#0:value|}; + + It should_do_something = () => + true.ShouldBeTrue(); + + internal class {|#1:InnerClass|} + { + private Establish {|#2:context|} = () => + value = string.Empty; + } + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + static string value; + + It should_do_something = () => + true.ShouldBeTrue(); + + class InnerClass + { + Establish context = () => + value = string.Empty; + } + } +}"; + + var expected = new[] + { + Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(0) + .WithArguments("value"), + + Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(1) + .WithArguments("InnerClass"), + + Verify.Diagnostic(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed) + .WithLocation(2) + .WithArguments("context") + }; + + await Verify.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public async Task RemovesFieldAccessModifierWithLeadingTrivia() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + static private string [|value|]; + + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + static string value; + + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + await Verify.VerifyCodeFixAsync(source, fixedSource); + } + + [Fact] + public async Task RemovesMultipleAccessModifiers() + { + const string source = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + private protected static string [|value|]; + + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + const string fixedSource = @" +using System; +using Machine.Specifications; + +namespace ConsoleApplication1 +{ + class SpecsClass + { + static string value; + + It should_do_something = () => + true.ShouldBeTrue(); + } +}"; + + await Verify.VerifyCodeFixAsync(source, fixedSource); + } +} diff --git a/src/Machine.Specifications.Analyzers.Tests/VerifierHelper.cs b/src/Machine.Specifications.Analyzers.Tests/VerifierHelper.cs new file mode 100644 index 00000000..e0c32030 --- /dev/null +++ b/src/Machine.Specifications.Analyzers.Tests/VerifierHelper.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; + +namespace Machine.Specifications.Analyzers.Tests; + +internal static class VerifierHelper +{ + internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler(); + + internal static ReferenceAssemblies MspecAssemblies { get; } = ReferenceAssemblies.Default.AddPackages( + ImmutableArray.Create( + new PackageIdentity("Machine.Specifications", "1.0.0"), + new PackageIdentity("Machine.Specifications.Should", "1.0.0"))); + + public static Solution GetNullableTransform(Solution solution, ProjectId projectId) + { + var project = solution.GetProject(projectId); + + var options = project!.CompilationOptions!.WithSpecificDiagnosticOptions( + project.CompilationOptions.SpecificDiagnosticOptions.SetItems(NullableWarnings)); + + return solution.WithProjectCompilationOptions(projectId, options); + } + + private static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = { "/warnaserror:nullable" }; + var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, Environment.CurrentDirectory, Environment.CurrentDirectory); + var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + return nullableWarnings + .SetItem("CS8632", ReportDiagnostic.Error) + .SetItem("CS8669", ReportDiagnostic.Error); + } +} diff --git a/src/Machine.Specifications.Analyzers/DiagnosticCategories.cs b/src/Machine.Specifications.Analyzers/DiagnosticCategories.cs new file mode 100644 index 00000000..00384509 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/DiagnosticCategories.cs @@ -0,0 +1,10 @@ +namespace Machine.Specifications.Analyzers; + +public static class DiagnosticCategories +{ + public const string Maintainability = nameof(Maintainability); + + public const string Naming = nameof(Naming); + + public const string Layout = nameof(Layout); +} diff --git a/src/Machine.Specifications.Analyzers/DiagnosticIds.cs b/src/Machine.Specifications.Analyzers/DiagnosticIds.cs new file mode 100644 index 00000000..66e2973c --- /dev/null +++ b/src/Machine.Specifications.Analyzers/DiagnosticIds.cs @@ -0,0 +1,16 @@ +namespace Machine.Specifications.Analyzers; + +public static class DiagnosticIds +{ + public static class Naming + { + public const string ClassMustBeUpper = "MSP1001"; + } + + public static class Maintainability + { + public const string AccessModifierShouldNotBeUsed = "MSP2001"; + + public const string PrivateDelegateFieldWarning = "MSP2002"; + } +} diff --git a/src/Machine.Specifications.Analyzers/FieldDeclarationSyntaxExtensions.cs b/src/Machine.Specifications.Analyzers/FieldDeclarationSyntaxExtensions.cs new file mode 100644 index 00000000..aaa82f6f --- /dev/null +++ b/src/Machine.Specifications.Analyzers/FieldDeclarationSyntaxExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers; + +public static class FieldDeclarationSyntaxExtensions +{ + public static bool IsSpecification(this FieldDeclarationSyntax syntax, SyntaxNodeAnalysisContext context) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(syntax.Declaration.Type, context.CancellationToken); + var symbol = symbolInfo.Symbol as ITypeSymbol; + + if (!symbol.IsMember()) + { + return false; + } + + return symbol?.Name is + MetadataNames.MspecItDelegate or + MetadataNames.MspecBehavesLikeDelegate or + MetadataNames.MspecBecause or + MetadataNames.MspecEstablishDelegate; + } +} diff --git a/src/Machine.Specifications.Analyzers/Layout/DelegateShouldNotBeOnSingleLineAnalyzer.cs b/src/Machine.Specifications.Analyzers/Layout/DelegateShouldNotBeOnSingleLineAnalyzer.cs new file mode 100644 index 00000000..1e5e0314 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Layout/DelegateShouldNotBeOnSingleLineAnalyzer.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers.Layout; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class DelegateShouldNotBeOnSingleLineAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } + + public override void Initialize(AnalysisContext context) + { + } +} diff --git a/src/Machine.Specifications.Analyzers/Layout/SingleLineStatementShouldNotUseBracesAnalyzer.cs b/src/Machine.Specifications.Analyzers/Layout/SingleLineStatementShouldNotUseBracesAnalyzer.cs new file mode 100644 index 00000000..33b90672 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Layout/SingleLineStatementShouldNotUseBracesAnalyzer.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers.Layout; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class SingleLineStatementShouldNotUseBracesAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } + + public override void Initialize(AnalysisContext context) + { + } +} diff --git a/src/Machine.Specifications.Analyzers/Machine.Specifications.Analyzers.csproj b/src/Machine.Specifications.Analyzers/Machine.Specifications.Analyzers.csproj new file mode 100644 index 00000000..60af2214 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Machine.Specifications.Analyzers.csproj @@ -0,0 +1,41 @@ + + + + netstandard2.0 + enable + enable + latest + true + + $(TargetsForTfmSpecificContentInPackage);PackageItems + + + + Roslyn analyzers and code fixes for Machine.Specifications + Machine Specifications + mspec;test;unit;testing;context;specification;bdd;tdd;analyzers;roslyn + See https://github.com/machine/machine.specifications.analyzers/releases + icon.png + https://github.com/machine/machine.specifications + https://github.com/machine/machine.specifications.analyzers + git + MIT + true + + + + + + + + + + + + + + + + + + diff --git a/src/Machine.Specifications.Analyzers/MachineSpecStubs.cs b/src/Machine.Specifications.Analyzers/MachineSpecStubs.cs new file mode 100644 index 00000000..1affc07d --- /dev/null +++ b/src/Machine.Specifications.Analyzers/MachineSpecStubs.cs @@ -0,0 +1,21 @@ +namespace Machine.Specifications; + +public class SetupDelegateAttribute : Attribute +{ +} + +public class ActDelegateAttribute : Attribute +{ +} + +public class BehaviorDelegateAttribute : Attribute +{ +} + +public class AssertDelegateAttribute : Attribute +{ +} + +public class CleanupDelegateAttribute : Attribute +{ +} diff --git a/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedAnalyzer.cs b/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedAnalyzer.cs new file mode 100644 index 00000000..46b944eb --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedAnalyzer.cs @@ -0,0 +1,87 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers.Maintainability; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class AccessModifierShouldNotBeUsedAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed, + "Access modifier should not be declared", + "Element '{0}' should not declare an access modifier", + DiagnosticCategories.Maintainability, + DiagnosticSeverity.Warning, + true); + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxNodeAction(AnalyzeTypeSyntax, SyntaxKind.ClassDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeFieldSyntax, SyntaxKind.FieldDeclaration); + } + + private void AnalyzeTypeSyntax(SyntaxNodeAnalysisContext context) + { + var type = (TypeDeclarationSyntax) context.Node; + + if (!type.IsSpecificationClass(context)) + { + return; + } + + CheckAccessModifier(context, type.Identifier, type.Modifiers); + } + + private void AnalyzeFieldSyntax(SyntaxNodeAnalysisContext context) + { + var field = (FieldDeclarationSyntax) context.Node; + + if (!field.Parent.IsKind(SyntaxKind.ClassDeclaration)) + { + return; + } + + if (field.Parent is not TypeDeclarationSyntax type || !type.IsSpecificationClass(context)) + { + return; + } + + var variable = field.Declaration.Variables + .FirstOrDefault(x => !x.Identifier.IsMissing); + + if (variable == null) + { + return; + } + + CheckAccessModifier(context, variable.Identifier, field.Modifiers); + } + + private void CheckAccessModifier(SyntaxNodeAnalysisContext context, SyntaxToken identifier, SyntaxTokenList modifiers) + { + if (identifier.IsMissing) + { + return; + } + + var modifier = modifiers + .FirstOrDefault(x => x.IsKind(SyntaxKind.PublicKeyword) || + x.IsKind(SyntaxKind.PrivateKeyword) || + x.IsKind(SyntaxKind.InternalKeyword) || + x.IsKind(SyntaxKind.ProtectedKeyword)); + + if (!modifier.IsKind(SyntaxKind.None)) + { + context.ReportDiagnostic(Diagnostic.Create(Rule, identifier.GetLocation(), identifier)); + } + } +} diff --git a/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedCodeFixProvider.cs b/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedCodeFixProvider.cs new file mode 100644 index 00000000..52d0b017 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Maintainability/AccessModifierShouldNotBeUsedCodeFixProvider.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Machine.Specifications.Analyzers.Maintainability; + +[Shared] +[ExportCodeFixProvider(LanguageNames.CSharp)] +public class AccessModifierShouldNotBeUsedCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticIds.Maintainability.AccessModifierShouldNotBeUsed); + + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan, true); + + if (node.IsMissing) + { + continue; + } + + var declaration = GetParentDeclaration(node); + + if (declaration == null) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + "Remove access modifier", + _ => TransformAsync(context.Document, root, declaration), + nameof(AccessModifierShouldNotBeUsedCodeFixProvider)), + diagnostic); + } + } + + private Task TransformAsync(Document document, SyntaxNode root, SyntaxNode declaration) + { + var fixedDeclaration = declaration.Kind() switch + { + SyntaxKind.ClassDeclaration => HandleDeclaration((ClassDeclarationSyntax) declaration), + SyntaxKind.FieldDeclaration => HandleDeclaration((FieldDeclarationSyntax) declaration), + _ => declaration + }; + + var fixedRoot = root.ReplaceNode(declaration, fixedDeclaration); + + return Task.FromResult(document.WithSyntaxRoot(fixedRoot)); + } + + private SyntaxNode HandleDeclaration(MemberDeclarationSyntax declaration) + { + if (!declaration.Modifiers.Any()) + { + return declaration; + } + + var trivia = declaration.Modifiers.First().LeadingTrivia; + + var modifiers = declaration.Modifiers + .Where(x => !IsAccessModifer(x)) + .ToArray(); + + return declaration + .WithModifiers(SyntaxFactory.TokenList(modifiers)) + .WithLeadingTrivia(trivia); + } + + private SyntaxNode GetParentDeclaration(SyntaxNode declaration) + { + while (declaration != null) + { + if (declaration.IsKind(SyntaxKind.ClassDeclaration) || declaration.IsKind(SyntaxKind.FieldDeclaration)) + { + return declaration; + } + + declaration = declaration.Parent; + } + + return null; + } + + private bool IsAccessModifer(SyntaxToken token) + { + return token.IsKind(SyntaxKind.PublicKeyword) || + token.IsKind(SyntaxKind.PrivateKeyword) || + token.IsKind(SyntaxKind.ProtectedKeyword) || + token.IsKind(SyntaxKind.InternalKeyword); + } +} diff --git a/src/Machine.Specifications.Analyzers/Maintainability/PrivateDelegateFieldWarningSuppressor.cs b/src/Machine.Specifications.Analyzers/Maintainability/PrivateDelegateFieldWarningSuppressor.cs new file mode 100644 index 00000000..fc8ae009 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Maintainability/PrivateDelegateFieldWarningSuppressor.cs @@ -0,0 +1,86 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers.Maintainability; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class PrivateDelegateFieldWarningSuppressor : DiagnosticSuppressor +{ + private static readonly SuppressionDescriptor Descriptor = + new SuppressionDescriptor(DiagnosticIds.Maintainability.PrivateDelegateFieldWarning, "IDE0051", "Private delegate field used by Machine.Specifications runner"); + + private static readonly Type[] SuppressedAttributeTypes = + [ + typeof(SetupDelegateAttribute), + typeof(ActDelegateAttribute), + typeof(BehaviorDelegateAttribute), + typeof(AssertDelegateAttribute), + typeof(CleanupDelegateAttribute) + ]; + + public override ImmutableArray SupportedSuppressions { get; } = [Descriptor]; + + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (var diagnostic in context.ReportedDiagnostics) + { + AnalyzeDiagnostic(diagnostic, context); + } + } + + private void AnalyzeDiagnostic(Diagnostic diagnostic, SuppressionAnalysisContext context) + { + var fieldDeclarationSyntax = context.GetSuppressibleNode(diagnostic); + + if (fieldDeclarationSyntax == null) + { + return; + } + + var syntaxTree = diagnostic.Location.SourceTree; + + if (syntaxTree == null) + { + return; + } + + var model = context.GetSemanticModel(syntaxTree); + + if (model.GetDeclaredSymbol(fieldDeclarationSyntax) is not IFieldSymbol fieldSymbol) + { + return; + } + + if (!IsSuppressable(fieldSymbol)) + { + return; + } + + foreach (var descriptor in SupportedSuppressions.Where(d => d.SuppressedDiagnosticId == diagnostic.Id)) + { + context.ReportSuppression(Suppression.Create(descriptor, diagnostic)); + } + } + + private static bool IsSuppressable(IFieldSymbol fieldSymbol) + { + var fieldTypeAttributes = fieldSymbol.Type.GetAttributes() + .Where(x => x.AttributeClass != null) + .Select(x => x.AttributeClass!); + + + if (fieldTypeAttributes.Any(x => SuppressedAttributeTypes.Any(y => x.Matches(y)))) + { + return true; + } + + if (fieldSymbol.DeclaredAccessibility == Accessibility.Public && fieldSymbol.ContainingType.Extends(typeof(object))) + { + return true; + } + + return false; + } +} diff --git a/src/Machine.Specifications.Analyzers/MetadataNames.cs b/src/Machine.Specifications.Analyzers/MetadataNames.cs new file mode 100644 index 00000000..9bda61e8 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/MetadataNames.cs @@ -0,0 +1,18 @@ +namespace Machine.Specifications.Analyzers; + +public static class MetadataNames +{ + public const string MspecAssemblyName = "Machine.Specifications"; + + public const string MspecCoreAssemblyName = "Machine.Specifications.Core"; + + public const string MspecNamespace = "Machine.Specifications"; + + public const string MspecItDelegate = "It"; + + public const string MspecBehavesLikeDelegate = "Behaves_like"; + + public const string MspecEstablishDelegate = "Establish"; + + public const string MspecBecause = "Because"; +} diff --git a/src/Machine.Specifications.Analyzers/Naming/ElementNameShouldBeSnakeCasedAnalyzer.cs b/src/Machine.Specifications.Analyzers/Naming/ElementNameShouldBeSnakeCasedAnalyzer.cs new file mode 100644 index 00000000..610216ed --- /dev/null +++ b/src/Machine.Specifications.Analyzers/Naming/ElementNameShouldBeSnakeCasedAnalyzer.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers.Naming; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ElementNameShouldBeSnakeCasedAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } + + public override void Initialize(AnalysisContext context) + { + } +} diff --git a/src/Machine.Specifications.Analyzers/SuppressionAnalysisContextExtensions.cs b/src/Machine.Specifications.Analyzers/SuppressionAnalysisContextExtensions.cs new file mode 100644 index 00000000..4d8e8959 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/SuppressionAnalysisContextExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; + +namespace Machine.Specifications.Analyzers; + +internal static class SuppressionAnalysisContextExtensions +{ + public static T? GetSuppressibleNode(this SuppressionAnalysisContext context, Diagnostic diagnostic) + where T : SyntaxNode + { + return GetSuppressibleNode(context, diagnostic, _ => true); + } + + public static T? GetSuppressibleNode(this SuppressionAnalysisContext context, Diagnostic diagnostic, Func predicate) + where T : SyntaxNode + { + var root = diagnostic.Location.SourceTree?.GetRoot(context.CancellationToken); + + return root? + .FindNode(diagnostic.Location.SourceSpan) + .DescendantNodesAndSelf() + .OfType() + .FirstOrDefault(predicate); + } +} diff --git a/src/Machine.Specifications.Analyzers/SymbolExtensions.cs b/src/Machine.Specifications.Analyzers/SymbolExtensions.cs new file mode 100644 index 00000000..cc0f7774 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/SymbolExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.CodeAnalysis; + +namespace Machine.Specifications.Analyzers; + +public static class SymbolExtensions +{ + public static bool IsMember(this ITypeSymbol symbol) + { + return symbol.IsMspecAssembly() && + symbol.ContainingNamespace?.ToString() == MetadataNames.MspecNamespace && + symbol.TypeKind == TypeKind.Delegate; + } + + private static bool IsMspecAssembly(this ITypeSymbol symbol) + { + return symbol.ContainingAssembly?.Name is MetadataNames.MspecAssemblyName or MetadataNames.MspecCoreAssemblyName; + } +} diff --git a/src/Machine.Specifications.Analyzers/TypeDeclarationSyntaxExtensions.cs b/src/Machine.Specifications.Analyzers/TypeDeclarationSyntaxExtensions.cs new file mode 100644 index 00000000..44e6a9ca --- /dev/null +++ b/src/Machine.Specifications.Analyzers/TypeDeclarationSyntaxExtensions.cs @@ -0,0 +1,23 @@ +using System.Linq; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Machine.Specifications.Analyzers; + +public static class TypeDeclarationSyntaxExtensions +{ + public static bool IsSpecificationClass(this TypeDeclarationSyntax type, SyntaxNodeAnalysisContext context) + { + return type + .DescendantNodesAndSelf() + .OfType() + .Any(x => x.HasSpecificationMember(context)); + } + + public static bool HasSpecificationMember(this TypeDeclarationSyntax declaration, SyntaxNodeAnalysisContext context) + { + return declaration.Members + .OfType() + .Any(x => x.IsSpecification(context)); + } +} diff --git a/src/Machine.Specifications.Analyzers/TypeSymbolExtensions.cs b/src/Machine.Specifications.Analyzers/TypeSymbolExtensions.cs new file mode 100644 index 00000000..10fb3948 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/TypeSymbolExtensions.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; + +namespace Machine.Specifications.Analyzers; + +internal static class TypeSymbolExtensions +{ + public static bool Extends(this ITypeSymbol? symbol, Type? type) + { + if (symbol == null || type == null) + { + return false; + } + + while (symbol != null) + { + if (symbol.Matches(type)) + { + return true; + } + + symbol = symbol.BaseType; + } + + return false; + } + + public static bool Matches(this ITypeSymbol symbol, Type type) + { + switch (symbol.SpecialType) + { + case SpecialType.System_Void: + return type == typeof(void); + + case SpecialType.System_Boolean: + return type == typeof(bool); + + case SpecialType.System_Int32: + return type == typeof(int); + + case SpecialType.System_Single: + return type == typeof(float); + } + + if (type.IsArray) + { + return symbol is IArrayTypeSymbol array && Matches(array.ElementType, type.GetElementType()!); + } + + if (symbol is not INamedTypeSymbol named) + { + return false; + } + + if (type.IsConstructedGenericType) + { + var args = type.GetTypeInfo().GenericTypeArguments; + + if (args.Length != named.TypeArguments.Length) + { + return false; + } + + for (var i = 0; i < args.Length; i++) + { + if (!Matches(named.TypeArguments[i], args[i])) + { + return false; + } + } + + return Matches(named.ConstructedFrom, type.GetGenericTypeDefinition()); + } + + return named.Name == type.Name && named.ContainingNamespace?.ToDisplayString() == type.Namespace; + } +} diff --git a/src/Machine.Specifications.Analyzers/install.ps1 b/src/Machine.Specifications.Analyzers/install.ps1 new file mode 100644 index 00000000..ff051759 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/install.ps1 @@ -0,0 +1,58 @@ +param($installPath, $toolsPath, $package, $project) + +if($project.Object.SupportsPackageDependencyResolution) +{ + if($project.Object.SupportsPackageDependencyResolution()) + { + # Do not install analyzers via install.ps1, instead let the project system handle it. + return + } +} + +$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve + +foreach($analyzersPath in $analyzersPaths) +{ + if (Test-Path $analyzersPath) + { + # Install the language agnostic analyzers. + foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll) + { + if($project.Object.AnalyzerReferences) + { + $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName) + } + } + } +} + +# $project.Type gives the language name like (C# or VB.NET) +$languageFolder = "" +if($project.Type -eq "C#") +{ + $languageFolder = "cs" +} +if($project.Type -eq "VB.NET") +{ + $languageFolder = "vb" +} +if($languageFolder -eq "") +{ + return +} + +foreach($analyzersPath in $analyzersPaths) +{ + # Install language specific analyzers. + $languageAnalyzersPath = join-path $analyzersPath $languageFolder + if (Test-Path $languageAnalyzersPath) + { + foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll) + { + if($project.Object.AnalyzerReferences) + { + $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName) + } + } + } +} diff --git a/src/Machine.Specifications.Analyzers/uninstall.ps1 b/src/Machine.Specifications.Analyzers/uninstall.ps1 new file mode 100644 index 00000000..4bed3337 --- /dev/null +++ b/src/Machine.Specifications.Analyzers/uninstall.ps1 @@ -0,0 +1,65 @@ +param($installPath, $toolsPath, $package, $project) + +if($project.Object.SupportsPackageDependencyResolution) +{ + if($project.Object.SupportsPackageDependencyResolution()) + { + # Do not uninstall analyzers via uninstall.ps1, instead let the project system handle it. + return + } +} + +$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve + +foreach($analyzersPath in $analyzersPaths) +{ + # Uninstall the language agnostic analyzers. + if (Test-Path $analyzersPath) + { + foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll) + { + if($project.Object.AnalyzerReferences) + { + $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName) + } + } + } +} + +# $project.Type gives the language name like (C# or VB.NET) +$languageFolder = "" +if($project.Type -eq "C#") +{ + $languageFolder = "cs" +} +if($project.Type -eq "VB.NET") +{ + $languageFolder = "vb" +} +if($languageFolder -eq "") +{ + return +} + +foreach($analyzersPath in $analyzersPaths) +{ + # Uninstall language specific analyzers. + $languageAnalyzersPath = join-path $analyzersPath $languageFolder + if (Test-Path $languageAnalyzersPath) + { + foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll) + { + if($project.Object.AnalyzerReferences) + { + try + { + $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName) + } + catch + { + + } + } + } + } +}