From 5e3b632eeebb04cb535791fd15b2a07e13381bb4 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 25 Feb 2025 19:58:17 +0100 Subject: [PATCH] Extended code analyzers (#293) --- .github/CODEOWNERS | 10 +- .../ERI/AllowedLLMProvidersExtensions.cs | 17 +- .../Components/MudTextList.razor.cs | 2 +- app/MindWork AI Studio/Redirect.cs | 1 - .../DataModel/DataSourceTypeExtension.cs | 17 +- .../Settings/DataModel/ThemesExtensions.cs | 17 +- .../Settings/SettingsMigrations.cs | 2 +- .../AnalyzerReleases.Shipped.md | 13 +- .../SourceCodeRules/Identifier.cs | 13 + .../NamingAnalyzers/ConstStaticAnalyzer.cs | 67 +++++ .../NamingAnalyzers/LocalConstantsAnalyzer.cs | 63 +++++ .../UnderscorePrefixAnalyzer.cs | 46 ++++ .../ConvertToUpperCodeFixProvider.cs | 67 +++++ .../UnderscorePrefixCodeFixProvider.cs | 54 ++++ .../SwitchExpressionMethodAnalyzer.cs | 96 +++++++ .../SwitchExpressionMethodCodeFixProvider.cs | 162 ++++++++++++ .../UsageAnalyzers/EmptyStringAnalyzer.cs | 68 +++++ .../ProviderAccessAnalyzer.cs | 18 +- .../RandomInstantiationAnalyzer.cs | 55 ++++ .../UsageAnalyzers/ThisUsageAnalyzer.cs | 236 ++++++++++++++++++ .../EmptyStringCodeFixProvider.cs | 54 ++++ .../ThisUsageCodeFixProvider.cs | 71 ++++++ 22 files changed, 1099 insertions(+), 50 deletions(-) create mode 100644 app/SourceCodeRules/SourceCodeRules/Identifier.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/ConstStaticAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/LocalConstantsAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/UnderscorePrefixAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/ConvertToUpperCodeFixProvider.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/UnderscorePrefixCodeFixProvider.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/StyleAnalyzers/SwitchExpressionMethodAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/StyleCodeFixes/SwitchExpressionMethodCodeFixProvider.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs rename app/SourceCodeRules/SourceCodeRules/{ => UsageAnalyzers}/ProviderAccessAnalyzer.cs (85%) create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/RandomInstantiationAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/EmptyStringCodeFixProvider.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f55b3a..2399d51 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,13 +2,13 @@ * @MindWorkAI/maintainer # The release team is responsible for anything inside the .github directory, such as workflows, actions, and issue templates: -/.github/ @MindWorkAI/release - -# The release team is responsible for the update directory: -/.updates/ @MindWorkAI/release +/.github/ @MindWorkAI/release @SommerEngineering # Our Rust experts are responsible for the Rust codebase: /runtime/ @MindWorkAI/rust-experts # Our .NET experts are responsible for the .NET codebase: -/app/ @MindWorkAI/net-experts \ No newline at end of file +/app/ @MindWorkAI/net-experts + +# The source code rules must be reviewed by the release team: +/app/SourceCodeRules/ @MindWorkAI/release @SommerEngineering \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/ERI/AllowedLLMProvidersExtensions.cs b/app/MindWork AI Studio/Assistants/ERI/AllowedLLMProvidersExtensions.cs index 130859b..c9c6462 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AllowedLLMProvidersExtensions.cs +++ b/app/MindWork AI Studio/Assistants/ERI/AllowedLLMProvidersExtensions.cs @@ -2,15 +2,12 @@ namespace AIStudio.Assistants.ERI; public static class AllowedLLMProvidersExtensions { - public static string Description(this AllowedLLMProviders provider) + public static string Description(this AllowedLLMProviders provider) => provider switch { - return provider switch - { - AllowedLLMProviders.NONE => "Please select what kind of LLM provider are allowed for this data source", - AllowedLLMProviders.ANY => "Any LLM provider is allowed: users might choose a cloud-based or a self-hosted provider", - AllowedLLMProviders.SELF_HOSTED => "Self-hosted LLM providers are allowed: users cannot choose any cloud-based provider", - - _ => "Unknown option was selected" - }; - } + AllowedLLMProviders.NONE => "Please select what kind of LLM provider are allowed for this data source", + AllowedLLMProviders.ANY => "Any LLM provider is allowed: users might choose a cloud-based or a self-hosted provider", + AllowedLLMProviders.SELF_HOSTED => "Self-hosted LLM providers are allowed: users cannot choose any cloud-based provider", + + _ => "Unknown option was selected" + }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/MudTextList.razor.cs b/app/MindWork AI Studio/Components/MudTextList.razor.cs index 551878b..46cde41 100644 --- a/app/MindWork AI Studio/Components/MudTextList.razor.cs +++ b/app/MindWork AI Studio/Components/MudTextList.razor.cs @@ -14,7 +14,7 @@ public partial class MudTextList : ComponentBase public string Icon { get; set; } = Icons.Material.Filled.CheckCircle; [Parameter] - public string Class { get; set; } = ""; + public string Class { get; set; } = string.Empty; private string Classes => $"mud-text-list {this.Class}"; } diff --git a/app/MindWork AI Studio/Redirect.cs b/app/MindWork AI Studio/Redirect.cs index 1b974a3..29c42bc 100644 --- a/app/MindWork AI Studio/Redirect.cs +++ b/app/MindWork AI Studio/Redirect.cs @@ -13,7 +13,6 @@ internal static class Redirect await nextHandler(); return; } - #if DEBUG diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceTypeExtension.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceTypeExtension.cs index a630a92..196eac7 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceTypeExtension.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceTypeExtension.cs @@ -10,15 +10,12 @@ public static class DataSourceTypeExtension /// /// The data source type. /// The display name of the data source type. - public static string GetDisplayName(this DataSourceType type) + public static string GetDisplayName(this DataSourceType type) => type switch { - return type switch - { - DataSourceType.LOCAL_FILE => "Local File", - DataSourceType.LOCAL_DIRECTORY => "Local Directory", - DataSourceType.ERI_V1 => "External ERI Server (v1)", - - _ => "None", - }; - } + DataSourceType.LOCAL_FILE => "Local File", + DataSourceType.LOCAL_DIRECTORY => "Local Directory", + DataSourceType.ERI_V1 => "External ERI Server (v1)", + + _ => "None", + }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/ThemesExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/ThemesExtensions.cs index 7942ab7..5d36a1b 100644 --- a/app/MindWork AI Studio/Settings/DataModel/ThemesExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/ThemesExtensions.cs @@ -2,15 +2,12 @@ namespace AIStudio.Settings.DataModel; public static class ThemesExtensions { - public static string GetName(this Themes theme) + public static string GetName(this Themes theme) => theme switch { - return theme switch - { - Themes.SYSTEM => "Synchronized with the operating system settings", - Themes.LIGHT => "Always use light theme", - Themes.DARK => "Always use dark theme", - - _ => "Unknown setting", - }; - } + Themes.SYSTEM => "Synchronized with the operating system settings", + Themes.LIGHT => "Always use light theme", + Themes.DARK => "Always use dark theme", + + _ => "Unknown setting", + }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/SettingsMigrations.cs b/app/MindWork AI Studio/Settings/SettingsMigrations.cs index da2a5ee..98482ce 100644 --- a/app/MindWork AI Studio/Settings/SettingsMigrations.cs +++ b/app/MindWork AI Studio/Settings/SettingsMigrations.cs @@ -84,7 +84,7 @@ public static class SettingsMigrations { Version = Version.V2, - Providers = previousData.Providers.Select(provider => provider with { IsSelfHosted = false, Hostname = "" }).ToList(), + Providers = previousData.Providers.Select(provider => provider with { IsSelfHosted = false, Hostname = string.Empty }).ToList(), EnableSpellchecking = previousData.EnableSpellchecking, IsSavingEnergy = previousData.IsSavingEnergy, diff --git a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md index 6b73c82..e6f97e7 100644 --- a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md +++ b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md @@ -2,6 +2,13 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------|----------|----------|------------------------ - MWAIS0001 | Usage | Error | ProviderAccessAnalyzer \ No newline at end of file + Rule ID | Category | Severity | Notes +-----------|----------|----------|-------------------------------- + MWAIS0001 | Usage | Error | ProviderAccessAnalyzer + MWAIS0002 | Naming | Error | ConstStaticAnalyzer + MWAIS0003 | Naming | Error | UnderscorePrefixAnalyzer + MWAIS0004 | Usage | Error | RandomInstantiationAnalyzer + MWAIS0005 | Usage | Error | ThisUsageAnalyzer + MWAIS0006 | Style | Error | SwitchExpressionMethodAnalyzer + MWAIS0007 | Usage | Error | EmptyStringAnalyzer + MWAIS0008 | Naming | Error | LocalConstantsAnalyzer \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/Identifier.cs b/app/SourceCodeRules/SourceCodeRules/Identifier.cs new file mode 100644 index 0000000..aa782cf --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/Identifier.cs @@ -0,0 +1,13 @@ +namespace SourceCodeRules; + +public static class Identifier +{ + public const string PROVIDER_ACCESS_ANALYZER = $"{Tools.ID_PREFIX}0001"; + public const string CONST_STATIC_ANALYZER = $"{Tools.ID_PREFIX}0002"; + public const string UNDERSCORE_PREFIX_ANALYZER = $"{Tools.ID_PREFIX}0003"; + public const string RANDOM_INSTANTIATION_ANALYZER = $"{Tools.ID_PREFIX}0004"; + public const string THIS_USAGE_ANALYZER = $"{Tools.ID_PREFIX}0005"; + public const string SWITCH_EXPRESSION_METHOD_ANALYZER = $"{Tools.ID_PREFIX}0006"; + public const string EMPTY_STRING_ANALYZER = $"{Tools.ID_PREFIX}0007"; + public const string LOCAL_CONSTANTS_ANALYZER = $"{Tools.ID_PREFIX}0008"; +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/ConstStaticAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/ConstStaticAnalyzer.cs new file mode 100644 index 0000000..d327313 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/ConstStaticAnalyzer.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.NamingAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class ConstStaticAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.CONST_STATIC_ANALYZER; + + private static readonly string TITLE = "Constant and static fields must be in UPPER_CASE"; + + private static readonly string MESSAGE_FORMAT = "Field '{0}' must be in UPPER_CASE"; + + private static readonly string DESCRIPTION = "All constant and static fields should be named using UPPER_CASE."; + + private const string CATEGORY = "Naming"; + + private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(this.AnalyzeField, SyntaxKind.FieldDeclaration); + } + + private void AnalyzeField(SyntaxNodeAnalysisContext context) + { + var fieldDeclaration = (FieldDeclarationSyntax)context.Node; + + // Prüfen ob das Feld static oder const ist + if (!fieldDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword) || m.IsKind(SyntaxKind.ConstKeyword))) + return; + + foreach (var variable in fieldDeclaration.Declaration.Variables) + { + var fieldName = variable.Identifier.Text; + + // Prüfen ob der Name bereits in UPPER_CASE ist + if (!IsUpperCase(fieldName)) + { + var diagnostic = Diagnostic.Create( + RULE, + variable.Identifier.GetLocation(), + fieldName); + + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsUpperCase(string name) + { + // Erlaubt: Nur Großbuchstaben, Zahlen und Unterstriche + return name.All(c => char.IsUpper(c) || char.IsDigit(c) || c == '_'); + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/LocalConstantsAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/LocalConstantsAnalyzer.cs new file mode 100644 index 0000000..da0e130 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/LocalConstantsAnalyzer.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.NamingAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class LocalConstantsAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.LOCAL_CONSTANTS_ANALYZER; + + private static readonly string TITLE = "Local constant variables must be in UPPER_CASE"; + + private static readonly string MESSAGE_FORMAT = "Local constant variable '{0}' must be in UPPER_CASE"; + + private static readonly string DESCRIPTION = "All local constant variables should be named using UPPER_CASE with words separated by underscores."; + + private const string CATEGORY = "Naming"; + + private static readonly DiagnosticDescriptor RULE = new( + DIAGNOSTIC_ID, + TITLE, + MESSAGE_FORMAT, + CATEGORY, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeLocalDeclaration, SyntaxKind.LocalDeclarationStatement); + } + + private static void AnalyzeLocalDeclaration(SyntaxNodeAnalysisContext context) + { + var localDeclaration = (LocalDeclarationStatementSyntax)context.Node; + if (!localDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.ConstKeyword))) + return; + + foreach (var variable in localDeclaration.Declaration.Variables) + { + var variableName = variable.Identifier.Text; + if (!IsUpperCase(variableName)) + { + var diagnostic = Diagnostic.Create(RULE, variable.Identifier.GetLocation(), variableName); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsUpperCase(string name) => name.All(c => char.IsUpper(c) || char.IsDigit(c) || c == '_') && + !string.IsNullOrEmpty(name) && name.Any(char.IsLetter); +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/UnderscorePrefixAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/UnderscorePrefixAnalyzer.cs new file mode 100644 index 0000000..6f35469 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/NamingAnalyzers/UnderscorePrefixAnalyzer.cs @@ -0,0 +1,46 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.NamingAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class UnderscorePrefixAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.UNDERSCORE_PREFIX_ANALYZER; + + private static readonly string TITLE = "Variable names cannot start with underscore"; + + private static readonly string MESSAGE_FORMAT = "The variable name '{0}' starts with an underscore which is not allowed"; + + private static readonly string DESCRIPTION = "Variable names cannot start with an underscore prefix."; + + private const string CATEGORY = "Naming"; + + private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeVariableDeclaration, SyntaxKind.VariableDeclarator); + } + + private static void AnalyzeVariableDeclaration(SyntaxNodeAnalysisContext context) + { + var variableDeclarator = (VariableDeclaratorSyntax)context.Node; + var variableName = variableDeclarator.Identifier.Text; + if (variableName.StartsWith("_")) + { + var diagnostic = Diagnostic.Create(RULE, variableDeclarator.Identifier.GetLocation(), variableName); + context.ReportDiagnostic(diagnostic); + } + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/ConvertToUpperCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/ConvertToUpperCodeFixProvider.cs new file mode 100644 index 0000000..961e7e0 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/ConvertToUpperCodeFixProvider.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Rename; + +namespace SourceCodeRules.NamingCodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ConvertToUpperCodeFixProvider)), Shared] +public sealed class ConvertToUpperCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => [Identifier.CONST_STATIC_ANALYZER, Identifier.LOCAL_CONSTANTS_ANALYZER]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var declaration = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + if (declaration is null) + return; + + context.RegisterCodeFix(CodeAction.Create(title: "Convert to UPPER_CASE", createChangedDocument: c => this.ConvertToUpperCaseAsync(context.Document, declaration, c), equivalenceKey: nameof(ConvertToUpperCodeFixProvider)), diagnostic); + } + + private async Task ConvertToUpperCaseAsync(Document document, VariableDeclaratorSyntax declarator, CancellationToken cancellationToken) + { + var oldName = declarator.Identifier.Text; + var newName = ConvertToUpperCase(oldName); + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + var symbol = semanticModel?.GetDeclaredSymbol(declarator, cancellationToken); + if (symbol is null) + return document; + + var solution = document.Project.Solution; + var newSolution = await Renamer.RenameSymbolAsync(solution, symbol, new SymbolRenameOptions(), newName, cancellationToken); + + return newSolution.GetDocument(document.Id) ?? document; + } + + private static string ConvertToUpperCase(string name) + { + var result = new StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var current = name[i]; + + // Insert an underscore before each uppercase letter, except the first one: + if (i > 0 && char.IsUpper(current) && !char.IsUpper(name[i - 1])) + result.Append('_'); + + result.Append(char.ToUpper(current)); + } + + return result.ToString(); + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/UnderscorePrefixCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/UnderscorePrefixCodeFixProvider.cs new file mode 100644 index 0000000..6308e57 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/NamingCodeFixes/UnderscorePrefixCodeFixProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Rename; + +namespace SourceCodeRules.NamingCodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnderscorePrefixCodeFixProvider)), Shared] +public sealed class UnderscorePrefixCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => [Identifier.UNDERSCORE_PREFIX_ANALYZER]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var declaration = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + if (declaration is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Remove underscore prefix", + createChangedDocument: c => this.RemoveUnderscorePrefixAsync(context.Document, declaration, c), + equivalenceKey: nameof(UnderscorePrefixCodeFixProvider)), + diagnostic); + } + + private async Task RemoveUnderscorePrefixAsync(Document document, VariableDeclaratorSyntax declarator, CancellationToken cancellationToken) + { + var oldName = declarator.Identifier.Text; + var newName = oldName.TrimStart('_'); + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + var symbol = semanticModel?.GetDeclaredSymbol(declarator, cancellationToken); + if (symbol is null) + return document; + + var solution = document.Project.Solution; + var newSolution = await Renamer.RenameSymbolAsync(solution, symbol, new SymbolRenameOptions(), newName, cancellationToken); + + return newSolution.GetDocument(document.Id) ?? document; + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/StyleAnalyzers/SwitchExpressionMethodAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/StyleAnalyzers/SwitchExpressionMethodAnalyzer.cs new file mode 100644 index 0000000..2bd7f80 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/StyleAnalyzers/SwitchExpressionMethodAnalyzer.cs @@ -0,0 +1,96 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.StyleAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public class SwitchExpressionMethodAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.SWITCH_EXPRESSION_METHOD_ANALYZER; + + private static readonly string TITLE = "Method with switch expression should use inline expression body"; + + private static readonly string MESSAGE_FORMAT = "Method with a switch expression should use inline expression body syntax with the switch keyword on the same line"; + + private static readonly string DESCRIPTION = "Methods that only return a switch expression should use the expression body syntax (=>) with the switch keyword on the same line for better readability."; + + private const string CATEGORY = "Style"; + + private static readonly DiagnosticDescriptor RULE = new( + DIAGNOSTIC_ID, + TITLE, + MESSAGE_FORMAT, + CATEGORY, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); + } + + private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) + { + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + + // Fall 1: Methode hat Block-Body mit einem Return-Statement, das eine Switch-Expression ist + if (methodDeclaration is { Body: not null, ExpressionBody: null }) + { + var statements = methodDeclaration.Body.Statements; + if (statements.Count == 1 && statements[0] is ReturnStatementSyntax { Expression: SwitchExpressionSyntax }) + { + var diagnostic = Diagnostic.Create(RULE, methodDeclaration.Identifier.GetLocation()); + context.ReportDiagnostic(diagnostic); + return; + } + } + + // Fall 2: Methode hat Expression-Body, aber die Switch-Expression beginnt auf einer neuen Zeile + var expressionBody = methodDeclaration.ExpressionBody; + if (expressionBody?.Expression is SwitchExpressionSyntax switchExpr) + { + var arrowToken = expressionBody.ArrowToken; + var switchToken = switchExpr.SwitchKeyword; + bool hasNewLineBetweenArrowAndSwitch = false; + + foreach (var trivia in arrowToken.TrailingTrivia) + { + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + { + hasNewLineBetweenArrowAndSwitch = true; + break; + } + } + + // Prüfe Leading Trivia des Switch-Keywords, falls notwendig + if (!hasNewLineBetweenArrowAndSwitch) + { + foreach (var trivia in switchToken.LeadingTrivia) + { + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + { + hasNewLineBetweenArrowAndSwitch = true; + break; + } + } + } + + if (hasNewLineBetweenArrowAndSwitch) + { + var diagnostic = Diagnostic.Create(RULE, methodDeclaration.Identifier.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/StyleCodeFixes/SwitchExpressionMethodCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/StyleCodeFixes/SwitchExpressionMethodCodeFixProvider.cs new file mode 100644 index 0000000..d89751f --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/StyleCodeFixes/SwitchExpressionMethodCodeFixProvider.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace SourceCodeRules.StyleCodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SwitchExpressionMethodCodeFixProvider)), Shared] +public class SwitchExpressionMethodCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => [Identifier.SWITCH_EXPRESSION_METHOD_ANALYZER]; + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + return; + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var methodDeclaration = root.FindToken(diagnosticSpan.Start) + .Parent?.AncestorsAndSelf() + .OfType() + .First(); + + if(methodDeclaration == null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Use inline expression body for switch expression", + createChangedDocument: c => UseInlineExpressionBodyAsync(context.Document, methodDeclaration, c), + equivalenceKey: nameof(SwitchExpressionMethodCodeFixProvider)), + diagnostic); + } + + private static async Task UseInlineExpressionBodyAsync(Document document, MethodDeclarationSyntax methodDecl, CancellationToken cancellationToken) + { + var sourceText = await document.GetTextAsync(cancellationToken); + var parameterText = methodDecl.ParameterList.ToString(); + var methodStartLine = sourceText.Lines.GetLineFromPosition(methodDecl.SpanStart); + + SwitchExpressionSyntax? switchExpr = null; + ExpressionSyntax? governingExpression = null; + var switchBodyText = string.Empty; + + if (methodDecl.Body != null) + { + // Case: Block-Body with a Return-Statement that contains a Switch-Expression + var returnStmt = (ReturnStatementSyntax)methodDecl.Body.Statements[0]; + if (returnStmt.Expression is not SwitchExpressionSyntax matchingSwitchExpr) + return document; + + switchExpr = matchingSwitchExpr; + governingExpression = switchExpr.GoverningExpression; + + // Extract the switch body text: + var switchStart = switchExpr.SwitchKeyword.SpanStart; + var switchEnd = switchExpr.CloseBraceToken.Span.End; + switchBodyText = sourceText.ToString(TextSpan.FromBounds(switchStart, switchEnd)); + } + else if (methodDecl.ExpressionBody != null) + { + // Case 2: Expression-Body with a poorly formatted Switch-Expression + switchExpr = (SwitchExpressionSyntax)methodDecl.ExpressionBody.Expression; + governingExpression = switchExpr.GoverningExpression; + + // Extract the switch body text: + var switchStart = switchExpr.SwitchKeyword.SpanStart; + var switchEnd = switchExpr.CloseBraceToken.Span.End; + switchBodyText = sourceText.ToString(TextSpan.FromBounds(switchStart, switchEnd)); + } + + if (switchExpr is null || governingExpression is null) + return document; + + // Extract the governing expression and the switch body: + var govExprText = sourceText.ToString(governingExpression.Span); + + // Create the new method with inline expression body and correct formatting: + var returnTypeText = methodDecl.ReturnType.ToString(); + var modifiersText = string.Join(" ", methodDecl.Modifiers); + var methodNameText = methodDecl.Identifier.Text; + + // Determine the indentation of the method: + var methodIndentation = ""; + for (var i = methodStartLine.Start; i < methodDecl.SpanStart; i++) + { + if (char.IsWhiteSpace(sourceText[i])) + methodIndentation += sourceText[i]; + else + break; + } + + // Erstelle die neue Methode mit Expression-Body und korrekter Formatierung + var newMethodText = new StringBuilder(); + newMethodText.Append($"{modifiersText} {returnTypeText} {methodNameText}{parameterText} => {govExprText} switch"); + + // Formatiere die geschweiften Klammern und den Switch-Body + var switchBody = switchBodyText.Substring("switch".Length).Trim(); + + // Bestimme die Einrückung für die Switch-Cases (4 Spaces oder 1 Tab mehr als die Methode) + var caseIndentation = methodIndentation + " "; // 4 Spaces Einrückung + + // Verarbeite die Klammern und formatiere den Body + var formattedSwitchBody = FormatSwitchBody(switchBody, methodIndentation, caseIndentation); + newMethodText.Append(formattedSwitchBody); + + // Ersetze die alte Methoden-Deklaration mit dem neuen Text + var newText = sourceText.Replace(methodDecl.Span, newMethodText.ToString()); + return document.WithText(newText); + } + + private static string FormatSwitchBody(string switchBody, string methodIndentation, string caseIndentation) + { + var result = new StringBuilder(); + + // Remove braces from the switch body: + var bodyWithoutBraces = switchBody.Trim(); + if (bodyWithoutBraces.StartsWith("{")) + bodyWithoutBraces = bodyWithoutBraces.Substring(1); + if (bodyWithoutBraces.EndsWith("}")) + bodyWithoutBraces = bodyWithoutBraces.Substring(0, bodyWithoutBraces.Length - 1); + + bodyWithoutBraces = bodyWithoutBraces.Trim(); + + // Add braces with correct indentation: + result.AppendLine(); + result.Append($"{methodIndentation}{{"); + + // Process each line of the switch body: + var lines = bodyWithoutBraces.Split(["\r\n", "\n"], System.StringSplitOptions.None); + foreach (var line in lines) + { + result.AppendLine(); + + var trimmedLine = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmedLine)) + continue; + + // Add correct indentation for each case: + result.Append(caseIndentation); + result.Append(trimmedLine); + } + + // Add the closing brace with correct indentation: + result.AppendLine(); + result.Append($"{methodIndentation}}};"); + + return result.ToString(); + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs new file mode 100644 index 0000000..f6cc65b --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.UsageAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.EMPTY_STRING_ANALYZER; + + private static readonly string TITLE = """ + Use string.Empty instead of "" + """; + + private static readonly string MESSAGE_FORMAT = """ + Use string.Empty instead of "" + """; + + private static readonly string DESCRIPTION = """Empty string literals ("") should be replaced with string.Empty for better code consistency and readability except in const contexts."""; + + private const string CATEGORY = "Usage"; + + private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeEmptyStringLiteral, SyntaxKind.StringLiteralExpression); + } + + private static void AnalyzeEmptyStringLiteral(SyntaxNodeAnalysisContext context) + { + var stringLiteral = (LiteralExpressionSyntax)context.Node; + if (stringLiteral.Token.ValueText != string.Empty) + return; + + if (IsInConstContext(stringLiteral)) + return; + + var diagnostic = Diagnostic.Create(RULE, stringLiteral.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsInConstContext(LiteralExpressionSyntax stringLiteral) + { + var variableDeclarator = stringLiteral.FirstAncestorOrSelf(); + if (variableDeclarator is null) + return false; + + var declaration = variableDeclarator.Parent?.Parent; + return declaration switch + { + FieldDeclarationSyntax fieldDeclaration => fieldDeclaration.Modifiers.Any(SyntaxKind.ConstKeyword), + LocalDeclarationStatementSyntax localDeclaration => localDeclaration.Modifiers.Any(SyntaxKind.ConstKeyword), + + _ => false + }; + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/ProviderAccessAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ProviderAccessAnalyzer.cs similarity index 85% rename from app/SourceCodeRules/SourceCodeRules/ProviderAccessAnalyzer.cs rename to app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ProviderAccessAnalyzer.cs index a28d7e0..a2db69d 100644 --- a/app/SourceCodeRules/SourceCodeRules/ProviderAccessAnalyzer.cs +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ProviderAccessAnalyzer.cs @@ -6,14 +6,14 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -namespace SourceCodeRules; +namespace SourceCodeRules.UsageAnalyzers; #pragma warning disable RS1038 [DiagnosticAnalyzer(LanguageNames.CSharp)] #pragma warning restore RS1038 -public class ProviderAccessAnalyzer : DiagnosticAnalyzer +public sealed class ProviderAccessAnalyzer : DiagnosticAnalyzer { - private const string DIAGNOSTIC_ID = $"{Tools.ID_PREFIX}0001"; + private const string DIAGNOSTIC_ID = Identifier.PROVIDER_ACCESS_ANALYZER; private static readonly string TITLE = "Direct access to `Providers` is not allowed"; @@ -25,7 +25,7 @@ public class ProviderAccessAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(RULE); + public override ImmutableArray SupportedDiagnostics => [RULE]; public override void Initialize(AnalysisContext context) { @@ -37,15 +37,15 @@ public class ProviderAccessAnalyzer : DiagnosticAnalyzer private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) { var memberAccess = (MemberAccessExpressionSyntax)context.Node; - - // Prüfen, ob wir eine Kette von Zugriffen haben, die auf "Providers" endet + + // Check if the member access is not on the `Providers` property: if (memberAccess.Name.Identifier.Text != "Providers") return; - - // Den kompletten Zugriffspfad aufbauen + + // Get the full path of the member access: var fullPath = this.GetFullMemberAccessPath(memberAccess); - // Prüfen, ob der Pfad unserem verbotenen Muster entspricht + // Check for the forbidden pattern: if (fullPath.EndsWith("ConfigurationData.Providers")) { var diagnostic = Diagnostic.Create(RULE, memberAccess.GetLocation()); diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/RandomInstantiationAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/RandomInstantiationAnalyzer.cs new file mode 100644 index 0000000..8244aa8 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/RandomInstantiationAnalyzer.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.UsageAnalyzers; + +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public class RandomInstantiationAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.RANDOM_INSTANTIATION_ANALYZER; + + private static readonly string TITLE = "Direct instantiation of Random is not allowed"; + + private static readonly string MESSAGE_FORMAT = "Do not use 'new Random()'. Instead, inject and use the ThreadSafeRandom service from the DI container."; + + private static readonly string DESCRIPTION = "Using 'new Random()' can lead to issues in multi-threaded scenarios. Use the ThreadSafeRandom service instead."; + + private const string CATEGORY = "Usage"; + + private static readonly DiagnosticDescriptor RULE = new( + DIAGNOSTIC_ID, + TITLE, + MESSAGE_FORMAT, + CATEGORY, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(this.AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); + } + + private void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) + { + var objectCreation = (ObjectCreationExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(objectCreation.Type).Symbol is not ITypeSymbol typeSymbol) + return; + + if (typeSymbol.ToString() == "System.Random" || typeSymbol is { Name: "Random", ContainingNamespace.Name: "System" }) + { + var diagnostic = Diagnostic.Create(RULE, objectCreation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs new file mode 100644 index 0000000..f48c374 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs @@ -0,0 +1,236 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SourceCodeRules.UsageAnalyzers; +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class ThisUsageAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.THIS_USAGE_ANALYZER; + + private static readonly string TITLE = "`this.` must be used"; + + private static readonly string MESSAGE_FORMAT = "`this.` must be used to access variables, methods, and properties"; + + private static readonly string DESCRIPTION = MESSAGE_FORMAT; + + private const string CATEGORY = "Usage"; + + private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(this.AnalyzeIdentifier, SyntaxKind.IdentifierName); + context.RegisterSyntaxNodeAction(this.AnalyzeGenericName, SyntaxKind.GenericName); + } + + private void AnalyzeGenericName(SyntaxNodeAnalysisContext context) + { + var genericNameSyntax = (GenericNameSyntax)context.Node; + + // Skip if already part of a 'this' expression + if (IsAccessedThroughThis(genericNameSyntax)) + return; + + if (IsWithinInitializer(genericNameSyntax)) + return; + + if (IsPartOfMemberAccess(genericNameSyntax)) + return; + + // Get symbol info for the generic name + var symbolInfo = context.SemanticModel.GetSymbolInfo(genericNameSyntax); + var symbol = symbolInfo.Symbol; + + if (symbol == null) + return; + + // Skip static methods + if (symbol.IsStatic) + return; + + // Skip local functions + if (symbol is IMethodSymbol methodSymbol && IsLocalFunction(methodSymbol)) + return; + + // Get the containing type of the current context + var containingSymbol = context.ContainingSymbol; + var currentType = containingSymbol?.ContainingType; + + // If we're in a static context, allow accessing members without this + if (IsInStaticContext(containingSymbol)) + return; + + if (symbol is IMethodSymbol) + { + var containingType = symbol.ContainingType; + + // If the symbol is a member of the current type or a base type, then require this + if (currentType != null && (SymbolEqualityComparer.Default.Equals(containingType, currentType) || + IsBaseTypeOf(containingType, currentType))) + { + var diagnostic = Diagnostic.Create(RULE, genericNameSyntax.Identifier.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } + + private void AnalyzeIdentifier(SyntaxNodeAnalysisContext context) + { + var identifierNameSyntax = (IdentifierNameSyntax)context.Node; + + // Skip if this identifier is part of a generic name - we'll handle that separately + if (identifierNameSyntax.Parent is GenericNameSyntax) + return; + + // Skip if already part of a 'this' expression + if (IsAccessedThroughThis(identifierNameSyntax)) + return; + + if (IsWithinInitializer(identifierNameSyntax)) + return; + + if (IsPartOfMemberAccess(identifierNameSyntax)) + return; + + // Also skip if it's part of static import statements + if (IsPartOfUsingStaticDirective(identifierNameSyntax)) + return; + + // Skip if it's part of a namespace or type name + if (IsPartOfNamespaceOrTypeName(identifierNameSyntax)) + return; + + // Get symbol info + var symbolInfo = context.SemanticModel.GetSymbolInfo(identifierNameSyntax); + var symbol = symbolInfo.Symbol; + + if (symbol == null) + return; + + // Skip local variables, parameters, and range variables + if (symbol.Kind is SymbolKind.Local or SymbolKind.Parameter or SymbolKind.RangeVariable or SymbolKind.TypeParameter) + return; + + // Skip types and namespaces + if (symbol.Kind is SymbolKind.NamedType or SymbolKind.Namespace) + return; + + // Explicitly check if this is a local function + if (symbol is IMethodSymbol methodSymbol && IsLocalFunction(methodSymbol)) + return; + + // Get the containing type of the current context + var containingSymbol = context.ContainingSymbol; + var currentType = containingSymbol?.ContainingType; + + // If we're in a static context, allow accessing members without this + if (IsInStaticContext(containingSymbol)) + return; + + // Now check if the symbol is an instance member of the current class + if (symbol is IFieldSymbol or IPropertySymbol or IMethodSymbol or IEventSymbol) + { + // Skip static members + if (symbol.IsStatic) + return; + + // Skip constants + if (symbol is IFieldSymbol { IsConst: true }) + return; + + var containingType = symbol.ContainingType; + + // If the symbol is a member of the current type or a base type, then require this + if (currentType != null && (SymbolEqualityComparer.Default.Equals(containingType, currentType) || + IsBaseTypeOf(containingType, currentType))) + { + var diagnostic = Diagnostic.Create(RULE, identifierNameSyntax.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsLocalFunction(IMethodSymbol methodSymbol) => methodSymbol.MethodKind is MethodKind.LocalFunction; + + private static bool IsBaseTypeOf(INamedTypeSymbol baseType, INamedTypeSymbol derivedType) + { + var currentType = derivedType.BaseType; + while (currentType != null) + { + if (SymbolEqualityComparer.Default.Equals(currentType, baseType)) + return true; + + currentType = currentType.BaseType; + } + + return false; + } + + private static bool IsInStaticContext(ISymbol? containingSymbol) => containingSymbol?.IsStatic is true; + + private static bool IsAccessedThroughThis(SyntaxNode node) + { + if (node.Parent is MemberAccessExpressionSyntax memberAccess) + if (memberAccess.Expression is ThisExpressionSyntax && memberAccess.Name == node) + return true; + + return false; + } + + private static bool IsWithinInitializer(SyntaxNode node) + { + for (var current = node.Parent; current != null; current = current.Parent) + if (current is InitializerExpressionSyntax) + return true; + + return false; + } + + private static bool IsPartOfMemberAccess(SyntaxNode node) + { + // Check if the node is part of a member access expression where the expression is not 'this': + if (node.Parent is MemberAccessExpressionSyntax memberAccess) + { + // If the member access expression is 'this', it's allowed: + if (memberAccess.Expression is ThisExpressionSyntax) + return false; + + // If the member access expression is something else (e.g., instance.Member), skip: + if (memberAccess.Name == node) + return true; + } + + // Also check for conditional access expressions (e.g., instance?.Member): + if (node.Parent is ConditionalAccessExpressionSyntax) + return true; + + return false; + } + + private static bool IsPartOfUsingStaticDirective(SyntaxNode node) + { + for (var current = node.Parent; current != null; current = current.Parent) + if (current is UsingDirectiveSyntax) + return true; + + return false; + } + + private static bool IsPartOfNamespaceOrTypeName(SyntaxNode node) + { + // Check if a node is part of a namespace, class, or type declaration: + if (node.Parent is NameSyntax && node.Parent is not MemberAccessExpressionSyntax) + return true; + + return false; + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/EmptyStringCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/EmptyStringCodeFixProvider.cs new file mode 100644 index 0000000..fe04dad --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/EmptyStringCodeFixProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace SourceCodeRules.UsageCodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EmptyStringCodeFixProvider)), Shared] +public class EmptyStringCodeFixProvider : CodeFixProvider +{ + private const string TITLE = """Replace "" with string.Empty"""; + + public sealed override ImmutableArray FixableDiagnosticIds => [Identifier.EMPTY_STRING_ANALYZER]; + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + if(root is null) + return; + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + if (root.FindToken(diagnosticSpan.Start).Parent is not LiteralExpressionSyntax emptyStringLiteral) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: TITLE, + createChangedDocument: c => ReplaceWithStringEmpty(context.Document, emptyStringLiteral, c), + equivalenceKey: TITLE), + diagnostic); + } + private static async Task ReplaceWithStringEmpty(Document document, LiteralExpressionSyntax emptyStringLiteral, CancellationToken cancellationToken) + { + var stringEmptyExpression = SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.IdentifierName("string"), SyntaxFactory.IdentifierName("Empty")).WithAdditionalAnnotations(Formatter.Annotation); + var root = await document.GetSyntaxRootAsync(cancellationToken); + if (root is null) + return document; + + var newRoot = root.ReplaceNode(emptyStringLiteral, stringEmptyExpression); + return document.WithSyntaxRoot(newRoot); + } +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs new file mode 100644 index 0000000..6f98772 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace SourceCodeRules.UsageCodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThisUsageCodeFixProvider)), Shared] +public class ThisUsageCodeFixProvider : CodeFixProvider +{ + private const string TITLE = "Add 'this.' prefix"; + + public sealed override ImmutableArray FixableDiagnosticIds => [Identifier.THIS_USAGE_ANALYZER]; + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + if (root == null) + return; + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var node = root.FindNode(diagnosticSpan); + + if (node is IdentifierNameSyntax identifierNode) + { + context.RegisterCodeFix( + CodeAction.Create( + title: TITLE, + createChangedDocument: c => AddThisPrefixAsync(context.Document, identifierNode, c), + equivalenceKey: nameof(ThisUsageCodeFixProvider)), + diagnostic); + } + else if (node is GenericNameSyntax genericNameNode) + { + context.RegisterCodeFix( + CodeAction.Create( + title: TITLE, + createChangedDocument: c => AddThisPrefixAsync(context.Document, genericNameNode, c), + equivalenceKey: nameof(ThisUsageCodeFixProvider)), + diagnostic); + } + } + + private static async Task AddThisPrefixAsync(Document document, SyntaxNode node, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + if (root == null) + return document; + + var thisExpression = SyntaxFactory.ThisExpression(); + var leadingTrivia = node.GetLeadingTrivia(); + var memberAccessExpression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + thisExpression.WithLeadingTrivia(leadingTrivia), + ((SimpleNameSyntax)node).WithLeadingTrivia(SyntaxTriviaList.Empty)) + .WithTrailingTrivia(node.GetTrailingTrivia()); + + var newRoot = root.ReplaceNode(node, memberAccessExpression); + return document.WithSyntaxRoot(newRoot); + } +} \ No newline at end of file