Переглянути джерело

Fixes #4170 - Added analyzer that flags when user does not have `Handled=true` (#4182)

* Added analyzer

* WIP - Trying to create tests, failing with bad dependencies

* Working test woo

* Tidy up

* Tidy up

* Fix integration tests failing on command line

* Use 4.11 compiler

* Fix expecting 'e' as param name

* Make analyzer come as part of Terminal.Gui

* Add docs

* Fix warnings
Thomas Nind 2 місяців тому
батько
коміт
3a645191db

+ 7 - 2
Directory.Packages.props

@@ -5,11 +5,16 @@
   <ItemGroup>
     <!-- Enable Nuget Source Link for github -->
     <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.Features" Version="4.11.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" Version="4.11.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.11.0" />
+    <PackageVersion Include="Microsoft.Net.Compilers.Toolset" Version="4.11.0" />
     <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="[8,9)" />
     <PackageVersion Include="ColorHelper" Version="[1.8.1,2)" />
     <PackageVersion Include="JetBrains.Annotations" Version="[2024.3.0,)" />
-    <PackageVersion Include="Microsoft.CodeAnalysis" Version="[4.11,4.12)" />
-    <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="[4.11,4.12)" />
+    <PackageVersion Include="Microsoft.CodeAnalysis" Version="4.11.0" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.11.0" />
     <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="[9.0.2,10)" />
     <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.6" />

+ 61 - 0
Terminal.Gui.Analyzers.Tests/HandledEventArgsAnalyzerTests.cs

@@ -0,0 +1,61 @@
+using Terminal.Gui.Input;
+using Terminal.Gui.Views;
+
+namespace Terminal.Gui.Analyzers.Tests;
+
+public class HandledEventArgsAnalyzerTests
+{
+    [Theory]
+    [InlineData("e")]
+    [InlineData ("args")]
+    public async Task Should_ReportDiagnostic_When_EHandledNotSet_Lambda (string paramName)
+    {
+        var originalCode = $$"""
+                            using Terminal.Gui.Views;
+
+                            class TestClass
+                            {
+                                void Setup()
+                                {
+                                    var b = new Button();
+                                    b.Accepting += (s, {{paramName}}) =>
+                                    {
+                                        // Forgot {{paramName}}.Handled = true;
+                                    };
+                                }
+                            }
+                            """;
+        await new ProjectBuilder ()
+              .WithSourceCode (originalCode)
+              .WithAnalyzer (new HandledEventArgsAnalyzer ())
+              .ValidateAsync ();
+    }
+
+    [Theory]
+    [InlineData ("e")]
+    [InlineData ("args")]
+    public async Task Should_ReportDiagnostic_When_EHandledNotSet_Method (string paramName)
+    {
+        var originalCode = $$"""
+                            using Terminal.Gui.Views;
+                            using Terminal.Gui.Input;
+
+                            class TestClass
+                            {
+                                void Setup()
+                                {
+                                    var b = new Button();
+                                    b.Accepting += BOnAccepting;
+                                }
+                                private void BOnAccepting (object? sender, CommandEventArgs {{paramName}})
+                                {
+
+                                }
+                            }
+                            """;
+        await new ProjectBuilder ()
+              .WithSourceCode (originalCode)
+              .WithAnalyzer (new HandledEventArgsAnalyzer ())
+              .ValidateAsync ();
+    }
+}

+ 165 - 0
Terminal.Gui.Analyzers.Tests/ProjectBuilder.cs

@@ -0,0 +1,165 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.Diagnostics;
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Drawing;
+using Microsoft.CodeAnalysis.CodeActions;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+using Document = Microsoft.CodeAnalysis.Document;
+using Formatter = Microsoft.CodeAnalysis.Formatting.Formatter;
+using System.Reflection;
+using JetBrains.Annotations;
+
+public sealed class ProjectBuilder
+{
+    private string _sourceCode;
+    private string _expectedFixedCode;
+    private DiagnosticAnalyzer _analyzer;
+    private CodeFixProvider _codeFix;
+
+    public ProjectBuilder WithSourceCode (string source)
+    {
+        _sourceCode = source;
+        return this;
+    }
+
+    public ProjectBuilder ShouldFixCodeWith (string expected)
+    {
+        _expectedFixedCode = expected;
+        return this;
+    }
+
+    public ProjectBuilder WithAnalyzer (DiagnosticAnalyzer analyzer)
+    {
+        _analyzer = analyzer;
+        return this;
+    }
+
+    public ProjectBuilder WithCodeFix (CodeFixProvider codeFix)
+    {
+        _codeFix = codeFix;
+        return this;
+    }
+
+    public async Task ValidateAsync ()
+    {
+        if (_sourceCode == null)
+        {
+            throw new InvalidOperationException ("Source code not set.");
+        }
+
+        if (_analyzer == null)
+        {
+            throw new InvalidOperationException ("Analyzer not set.");
+        }
+
+        // Parse original document
+        var document = CreateDocument (_sourceCode);
+        var compilation = await document.Project.GetCompilationAsync ();
+
+        var diagnostics = compilation.GetDiagnostics ();
+        var errors = diagnostics.Where (d => d.Severity == DiagnosticSeverity.Error);
+
+        if (errors.Any ())
+        {
+            var errorMessages = string.Join (Environment.NewLine, errors.Select (e => e.ToString ()));
+            throw new Exception ("Compilation failed with errors:" + Environment.NewLine + errorMessages);
+        }
+
+        // Run analyzer
+        var analyzerDiagnostics = await GetAnalyzerDiagnosticsAsync (compilation, _analyzer);
+
+        Assert.NotEmpty (analyzerDiagnostics);
+
+        if (_expectedFixedCode != null)
+        {
+            if (_codeFix == null)
+            {
+                throw new InvalidOperationException ("Expected code fix but none was set.");
+            }
+
+            var fixedDocument = await ApplyCodeFixAsync (document, analyzerDiagnostics.First (), _codeFix);
+
+            var formattedDocument = await Formatter.FormatAsync (fixedDocument);
+            var fixedSource = (await formattedDocument.GetTextAsync ()).ToString ();
+
+            Assert.Equal (_expectedFixedCode, fixedSource);
+        }
+    }
+
+    private static Document CreateDocument (string source)
+    {
+        var dd = typeof (Enumerable).GetTypeInfo ().Assembly.Location;
+        var coreDir = Directory.GetParent (dd) ?? throw new Exception ($"Could not find parent directory of dotnet sdk.  Sdk directory was {dd}");
+
+        var workspace = new AdhocWorkspace ();
+        var projectId = ProjectId.CreateNewId ();
+        var documentId = DocumentId.CreateNewId (projectId);
+
+        var references = new List<MetadataReference> ()
+        {
+            MetadataReference.CreateFromFile(typeof(Button).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(View).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(System.IO.FileSystemInfo).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(MarshalByValueComponent).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(ObservableCollection<string>).Assembly.Location),
+
+            // New assemblies required by Terminal.Gui version 2
+            MetadataReference.CreateFromFile(typeof(Size).Assembly.Location),
+            MetadataReference.CreateFromFile(typeof(CanBeNullAttribute).Assembly.Location),
+
+
+            MetadataReference.CreateFromFile(Path.Combine(coreDir.FullName, "mscorlib.dll")),
+            MetadataReference.CreateFromFile(Path.Combine(coreDir.FullName, "System.Runtime.dll")),
+            MetadataReference.CreateFromFile(Path.Combine(coreDir.FullName, "System.Collections.dll")),
+            MetadataReference.CreateFromFile(Path.Combine(coreDir.FullName, "System.Data.Common.dll")),
+            // Add more as necessary
+        };
+
+
+        var projectInfo = ProjectInfo.Create (
+                                              projectId,
+                                              VersionStamp.Create (),
+                                              "TestProject",
+                                              "TestAssembly",
+                                              LanguageNames.CSharp,
+                                              compilationOptions: new CSharpCompilationOptions (OutputKind.DynamicallyLinkedLibrary),
+                                              metadataReferences: references);
+
+        var solution = workspace.CurrentSolution
+                                .AddProject (projectInfo)
+                                .AddDocument (documentId, "Test.cs", SourceText.From (source));
+
+        return solution.GetDocument (documentId)!;
+    }
+
+    private static async Task<ImmutableArray<Diagnostic>> GetAnalyzerDiagnosticsAsync (Compilation compilation, DiagnosticAnalyzer analyzer)
+    {
+        var compilationWithAnalyzers = compilation.WithAnalyzers (ImmutableArray.Create (analyzer));
+        return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync ();
+    }
+
+    private static async Task<Document> ApplyCodeFixAsync (Document document, Diagnostic diagnostic, CodeFixProvider codeFix)
+    {
+        CodeAction _codeAction = null;
+        var context = new CodeFixContext ((TextDocument)document, diagnostic, (action, _) => _codeAction = action, CancellationToken.None);
+
+        await codeFix.RegisterCodeFixesAsync (context);
+
+        if (_codeAction == null)
+        {
+            throw new InvalidOperationException ("Code fix did not register a fix.");
+        }
+
+        var operations = await _codeAction.GetOperationsAsync (CancellationToken.None);
+        var solution = operations.OfType<ApplyChangesOperation> ().First ().ChangedSolution;
+        return solution.GetDocument (document.Id);
+    }
+}

+ 36 - 0
Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj

@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+	<PropertyGroup>
+		<Nullable>enable</Nullable>
+		<IsPackable>false</IsPackable>
+		<IsTestProject>true</IsTestProject>
+		<DefineConstants>$(DefineConstants);JETBRAINS_ANNOTATIONS;CONTRACTS_FULL</DefineConstants>
+		<DebugType>portable</DebugType>
+		<ImplicitUsings>enable</ImplicitUsings>
+		<NoLogo>true</NoLogo>
+		<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
+		<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+	</PropertyGroup>
+	<ItemGroup>
+		<PackageReference Include="coverlet.collector" />
+		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
+		<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
+		<PackageReference Include="Microsoft.CodeAnalysis.Features" />
+		<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" />
+		<PackageReference Include="Microsoft.NET.Test.Sdk" />
+		<PackageReference Include="xunit" />
+		<PackageReference Include="xunit.runner.visualstudio" />
+  </ItemGroup>
+	<ItemGroup>
+	  <ProjectReference Include="..\Terminal.Gui.Analyzers\Terminal.Gui.Analyzers.csproj" />
+	  <ProjectReference Include="..\Terminal.Gui\Terminal.Gui.csproj" />
+	</ItemGroup>
+	<ItemGroup>
+		<Using Include="Xunit" />
+	</ItemGroup>
+	<ItemGroup>
+		<None Update="xunit.runner.json">
+			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+		</None>
+	</ItemGroup>
+</Project>

+ 6 - 0
Terminal.Gui.Analyzers/AnalyzerReleases.Shipped.md

@@ -0,0 +1,6 @@
+## Release 1.0.0
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|--------------------

+ 5 - 0
Terminal.Gui.Analyzers/AnalyzerReleases.Unshipped.md

@@ -0,0 +1,5 @@
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|--------------------
+TGUI001  | Reliability |  Warning | HandledEventArgsAnalyzer, [Documentation](./TGUI001.md)

+ 67 - 0
Terminal.Gui.Analyzers/DiagnosticCategory.cs

@@ -0,0 +1,67 @@
+namespace Terminal.Gui.Analyzers;
+
+/// <summary>
+/// Categories commonly used for diagnostic analyzers, inspired by FxCop and .NET analyzers conventions.
+/// </summary>
+internal enum DiagnosticCategory
+{
+    /// <summary>
+    /// Issues related to naming conventions and identifiers.
+    /// </summary>
+    Naming,
+
+    /// <summary>
+    /// API design, class structure, inheritance, etc.
+    /// </summary>
+    Design,
+
+    /// <summary>
+    /// How code uses APIs or language features incorrectly or suboptimally.
+    /// </summary>
+    Usage,
+
+    /// <summary>
+    /// Patterns that cause poor runtime performance.
+    /// </summary>
+    Performance,
+
+    /// <summary>
+    /// Vulnerabilities or insecure coding patterns.
+    /// </summary>
+    Security,
+
+    /// <summary>
+    /// Code patterns that can cause bugs, crashes, or unpredictable behavior.
+    /// </summary>
+    Reliability,
+
+    /// <summary>
+    /// Code readability, complexity, or future-proofing concerns.
+    /// </summary>
+    Maintainability,
+
+    /// <summary>
+    /// Code patterns that may not work on all platforms or frameworks.
+    /// </summary>
+    Portability,
+
+    /// <summary>
+    /// Issues with culture, localization, or globalization support.
+    /// </summary>
+    Globalization,
+
+    /// <summary>
+    /// Problems when working with COM, P/Invoke, or other interop scenarios.
+    /// </summary>
+    Interoperability,
+
+    /// <summary>
+    /// Issues with missing or incorrect XML doc comments.
+    /// </summary>
+    Documentation,
+
+    /// <summary>
+    /// Purely stylistic issues not affecting semantics (e.g., whitespace, order).
+    /// </summary>
+    Style
+}

+ 269 - 0
Terminal.Gui.Analyzers/HandledEventArgsAnalyzer.cs

@@ -0,0 +1,269 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Terminal.Gui.Analyzers;
+
+[DiagnosticAnalyzer (LanguageNames.CSharp)]
+public class HandledEventArgsAnalyzer : DiagnosticAnalyzer
+{
+    public const string DiagnosticId = "TGUI001";
+    private static readonly LocalizableString Title = "Accepting event handler should set Handled = true";
+    private static readonly LocalizableString MessageFormat = "Accepting event handler does not set Handled = true";
+    private static readonly LocalizableString Description = "Handlers for Accepting should mark the CommandEventArgs as handled by setting Handled = true otherwise subsequent Accepting event handlers may also fire (e.g. default buttons).";
+    private static readonly string Url = "https://github.com/tznind/gui.cs/blob/analyzer-no-handled/Terminal.Gui.Analyzers/TGUI001.md";
+    private const string Category = nameof(DiagnosticCategory.Reliability);
+
+    private static readonly DiagnosticDescriptor _rule = new (
+                                                              DiagnosticId,
+                                                              Title,
+                                                              MessageFormat,
+                                                              Category,
+                                                              DiagnosticSeverity.Warning,
+                                                              true,
+                                                              Description,
+                                                              helpLinkUri: Url);
+
+    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [_rule];
+
+    public override void Initialize (AnalysisContext context)
+    {
+        context.EnableConcurrentExecution ();
+
+        // Only analyze non-generated code
+        context.ConfigureGeneratedCodeAnalysis (GeneratedCodeAnalysisFlags.None);
+
+        // Register for b.Accepting += (s,e)=>{...};
+        context.RegisterSyntaxNodeAction (
+                                          AnalyzeLambdaOrAnonymous,
+                                          SyntaxKind.ParenthesizedLambdaExpression,
+                                          SyntaxKind.SimpleLambdaExpression,
+                                          SyntaxKind.AnonymousMethodExpression);
+
+        // Register for b.Accepting += MyMethod;
+        context.RegisterSyntaxNodeAction (
+                                          AnalyzeEventSubscriptionWithMethodGroup,
+                                          SyntaxKind.AddAssignmentExpression);
+    }
+
+    private static void AnalyzeLambdaOrAnonymous (SyntaxNodeAnalysisContext context)
+    {
+        var lambda = (AnonymousFunctionExpressionSyntax)context.Node;
+
+        // Check if this lambda is assigned to the Accepting event
+        if (!IsAssignedToAcceptingEvent (lambda.Parent, context))
+        {
+            return;
+        }
+
+        // Look for any parameter of type CommandEventArgs (regardless of name)
+        IParameterSymbol? eParam = GetCommandEventArgsParameter (lambda, context.SemanticModel);
+
+        if (eParam == null)
+        {
+            return;
+        }
+
+        // Analyze lambda body for e.Handled = true assignment
+        if (lambda.Body is BlockSyntax block)
+        {
+            bool setsHandled = block.Statements
+                                    .SelectMany (s => s.DescendantNodes ().OfType<AssignmentExpressionSyntax> ())
+                                    .Any (a => IsHandledAssignment (a, eParam, context));
+
+            if (!setsHandled)
+            {
+                var diag = Diagnostic.Create (_rule, lambda.GetLocation ());
+                context.ReportDiagnostic (diag);
+            }
+        }
+        else if (lambda.Body is ExpressionSyntax)
+        {
+            // Expression-bodied lambdas unlikely for event handlers — skip
+        }
+    }
+
+    /// <summary>
+    ///     Finds the first parameter of type CommandEventArgs in any parameter list (method or lambda).
+    /// </summary>
+    /// <param name="paramOwner"></param>
+    /// <param name="semanticModel"></param>
+    /// <returns></returns>
+    private static IParameterSymbol? GetCommandEventArgsParameter (SyntaxNode paramOwner, SemanticModel semanticModel)
+    {
+        SeparatedSyntaxList<ParameterSyntax>? parameters = paramOwner switch
+                                                           {
+                                                               AnonymousFunctionExpressionSyntax lambda => GetParameters (lambda),
+                                                               MethodDeclarationSyntax method => method.ParameterList.Parameters,
+                                                               _ => null
+                                                           };
+
+        if (parameters == null || parameters.Value.Count == 0)
+        {
+            return null;
+        }
+
+        foreach (ParameterSyntax param in parameters.Value)
+        {
+            IParameterSymbol? symbol = semanticModel.GetDeclaredSymbol (param);
+
+            if (symbol != null && IsCommandEventArgsType (symbol.Type))
+            {
+                return symbol;
+            }
+        }
+
+        return null;
+    }
+
+    private static bool IsAssignedToAcceptingEvent (SyntaxNode? node, SyntaxNodeAnalysisContext context)
+    {
+        if (node is AssignmentExpressionSyntax assignment && IsAcceptingEvent (assignment.Left, context))
+        {
+            return true;
+        }
+
+        if (node?.Parent is AssignmentExpressionSyntax parentAssignment && IsAcceptingEvent (parentAssignment.Left, context))
+        {
+            return true;
+        }
+
+        return false;
+    }
+
+    private static bool IsCommandEventArgsType (ITypeSymbol? type) { return type != null && type.Name == "CommandEventArgs"; }
+
+    private static void AnalyzeEventSubscriptionWithMethodGroup (SyntaxNodeAnalysisContext context)
+    {
+        var assignment = (AssignmentExpressionSyntax)context.Node;
+
+        // Check event name: b.Accepting += ...
+        if (!IsAcceptingEvent (assignment.Left, context))
+        {
+            return;
+        }
+
+        // Right side: should be method group (IdentifierNameSyntax)
+        if (assignment.Right is IdentifierNameSyntax methodGroup)
+        {
+            // Resolve symbol of method group
+            SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo (methodGroup);
+
+            if (symbolInfo.Symbol is IMethodSymbol methodSymbol)
+            {
+                // Find method declaration in syntax tree
+                ImmutableArray<SyntaxReference> declRefs = methodSymbol.DeclaringSyntaxReferences;
+
+                foreach (SyntaxReference declRef in declRefs)
+                {
+                    var methodDecl = declRef.GetSyntax () as MethodDeclarationSyntax;
+
+                    if (methodDecl != null)
+                    {
+                        AnalyzeHandlerMethodBody (context, methodDecl, methodSymbol);
+                    }
+                }
+            }
+        }
+    }
+
+    private static void AnalyzeHandlerMethodBody (SyntaxNodeAnalysisContext context, MethodDeclarationSyntax methodDecl, IMethodSymbol methodSymbol)
+    {
+        // Look for any parameter of type CommandEventArgs
+        IParameterSymbol? eParam = GetCommandEventArgsParameter (methodDecl, context.SemanticModel);
+
+        if (eParam == null)
+        {
+            return;
+        }
+
+        // Analyze method body
+        if (methodDecl.Body != null)
+        {
+            bool setsHandled = methodDecl.Body.Statements
+                                         .SelectMany (s => s.DescendantNodes ().OfType<AssignmentExpressionSyntax> ())
+                                         .Any (a => IsHandledAssignment (a, eParam, context));
+
+            if (!setsHandled)
+            {
+                var diag = Diagnostic.Create (_rule, methodDecl.Identifier.GetLocation ());
+                context.ReportDiagnostic (diag);
+            }
+        }
+    }
+
+    private static SeparatedSyntaxList<ParameterSyntax> GetParameters (AnonymousFunctionExpressionSyntax lambda)
+    {
+        switch (lambda)
+        {
+            case ParenthesizedLambdaExpressionSyntax p:
+                return p.ParameterList.Parameters;
+            case SimpleLambdaExpressionSyntax s:
+                // Simple lambda has a single parameter, wrap it in a list
+                return SyntaxFactory.SeparatedList (new [] { s.Parameter });
+            case AnonymousMethodExpressionSyntax a:
+                return a.ParameterList?.Parameters ?? default (SeparatedSyntaxList<ParameterSyntax>);
+            default:
+                return default (SeparatedSyntaxList<ParameterSyntax>);
+        }
+    }
+
+    private static bool IsAcceptingEvent (ExpressionSyntax expr, SyntaxNodeAnalysisContext context)
+    {
+        // Check if expr is b.Accepting or similar
+
+        // Get symbol info
+        SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo (expr);
+        ISymbol? symbol = symbolInfo.Symbol;
+
+        if (symbol == null)
+        {
+            return false;
+        }
+
+        // Accepting event symbol should be an event named "Accepting"
+        if (symbol.Kind == SymbolKind.Event && symbol.Name == "Accepting")
+        {
+            return true;
+        }
+
+        return false;
+    }
+
+    private static bool IsHandledAssignment (AssignmentExpressionSyntax assignment, IParameterSymbol eParamSymbol, SyntaxNodeAnalysisContext context)
+    {
+        // Check if left side is "e.Handled" and right side is "true"
+        // Left side should be MemberAccessExpression: e.Handled
+
+        if (assignment.Left is MemberAccessExpressionSyntax memberAccess)
+        {
+            // Check that member access expression is "e.Handled"
+            ISymbol? exprSymbol = context.SemanticModel.GetSymbolInfo (memberAccess.Expression).Symbol;
+
+            if (exprSymbol == null)
+            {
+                return false;
+            }
+
+            if (!SymbolEqualityComparer.Default.Equals (exprSymbol, eParamSymbol))
+            {
+                return false;
+            }
+
+            if (memberAccess.Name.Identifier.Text != "Handled")
+            {
+                return false;
+            }
+
+            // Check right side is true literal
+            if (assignment.Right is LiteralExpressionSyntax literal && literal.IsKind (SyntaxKind.TrueLiteralExpression))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}

+ 34 - 0
Terminal.Gui.Analyzers/TGUI001.md

@@ -0,0 +1,34 @@
+# TGUI001: Describe what your rule checks
+
+**Category:** Reliability  
+**Severity:** Warning  
+**Enabled by default:** Yes
+
+## Cause
+
+When registering an event handler for `Accepting`, you should set Handled to true, this prevents other subsequent Views from responding to the same input event.
+
+## Reason for rule
+
+If you do not do this then you may see unpredictable behaviour such as clicking a Button resulting in another `IsDefault` button in the View also firing.
+
+See:
+
+- https://github.com/gui-cs/Terminal.Gui/issues/3913
+- https://github.com/gui-cs/Terminal.Gui/issues/4170
+
+## How to fix violations
+
+Set Handled to `true` in your event handler
+
+### Examples
+
+```diff
+var b = new Button();
+b.Accepting += (s, e) =>
+{
+    // Do something
+
++    e.Handled = true;
+};
+```

+ 18 - 0
Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.0</TargetFramework>
+    <IncludeBuildOutput>false</IncludeBuildOutput>
+    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
+    <!-- Analyzer only, no executable -->
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
+
+  </ItemGroup>
+
+
+</Project>

+ 15 - 1
Terminal.Gui/Terminal.Gui.csproj

@@ -61,7 +61,11 @@
         <PackageReference Include="System.IO.Abstractions" />
         <PackageReference Include="Wcwidth" />
     </ItemGroup>
-
+	  <ItemGroup>
+    <ProjectReference Include="..\Terminal.Gui.Analyzers\Terminal.Gui.Analyzers.csproj"
+                      ReferenceOutputAssembly="false"
+                      OutputItemType="Analyzer" />
+	  </ItemGroup>
     <ItemGroup>
       <!-- Enable Nuget Source Link for github -->
       <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
@@ -155,6 +159,16 @@
         </VisualStudio>
     </ProjectExtensions>
 
+
+    <Target Name="CopyAnalyzersToPackage" AfterTargets="Build">
+	    <ItemGroup>
+		    <AnalyzerFiles Include="..\Terminal.Gui.Analyzers\bin\$(Configuration)\netstandard2.0\Terminal.Gui.Analyzers.dll" />
+	    </ItemGroup>
+    </Target>
+    <ItemGroup>
+	    <None Include="..\Terminal.Gui.Analyzers\bin\$(Configuration)\netstandard2.0\Terminal.Gui.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" />
+    </ItemGroup>
+
     <!--<Target Name="PreBuildCleanup" BeforeTargets="BeforeBuild" Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
         <Exec Command="rmdir /s /q &quot;$(UserProfile)\.nuget\packages\terminal.gui\2.0.0&quot;" Condition=" '$(OS)' == 'Windows_NT' " />
         <Exec Command="rm -rf ~/.nuget/packages/terminal.gui/2.0.0" Condition=" '$(OS)' != 'Windows_NT' " />

+ 12 - 0
Terminal.sln

@@ -75,6 +75,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTestingXun
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTestingXunit.Generator", "Tests\TerminalGuiFluentTestingXunit.Generator\TerminalGuiFluentTestingXunit.Generator.csproj", "{199F27D8-A905-4DDC-82CA-1FE1A90B1788}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers", "Terminal.Gui.Analyzers\Terminal.Gui.Analyzers.csproj", "{D1D68BA7-8476-448E-8BA3-927C15933119}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Tests", "Terminal.Gui.Analyzers.Tests\Terminal.Gui.Analyzers.Tests.csproj", "{8C643A64-2A77-4432-987A-2E72BD9708E3}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -141,6 +145,14 @@ Global
 		{199F27D8-A905-4DDC-82CA-1FE1A90B1788}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{199F27D8-A905-4DDC-82CA-1FE1A90B1788}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{199F27D8-A905-4DDC-82CA-1FE1A90B1788}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D1D68BA7-8476-448E-8BA3-927C15933119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D1D68BA7-8476-448E-8BA3-927C15933119}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D1D68BA7-8476-448E-8BA3-927C15933119}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D1D68BA7-8476-448E-8BA3-927C15933119}.Release|Any CPU.Build.0 = Release|Any CPU
+		{8C643A64-2A77-4432-987A-2E72BD9708E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{8C643A64-2A77-4432-987A-2E72BD9708E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{8C643A64-2A77-4432-987A-2E72BD9708E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{8C643A64-2A77-4432-987A-2E72BD9708E3}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 1 - 0
Tests/IntegrationTests/IntegrationTests.csproj

@@ -23,6 +23,7 @@
         <PackageReference Include="Microsoft.NET.Test.Sdk" />
         <PackageReference Include="xunit" />
         <PackageReference Include="xunit.runner.visualstudio" />
+        <PackageReference Include="Microsoft.Net.Compilers.Toolset" PrivateAssets="all" />
     </ItemGroup>
     <ItemGroup>
         <ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />

+ 0 - 1
Tests/TerminalGuiFluentTestingXunit.Generator/TerminalGuiFluentTestingXunit.Generator.csproj

@@ -4,7 +4,6 @@
 	<PropertyGroup>
 		<TargetFramework>netstandard2.0</TargetFramework>
 		<LangVersion>Latest</LangVersion>
-		<ImplicitUsings>enable</ImplicitUsings>
 		<Nullable>enable</Nullable>
 		<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
 	</PropertyGroup>

+ 1 - 0
Tests/TerminalGuiFluentTestingXunit/TerminalGuiFluentTestingXunit.csproj

@@ -14,6 +14,7 @@
     <PackageReference Include="Microsoft.NET.Test.Sdk" />
     <PackageReference Include="xunit" />
     <PackageReference Include="xunit.runner.visualstudio" />
+    <PackageReference Include="Microsoft.Net.Compilers.Toolset" PrivateAssets="all" />
   </ItemGroup>
 
 </Project>