From db9bb8f4878c8857af94af88182c3ef62f412f67 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 25 Feb 2025 18:34:22 +0100 Subject: [PATCH] Added this usage rule & code fix --- .../AnalyzerReleases.Shipped.md | 13 +- .../SourceCodeRules/Identifier.cs | 1 + .../UsageAnalyzers/ThisUsageAnalyzer.cs | 236 ++++++++++++++++++ .../ThisUsageCodeFixProvider.cs | 71 ++++++ 4 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs diff --git a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md index f2d4d4d9..6ce0a98e 100644 --- a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md +++ b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md @@ -2,9 +2,10 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------|----------|----------|------------------------ - MWAIS0001 | Usage | Error | ProviderAccessAnalyzer - MWAIS0002 | Naming | Error | ConstStaticAnalyzer - MWAIS0003 | Naming | Error | UnderscorePrefixAnalyzer - MWAIS0004 | Usage | Error | RandomInstantiationAnalyzer \ 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 \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/Identifier.cs b/app/SourceCodeRules/SourceCodeRules/Identifier.cs index 81eca55f..4e51b98b 100644 --- a/app/SourceCodeRules/SourceCodeRules/Identifier.cs +++ b/app/SourceCodeRules/SourceCodeRules/Identifier.cs @@ -6,4 +6,5 @@ public static class Identifier 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"; } \ 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 00000000..f48c374b --- /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/ThisUsageCodeFixProvider.cs b/app/SourceCodeRules/SourceCodeRules/UsageCodeFixes/ThisUsageCodeFixProvider.cs new file mode 100644 index 00000000..6f98772c --- /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