diff --git a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md index 50220653..62f03de9 100644 --- a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md +++ b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md @@ -9,4 +9,5 @@ MWAIS0003 | Naming | Error | UnderscorePrefixAnalyzer MWAIS0004 | Usage | Error | RandomInstantiationAnalyzer MWAIS0005 | Usage | Error | ThisUsageAnalyzer - MWAIS0006 | Style | Error | SwitchExpressionMethodAnalyzer \ No newline at end of file + MWAIS0006 | Style | Error | SwitchExpressionMethodAnalyzer + MWAIS0007 | Usage | Error | EmptyStringAnalyzer \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/Identifier.cs b/app/SourceCodeRules/SourceCodeRules/Identifier.cs index dfd2fb05..80c02bc6 100644 --- a/app/SourceCodeRules/SourceCodeRules/Identifier.cs +++ b/app/SourceCodeRules/SourceCodeRules/Identifier.cs @@ -8,4 +8,5 @@ public static class Identifier 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"; } \ 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 00000000..f6cc65b7 --- /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/UsageCodeFixes/EmptyStringCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/EmptyStringCodeFixProvider.cs new file mode 100644 index 00000000..fe04dadf --- /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