Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
</Project>
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,6 +48,7 @@ You can customize the analyzer behavior by setting MSBuild properties in your pr
<PropertyGroup>
<GodotSharpAnalyzersDisableSignalConnectionLeak>true</GodotSharpAnalyzersDisableSignalConnectionLeak>
<GodotSharpAnalyzersDisableSignalLambdaConnectionLeak>true</GodotSharpAnalyzersDisableSignalLambdaConnectionLeak>
<GodotSharpAnalyzersDisableMissingPartialModifier>true</GodotSharpAnalyzersDisableMissingPartialModifier>
</PropertyGroup>
```

Expand Down
2 changes: 1 addition & 1 deletion docs/rules/GD0001.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [GitHub Issue #89116](https://github.com/godotengine/godot/issues/89116) - Signal memory leak discussion
105 changes: 105 additions & 0 deletions docs/rules/GD0004.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion src/GodotSharpAnalyzers/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
3 changes: 2 additions & 1 deletion src/GodotSharpAnalyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
GD0001a | Memory | Info | SignalLambdaConnectionLeak, [SignalConnectionLeakAnalyzer](Analyzers/Memory/SignalConnectionLeakAnalyzer.cs)
GD0004 | TypeSafety | Error | MissingPartialModifier, [PartialModifierAnalyzer](Analyzers/TypeSafety/PartialModifierAnalyzer.cs)
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,4 @@ private static bool IsSingletonClass(INamedTypeSymbol type)

return hasStaticInstance || hasAutoLoadAttribute;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,4 @@ private async Task<Document> AddSignalDisconnectionAsync(

return editor.GetChangedDocument();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string> 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<ClassDeclarationSyntax>()
.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<Document> 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);
}
}
2 changes: 1 addition & 1 deletion src/GodotSharpAnalyzers/DiagnosticCategories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ public static class DiagnosticCategories
public const string Performance = "Performance";
public const string TypeSafety = "TypeSafety";
public const string BestPractices = "BestPractices";
}
}
12 changes: 11 additions & 1 deletion src/GodotSharpAnalyzers/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

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"));
}
3 changes: 2 additions & 1 deletion src/GodotSharpAnalyzers/build/GodotSharpAnalyzers.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<!-- Allow users to disable specific rules -->
<NoWarn Condition="'$(GodotSharpAnalyzersDisableSignalConnectionLeak)' == 'true'">$(NoWarn);GD0001</NoWarn>
<NoWarn Condition="'$(GodotSharpAnalyzersDisableSignalLambdaConnectionLeak)' == 'true'">$(NoWarn);GD0001a</NoWarn>
<NoWarn Condition="'$(GodotSharpAnalyzersDisableMissingPartialModifier)' == 'true'">$(NoWarn);GD0004</NoWarn>

</PropertyGroup>

Expand All @@ -31,4 +32,4 @@
<Message Text="GodotSharpAnalyzers: For configuration options, see https://github.com/megacrit/GodotSharpAnalyzers" Importance="low" />
</Target>

</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,4 @@ public override void _Ready()
// Should not trigger for static method connections
await VerifyAnalyzer.VerifyAnalyzerAsync(test);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,4 @@ private void OnHealthChanged(int newHealth)
}
}.RunAsync();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,4 @@ private void OnHealthChanged(int newHealth) { }

await VerifyAnalyzer.VerifyAnalyzerAsync(test, expected);
}
}
}
Loading
Loading