From 42b6d3e9b08e60ab31ed833eaf20614fa99198e7 Mon Sep 17 00:00:00 2001 From: Danielle Jenkins Date: Tue, 22 Jul 2025 12:26:19 -0700 Subject: [PATCH] Add GD0004 missing partial modifier analyzer with code fix and comprehensive tests --- Directory.Build.props | 2 +- README.md | 2 + docs/rules/GD0001.md | 2 +- docs/rules/GD0004.md | 105 +++++ .../AnalyzerReleases.Shipped.md | 2 +- .../AnalyzerReleases.Unshipped.md | 3 +- .../Memory/SignalConnectionLeakAnalyzer.cs | 2 +- .../TypeSafety/PartialModifierAnalyzer.cs | 107 +++++ .../Memory/SignalConnectionLeakCodeFix.cs | 2 +- .../TypeSafety/PartialModifierCodeFix.cs | 67 +++ .../DiagnosticCategories.cs | 2 +- .../DiagnosticDescriptors.cs | 12 +- .../build/GodotSharpAnalyzers.props | 3 +- .../SignalConnectionLeakAnalyzerTests.cs | 2 +- .../SignalConnectionLeakConfigurationTests.cs | 2 +- .../SignalConnectionLeakEdgeCaseTests.cs | 2 +- .../PartialModifierAnalyzerTests.cs | 432 ++++++++++++++++++ .../Verifiers/CSharpAnalyzerVerifier.cs | 2 +- .../Verifiers/CSharpCodeFixVerifier.cs | 2 +- 19 files changed, 739 insertions(+), 14 deletions(-) create mode 100644 docs/rules/GD0004.md create mode 100644 src/GodotSharpAnalyzers/Analyzers/TypeSafety/PartialModifierAnalyzer.cs create mode 100644 src/GodotSharpAnalyzers/CodeFixes/TypeSafety/PartialModifierCodeFix.cs create mode 100644 tests/GodotSharpAnalyzers.Tests/TypeSafety/PartialModifierAnalyzerTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index b3c6bb8..67b1383 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,4 +6,4 @@ AllEnabledByDefault true - \ No newline at end of file + diff --git a/README.md b/README.md index d97c056..4123138 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ dotnet add package GodotSharpAnalyzers - [x] **GD0001** (Memory): Signal connections should be disconnected to prevent memory leaks - [x] **GD0001a** (Memory): Signal connected to lambda expression (informational) +- [x] **GD0004** (TypeSafety): Classes using Godot source generators must be declared as partial ## Configuration @@ -47,6 +48,7 @@ You can customize the analyzer behavior by setting MSBuild properties in your pr true true + true ``` diff --git a/docs/rules/GD0001.md b/docs/rules/GD0001.md index fbdc2fd..07ecd48 100644 --- a/docs/rules/GD0001.md +++ b/docs/rules/GD0001.md @@ -134,4 +134,4 @@ You can configure this rule's behavior in your project file: ## References - [Godot 4 C# Signal Documentation](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_signals.html) -- [GitHub Issue #89116](https://github.com/godotengine/godot/issues/89116) - Signal memory leak discussion \ No newline at end of file +- [GitHub Issue #89116](https://github.com/godotengine/godot/issues/89116) - Signal memory leak discussion diff --git a/docs/rules/GD0004.md b/docs/rules/GD0004.md new file mode 100644 index 0000000..c949c22 --- /dev/null +++ b/docs/rules/GD0004.md @@ -0,0 +1,105 @@ +# GD0004: Missing partial modifier + +**Rule ID**: GD0004 +**Category**: TypeSafety +**Severity**: Error + +## Cause + +A class that inherits from a Godot type and uses Godot source generators (signals, exports) is missing the `partial` modifier. + +## Rule Description + +Classes that use Godot's source generators must be declared as `partial` to allow the generated code to be merged with the class definition. This includes classes that: + +- Define signals using `[Signal]` attribute on delegates +- Use `[Export]` attribute on properties or fields +- Use other Godot source generation features + +## How to Fix Violations + +Add the `partial` modifier to the class declaration. + +## Examples + +### Violates GD0004 + +```csharp +using Godot; + +public class Player : Node // GD0004: Missing partial modifier +{ + [Signal] + public delegate void HealthChangedEventHandler(int newHealth); + + [Export] + public int MaxHealth { get; set; } = 100; +} +``` + +```csharp +using Godot; + +public class Weapon : Node2D // GD0004: Missing partial modifier +{ + [Export] + public float damage = 25.0f; +} +``` + +### Does Not Violate GD0004 + +```csharp +using Godot; + +public partial class Player : Node // Correct: partial modifier present +{ + [Signal] + public delegate void HealthChangedEventHandler(int newHealth); + + [Export] + public int MaxHealth { get; set; } = 100; +} +``` + +```csharp +using Godot; + +public class SimpleNode : Node // Correct: no source generators used +{ + private int _health = 100; + + public override void _Ready() + { + GD.Print("Ready!"); + } +} +``` + +```csharp +public class RegularClass // Correct: doesn't inherit from Godot type +{ + public delegate void SomeEventHandler(); + public int SomeProperty { get; set; } +} +``` + +## When to Suppress Warnings + +This rule should generally not be suppressed, as missing the `partial` modifier will cause compilation errors when using Godot source generators. + +## Configuration + +This rule can be configured in your `.editorconfig` file: + +```ini +# Disable GD0004 (not recommended) +dotnet_diagnostic.GD0004.severity = none + +# Make GD0004 suggestions instead of errors (not recommended) +dotnet_diagnostic.GD0004.severity = suggestion +``` + +## See Also + +- [Godot C# Source Generators Documentation](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_features.html#source-generators) diff --git a/src/GodotSharpAnalyzers/AnalyzerReleases.Shipped.md b/src/GodotSharpAnalyzers/AnalyzerReleases.Shipped.md index 339d370..f50bb1f 100644 --- a/src/GodotSharpAnalyzers/AnalyzerReleases.Shipped.md +++ b/src/GodotSharpAnalyzers/AnalyzerReleases.Shipped.md @@ -1,2 +1,2 @@ ; Shipped analyzer releases -; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md \ No newline at end of file +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/GodotSharpAnalyzers/AnalyzerReleases.Unshipped.md b/src/GodotSharpAnalyzers/AnalyzerReleases.Unshipped.md index 8f735f8..a55e489 100644 --- a/src/GodotSharpAnalyzers/AnalyzerReleases.Unshipped.md +++ b/src/GodotSharpAnalyzers/AnalyzerReleases.Unshipped.md @@ -6,4 +6,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|-------------------- GD0001 | Memory | Warning | SignalConnectionLeak, [SignalConnectionLeakAnalyzer](Analyzers/Memory/SignalConnectionLeakAnalyzer.cs) -GD0001a | Memory | Info | SignalLambdaConnectionLeak, [SignalConnectionLeakAnalyzer](Analyzers/Memory/SignalConnectionLeakAnalyzer.cs) \ No newline at end of file +GD0001a | Memory | Info | SignalLambdaConnectionLeak, [SignalConnectionLeakAnalyzer](Analyzers/Memory/SignalConnectionLeakAnalyzer.cs) +GD0004 | TypeSafety | Error | MissingPartialModifier, [PartialModifierAnalyzer](Analyzers/TypeSafety/PartialModifierAnalyzer.cs) diff --git a/src/GodotSharpAnalyzers/Analyzers/Memory/SignalConnectionLeakAnalyzer.cs b/src/GodotSharpAnalyzers/Analyzers/Memory/SignalConnectionLeakAnalyzer.cs index 54fdf11..a4c3b3c 100644 --- a/src/GodotSharpAnalyzers/Analyzers/Memory/SignalConnectionLeakAnalyzer.cs +++ b/src/GodotSharpAnalyzers/Analyzers/Memory/SignalConnectionLeakAnalyzer.cs @@ -234,4 +234,4 @@ private static bool IsSingletonClass(INamedTypeSymbol type) return hasStaticInstance || hasAutoLoadAttribute; } -} \ No newline at end of file +} diff --git a/src/GodotSharpAnalyzers/Analyzers/TypeSafety/PartialModifierAnalyzer.cs b/src/GodotSharpAnalyzers/Analyzers/TypeSafety/PartialModifierAnalyzer.cs new file mode 100644 index 0000000..7248c2e --- /dev/null +++ b/src/GodotSharpAnalyzers/Analyzers/TypeSafety/PartialModifierAnalyzer.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace GodotSharpAnalyzers.Analyzers.TypeSafety; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class PartialModifierAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(DiagnosticDescriptors.MissingPartialModifier); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeClass, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeClass(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + + // Check if the class is already partial + if (classDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + return; + + var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration); + if (classSymbol == null) + return; + + // Check if it inherits from a Godot type + if (!InheritsFromGodotType(classSymbol)) + return; + + // Check if the class uses Godot source generators + if (UsesGodotSourceGenerators(classDeclaration, classSymbol)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.MissingPartialModifier, + classDeclaration.Identifier.GetLocation(), + classSymbol.Name); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool InheritsFromGodotType(INamedTypeSymbol type) + { + var current = type.BaseType; + while (current != null) + { + if (current.ContainingNamespace?.ToDisplayString() == "Godot") + return true; + current = current.BaseType; + } + return false; + } + + private static bool UsesGodotSourceGenerators(ClassDeclarationSyntax classDeclaration, INamedTypeSymbol classSymbol) + { + // Check for [Signal] attribute on delegates + foreach (var member in classDeclaration.Members) + { + if (member is DelegateDeclarationSyntax delegateDecl) + { + if (HasSignalAttribute(delegateDecl)) + return true; + } + } + + // Check for [Export] attribute on properties/fields + var members = classSymbol.GetMembers(); + foreach (var member in members) + { + if (member is IPropertySymbol || member is IFieldSymbol) + { + if (HasExportAttribute(member)) + return true; + } + } + + return false; + } + + private static bool HasSignalAttribute(DelegateDeclarationSyntax delegateDeclaration) + { + return delegateDeclaration.AttributeLists + .SelectMany(list => list.Attributes) + .Any(attr => + { + var name = attr.Name.ToString(); + return name == "Signal" || name == "SignalAttribute"; + }); + } + + private static bool HasExportAttribute(ISymbol member) + { + return member.GetAttributes() + .Any(attr => attr.AttributeClass?.Name == "ExportAttribute" && + attr.AttributeClass.ContainingNamespace?.Name == "Godot"); + } +} diff --git a/src/GodotSharpAnalyzers/CodeFixes/Memory/SignalConnectionLeakCodeFix.cs b/src/GodotSharpAnalyzers/CodeFixes/Memory/SignalConnectionLeakCodeFix.cs index 86f1672..3c183bf 100644 --- a/src/GodotSharpAnalyzers/CodeFixes/Memory/SignalConnectionLeakCodeFix.cs +++ b/src/GodotSharpAnalyzers/CodeFixes/Memory/SignalConnectionLeakCodeFix.cs @@ -103,4 +103,4 @@ private async Task AddSignalDisconnectionAsync( return editor.GetChangedDocument(); } -} \ No newline at end of file +} diff --git a/src/GodotSharpAnalyzers/CodeFixes/TypeSafety/PartialModifierCodeFix.cs b/src/GodotSharpAnalyzers/CodeFixes/TypeSafety/PartialModifierCodeFix.cs new file mode 100644 index 0000000..97bebe6 --- /dev/null +++ b/src/GodotSharpAnalyzers/CodeFixes/TypeSafety/PartialModifierCodeFix.cs @@ -0,0 +1,67 @@ +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 GodotSharpAnalyzers.CodeFixes.TypeSafety; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PartialModifierCodeFix)), Shared] +public class PartialModifierCodeFix : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds + => ImmutableArray.Create(DiagnosticDescriptors.MissingPartialModifier.Id); + + public override FixAllProvider GetFixAllProvider() + => WellKnownFixAllProviders.BatchFixer; + + public 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; + + // Find the class declaration + var classDeclaration = root.FindToken(diagnosticSpan.Start) + .Parent?.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (classDeclaration == null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Add partial modifier", + createChangedDocument: c => AddPartialModifierAsync(context.Document, classDeclaration, c), + equivalenceKey: "AddPartialModifier"), + diagnostic); + } + + private async Task AddPartialModifierAsync( + Document document, + ClassDeclarationSyntax classDeclaration, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + return document; + + // Create new modifiers with partial + var newModifiers = classDeclaration.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.PartialKeyword)); + + // Replace the class declaration with the new one + var newClassDeclaration = classDeclaration.WithModifiers(newModifiers); + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/GodotSharpAnalyzers/DiagnosticCategories.cs b/src/GodotSharpAnalyzers/DiagnosticCategories.cs index cefbb30..e8f2a8d 100644 --- a/src/GodotSharpAnalyzers/DiagnosticCategories.cs +++ b/src/GodotSharpAnalyzers/DiagnosticCategories.cs @@ -7,4 +7,4 @@ public static class DiagnosticCategories public const string Performance = "Performance"; public const string TypeSafety = "TypeSafety"; public const string BestPractices = "BestPractices"; -} \ No newline at end of file +} diff --git a/src/GodotSharpAnalyzers/DiagnosticDescriptors.cs b/src/GodotSharpAnalyzers/DiagnosticDescriptors.cs index 02fc969..4f10727 100644 --- a/src/GodotSharpAnalyzers/DiagnosticDescriptors.cs +++ b/src/GodotSharpAnalyzers/DiagnosticDescriptors.cs @@ -25,4 +25,14 @@ public static class DiagnosticDescriptors isEnabledByDefault: true, description: "Lambda expressions connected to signals cannot be disconnected easily. Consider using named methods for signals that need disconnection.", helpLinkUri: string.Format(HelpLinkFormat, "GD0001")); -} \ No newline at end of file + + public static readonly DiagnosticDescriptor MissingPartialModifier = new( + id: "GD0004", + title: "Missing partial modifier", + messageFormat: "Class '{0}' uses Godot source generators and must be declared as partial", + category: DiagnosticCategories.TypeSafety, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Classes using Godot source generators require the partial modifier.", + helpLinkUri: string.Format(HelpLinkFormat, "GD0004")); +} diff --git a/src/GodotSharpAnalyzers/build/GodotSharpAnalyzers.props b/src/GodotSharpAnalyzers/build/GodotSharpAnalyzers.props index 6227eac..fc37d72 100644 --- a/src/GodotSharpAnalyzers/build/GodotSharpAnalyzers.props +++ b/src/GodotSharpAnalyzers/build/GodotSharpAnalyzers.props @@ -15,6 +15,7 @@ $(NoWarn);GD0001 $(NoWarn);GD0001a + $(NoWarn);GD0004 @@ -31,4 +32,4 @@ - \ No newline at end of file + diff --git a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakAnalyzerTests.cs b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakAnalyzerTests.cs index 188c2f3..666b00a 100644 --- a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakAnalyzerTests.cs +++ b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakAnalyzerTests.cs @@ -194,4 +194,4 @@ public override void _Ready() // Should not trigger for static method connections await VerifyAnalyzer.VerifyAnalyzerAsync(test); } -} \ No newline at end of file +} diff --git a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakConfigurationTests.cs b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakConfigurationTests.cs index c92453f..d62ccef 100644 --- a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakConfigurationTests.cs +++ b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakConfigurationTests.cs @@ -328,4 +328,4 @@ private void OnHealthChanged(int newHealth) } }.RunAsync(); } -} \ No newline at end of file +} diff --git a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakEdgeCaseTests.cs b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakEdgeCaseTests.cs index fd803cc..7edf62c 100644 --- a/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakEdgeCaseTests.cs +++ b/tests/GodotSharpAnalyzers.Tests/Memory/SignalConnectionLeakEdgeCaseTests.cs @@ -299,4 +299,4 @@ private void OnHealthChanged(int newHealth) { } await VerifyAnalyzer.VerifyAnalyzerAsync(test, expected); } -} \ No newline at end of file +} diff --git a/tests/GodotSharpAnalyzers.Tests/TypeSafety/PartialModifierAnalyzerTests.cs b/tests/GodotSharpAnalyzers.Tests/TypeSafety/PartialModifierAnalyzerTests.cs new file mode 100644 index 0000000..90e5969 --- /dev/null +++ b/tests/GodotSharpAnalyzers.Tests/TypeSafety/PartialModifierAnalyzerTests.cs @@ -0,0 +1,432 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using VerifyCS = GodotSharpAnalyzers.Tests.Verifiers.CSharpCodeFixVerifier< + GodotSharpAnalyzers.Analyzers.TypeSafety.PartialModifierAnalyzer, + GodotSharpAnalyzers.CodeFixes.TypeSafety.PartialModifierCodeFix>; +using VerifyAnalyzer = GodotSharpAnalyzers.Tests.Verifiers.CSharpAnalyzerVerifier< + GodotSharpAnalyzers.Analyzers.TypeSafety.PartialModifierAnalyzer>; + +namespace GodotSharpAnalyzers.Tests.TypeSafety; + +public class PartialModifierAnalyzerTests +{ + [Fact] + public async Task TestClassWithSignalMissingPartial() + { + var test = @" +using Godot; + +public class {|#0:Player|} : Node +{ + [Signal] + public delegate void HealthChangedEventHandler(int newHealth); +}"; + + var fixedTest = @" +using Godot; + +public partial class Player : Node +{ + [Signal] + public delegate void HealthChangedEventHandler(int newHealth); +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("Player"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestClassWithExportMissingPartial() + { + var test = @" +using Godot; + +public class {|#0:Enemy|} : Node +{ + [Export] + public int Health { get; set; } = 100; +}"; + + var fixedTest = @" +using Godot; + +public partial class Enemy : Node +{ + [Export] + public int Health { get; set; } = 100; +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("Enemy"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestPartialClassNoWarning() + { + var test = @" +using Godot; + +public partial class Player : Node +{ + [Signal] + public delegate void HealthChangedEventHandler(int newHealth); + + [Export] + public int MaxHealth { get; set; } = 100; +}"; + + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestNonGodotClassNoWarning() + { + var test = @" +public class RegularClass +{ + public delegate void SomeEventHandler(); + + public int SomeProperty { get; set; } +}"; + + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestGodotClassWithoutSourceGeneratorsNoWarning() + { + var test = @" +using Godot; + +public class Player : Node +{ + // No signals or exports + private int _health = 100; + + public override void _Ready() + { + GD.Print(""Player ready""); + } +}"; + + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestClassWithExportFieldMissingPartial() + { + var test = @" +using Godot; + +public class {|#0:Weapon|} : Node +{ + [Export] + public float damage = 50.0f; +}"; + + var fixedTest = @" +using Godot; + +public partial class Weapon : Node +{ + [Export] + public float damage = 50.0f; +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("Weapon"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestClassWithMultipleModifiersPartialAdded() + { + var test = @" +using Godot; + +public abstract class {|#0:BasePlayer|} : Node +{ + [Signal] + public delegate void PlayerDiedEventHandler(); +}"; + + var fixedTest = @" +using Godot; + +public abstract partial class BasePlayer : Node +{ + [Signal] + public delegate void PlayerDiedEventHandler(); +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("BasePlayer"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestInheritanceFromGodotResource() + { + var test = @" +using Godot; + +public class {|#0:CustomResource|} : Resource +{ + [Export] + public string ResourceName { get; set; } = """"; +}"; + + var fixedTest = @" +using Godot; + +public partial class CustomResource : Resource +{ + [Export] + public string ResourceName { get; set; } = """"; +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("CustomResource"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestNestedClassWithGodotInheritance() + { + var test = @" +using Godot; + +public class OuterClass +{ + public class {|#0:NestedPlayer|} : Node + { + [Signal] + public delegate void NestedSignalEventHandler(); + } +}"; + + var fixedTest = @" +using Godot; + +public class OuterClass +{ + public partial class NestedPlayer : Node + { + [Signal] + public delegate void NestedSignalEventHandler(); + } +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("NestedPlayer"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestFalsePositiveWithCustomGodotNamespace() + { + var test = @" +namespace MyProject.Godot +{ + public class Node + { + // Custom Node class in custom Godot namespace + } +} + +namespace MyProject +{ + using MyProject.Godot; + + public class Player : Node // Should not trigger - not the real Godot.Node + { + public delegate void SomeEventHandler(); + + public int SomeProperty { get; set; } + } +}"; + + // Should not produce any diagnostics + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestDeepInheritanceFromGodotType() + { + var test = @" +using Godot; + +public class BaseCharacter : Node +{ + protected int _health; +} + +public class {|#0:Player|} : BaseCharacter +{ + [Export] + public int MaxHealth { get; set; } = 100; +}"; + + var fixedTest = @" +using Godot; + +public class BaseCharacter : Node +{ + protected int _health; +} + +public partial class Player : BaseCharacter +{ + [Export] + public int MaxHealth { get; set; } = 100; +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("Player"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + [Fact] + public async Task TestClassWithBothSignalAndExport() + { + var test = @" +using Godot; + +public class {|#0:ComplexNode|} : Node +{ + [Signal] + public delegate void StateChangedEventHandler(string newState); + + [Export] + public float Speed { get; set; } = 100.0f; + + [Export] + private int _maxItems = 10; +}"; + + var fixedTest = @" +using Godot; + +public partial class ComplexNode : Node +{ + [Signal] + public delegate void StateChangedEventHandler(string newState); + + [Export] + public float Speed { get; set; } = 100.0f; + + [Export] + private int _maxItems = 10; +}"; + + var expected = VerifyCS.Diagnostic(DiagnosticDescriptors.MissingPartialModifier) + .WithLocation(0) + .WithArguments("ComplexNode"); + + await VerifyCS.VerifyCodeFixAsync(test, expected, fixedTest); + } + + + [Fact] + public async Task TestSimpleNodeInheritanceNoWarning() + { + var test = @" +using Godot; + +public class SimplePlayer : Node // Should this trigger? Let's test! +{ + private int _health = 100; + + public override void _Ready() + { + GD.Print(""Player ready with health: "" + _health); + } + + public override void _Process(double delta) + { + // Some game logic + } +}"; + + // If this passes, then simple inheritance doesn't require partial + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestVirtualMethodOverridesOnlyNoWarning() + { + var test = @" +using Godot; + +public class GameManager : Node // Should this trigger? Let's test! +{ + public override void _Ready() + { + GD.Print(""Game Manager Ready""); + } + + public override void _Process(double delta) + { + // Some simple game logic + GD.Print(""Processing: "" + delta); + } +}"; + + // If this passes, then virtual method overrides don't require partial + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestRegularCSharpClassWithSignalAttributeNoWarning() + { + var test = @" +using Godot; + +public class RegularClass // NOT inheriting from Godot - should NOT trigger! +{ + [Signal] + public delegate void SomeEventEventHandler(); + + public int SomeProperty { get; set; } +}"; + + // Should not produce any diagnostics - regular classes don't need partial + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestRegularCSharpClassWithExportAttributeNoWarning() + { + var test = @" +using Godot; + +public class DataClass // NOT inheriting from Godot - should NOT trigger! +{ + [Export] + public string Name { get; set; } = """"; + + [Export] + public int Value = 42; +}"; + + // Should not produce any diagnostics - regular classes don't need partial + await VerifyAnalyzer.VerifyAnalyzerAsync(test); + } +} diff --git a/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs index f33d2a1..0b75e80 100644 --- a/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs +++ b/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -396,4 +396,4 @@ private static ImmutableDictionary GetNullableWarnings builder.Add("CS8714", ReportDiagnostic.Error); return builder.ToImmutable(); } -} \ No newline at end of file +} diff --git a/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs index 5789be0..e064631 100644 --- a/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs +++ b/tests/GodotSharpAnalyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -323,4 +323,4 @@ private static string GetTestNodeWithSignal() { return Test.GetTestNodeWithSignal(); } -} \ No newline at end of file +}