Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: .NET

on:
push:
branches: [ "master", "development" ]
branches: [ "development" ]
pull_request:
branches: [ "master", "development" ]
branches: [ "master" ]

jobs:
build:
Expand Down
9 changes: 9 additions & 0 deletions src/UtilityVerse.Copy/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Release 0.3.0

### New Rules

Rule ID | Category | Severity | Notes
--------|------------------|----------|--------------------
UV001 | UtilityVerse.Copy | Error | MissingPartialErrorAnalyzer
UV002 | UtilityVerse.Copy | Error | NestedTypeErrorAnalyzer (DeepCopy)
UV003 | UtilityVerse.Copy | Error | NestedTypeErrorAnalyzer (ShallowCopy)
Empty file.
56 changes: 56 additions & 0 deletions src/UtilityVerse.Copy/Analyzers/MissingPartialErrorAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace UtilityVerse.Copy.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class MissingPartialErrorAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor MissingPartialModifierRule = new(
id: "UV001",
title: "Type must be marked as partial for DeepCopy/ShallowCopy generation",
messageFormat: "Type '{0}' must be marked as 'partial' to support DeepCopy/ShallowCopy generation",
category: "UtilityVerse.Copy",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "UtilityVerse.Copy requires all target types to be marked with 'partial' so the DeepCopy() or ShallowCopy() method can be injected."
);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [MissingPartialModifierRule];

public override void Initialize(AnalysisContext context)
{
// Configure analysis for syntax trees
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration, SyntaxKind.ClassDeclaration, SyntaxKind.StructDeclaration, SyntaxKind.RecordDeclaration, SyntaxKind.RecordStructDeclaration);
}

private static void AnalyzeTypeDeclaration(SyntaxNodeAnalysisContext context)
{
var typeDecl = (TypeDeclarationSyntax)context.Node;

// Skip if already marked partial
if (typeDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
return;

// Skip if not user-defined (e.g., external/metadata references)
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl);
if (symbol is null || symbol.DeclaringSyntaxReferences.Length == 0)
return;

// Optionally: check if it's used for DeepCopy somehow
// Example: skip types with [GeneratedCode] or [CompilerGenerated]
if (symbol.GetAttributes().Any(attr =>
attr.AttributeClass?.ToDisplayString() is "System.CodeDom.Compiler.GeneratedCodeAttribute" or "System.Runtime.CompilerServices.CompilerGeneratedAttribute"))
return;

var diagnostic = Diagnostic.Create(MissingPartialModifierRule, typeDecl.Identifier.GetLocation(), symbol.Name);
context.ReportDiagnostic(diagnostic);
}
}
122 changes: 122 additions & 0 deletions src/UtilityVerse.Copy/Analyzers/NestedTypeErrorAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace UtilityVerse.Copy.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class NestedTypeErrorAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor DeepRule = new(
id: "UV002",
title: "Nested type missing DeepCopy attribute",
messageFormat: "Property '{0}' of type '{1}' requires nested type '{2}' to have DeepCopy attribute or implement IDeepCopy",
category: "UtilityVerse.Copy",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor ShallowRule = new DiagnosticDescriptor(
id: "UV003",
title: "Nested type missing ShallowCopy attribute",
messageFormat: "Property '{0}' of type '{1}' requires nested type '{2}' to have ShallowCopy attribute or implement IShallowCopy",
category: "UtilityVerse.Copy",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
[DeepRule, ShallowRule];

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
}

private void AnalyzeNamedType(SymbolAnalysisContext context)
{
var namedType = (INamedTypeSymbol)context.Symbol;

// Only analyze public partial types (assuming generator requires partial)
if (namedType.DeclaredAccessibility != Accessibility.Public)
return;

if (!namedType.DeclaringSyntaxReferences.Any(syntaxRef =>
syntaxRef.GetSyntax() is TypeDeclarationSyntax tds &&
tds.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))))
return;

// Check if type opts into DeepCopy or ShallowCopy
bool hasDeepCopy = Helper.HasDeepCopyOptIn(namedType);
bool hasShallowCopy = Helper.HasShallowCopyOptIn(namedType);

if (!hasDeepCopy && !hasShallowCopy)
return; // type itself not opted in - no need to check nested types

// Get all properties including inherited
var properties = namedType.GetMembers().OfType<IPropertySymbol>().Where(p => !p.IsStatic);

foreach (var prop in properties)
{
if (prop.Type is not INamedTypeSymbol propType)
{
continue;
}
// Skip primitives and enums
if (Helper.IsTrulyPrimitive(propType))
continue;

if (propType.IsGenericType)
{
foreach (var typeArg in propType.TypeArguments.OfType<INamedTypeSymbol>())
{
if (Helper.IsTrulyPrimitive(typeArg))
continue;

if (hasDeepCopy && !Helper.HasDeepCopyOptIn(typeArg))
{
var diagnostic = Diagnostic.Create(
DeepRule,
prop.Locations.FirstOrDefault(),
prop.Name, namedType.Name, typeArg.Name);
context.ReportDiagnostic(diagnostic);
}

if (hasShallowCopy && !Helper.HasShallowCopyOptIn(typeArg))
{
var diagnostic = Diagnostic.Create(
ShallowRule,
prop.Locations.FirstOrDefault(),
prop.Name, namedType.Name, typeArg.Name);
context.ReportDiagnostic(diagnostic);
}
}
}
else
{
// Handle non-generic nested types
if (hasDeepCopy && !Helper.HasDeepCopyOptIn(propType))
{
var diagnostic = Diagnostic.Create(
DeepRule,
prop.Locations.FirstOrDefault(),
prop.Name, namedType.Name, propType.Name);
context.ReportDiagnostic(diagnostic);
}

if (hasShallowCopy && !Helper.HasShallowCopyOptIn(propType))
{
var diagnostic = Diagnostic.Create(
ShallowRule,
prop.Locations.FirstOrDefault(),
prop.Name, namedType.Name, propType.Name);
context.ReportDiagnostic(diagnostic);
}
}
}
}
}
7 changes: 3 additions & 4 deletions src/UtilityVerse.Copy/CopyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using UtilityVerse.Copy.Generators;
Expand Down Expand Up @@ -52,8 +51,8 @@ private static (INamedTypeSymbol Symbol, CopyMode Mode)? GetSemanticTarget(Gener
if (context.Node is not TypeDeclarationSyntax typeDecl)
return null;

if (!typeDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
return null;
// if (!typeDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
// return null;

var symbol = ModelExtensions.GetDeclaredSymbol(context.SemanticModel, typeDecl) as INamedTypeSymbol;
if (symbol is not { DeclaredAccessibility: Accessibility.Public })
Expand All @@ -71,7 +70,7 @@ private static (INamedTypeSymbol Symbol, CopyMode Mode)? GetSemanticTarget(Gener
return null;
}

private static string GenerateDeepCopy(INamedTypeSymbol typeSymbol) => DeepCopyGenerator.Generate(typeSymbol);
private static string? GenerateDeepCopy(INamedTypeSymbol typeSymbol) => DeepCopyGenerator.Generate(typeSymbol);

private static string? GenerateShallowCopy(INamedTypeSymbol typeSymbol) => ShallowCopyGenerator.Generate(typeSymbol);

Expand Down
27 changes: 16 additions & 11 deletions src/UtilityVerse.Copy/Generators/DeepCopyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ namespace UtilityVerse.Copy.Generators;

internal static class DeepCopyGenerator
{
internal static string Generate(INamedTypeSymbol typeSymbol)
internal static string? Generate(INamedTypeSymbol typeSymbol)
{
if (typeSymbol.IsAbstract) return string.Empty;
if (!Helper.IsPartial(typeSymbol)) return string.Empty;

var isGlobalNamespace = typeSymbol.ContainingNamespace.IsGlobalNamespace;
var namespaceName = isGlobalNamespace ? null : typeSymbol.ContainingNamespace.ToDisplayString();
Expand All @@ -36,16 +37,20 @@ internal static string Generate(INamedTypeSymbol typeSymbol)

sb.AppendLine("""

// <auto-generated>
// This code was generated by Copy.
// Author: Pritom Purkayasta
// DO NOT modify this file manually. Changes may be overwritten.
// This file contains auto-generated DeepCopy() implementations.
// </auto-generated>

using System;
using System.Linq;
using System.Collections.Generic;
// <auto-generated>
// This code was generated by Copy.
// Author: Pritom Purkayasta
// DO NOT modify this file manually. Changes may be overwritten.
// This file contains auto-generated DeepCopy() implementations.
// </auto-generated>

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Immutable;
using System.Collections.Concurrent;
using System.Collections.Frozen;

""");

Expand Down
54 changes: 27 additions & 27 deletions src/UtilityVerse.Copy/Generators/ShallowCopyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@

namespace UtilityVerse.Copy.Generators;

public static class ShallowCopyGenerator
internal static class ShallowCopyGenerator
{
public static string? Generate(INamedTypeSymbol typeSymbol)
internal static string? Generate(INamedTypeSymbol typeSymbol)
{
if (typeSymbol.IsAbstract)
return null;
if (typeSymbol.IsAbstract) return string.Empty;
if (!Helper.IsPartial(typeSymbol)) return string.Empty;

var isGlobalNamespace = typeSymbol.ContainingNamespace.IsGlobalNamespace;
var namespaceName = isGlobalNamespace ? null : typeSymbol.ContainingNamespace.ToDisplayString();
Expand Down Expand Up @@ -87,34 +87,34 @@ public static class ShallowCopyGenerator
break;

case INamedTypeSymbol named when named.IsGenericType:
{
var original = named.OriginalDefinition.ConstructUnboundGenericType().ToDisplayString();

if (Helper.IsGenericCollection(original, out var kind))
{
var itemType = named.TypeArguments.FirstOrDefault()?.ToDisplayString() ?? "var";
assignment = kind switch
{
"List" or "Enumerable" =>
$"this.{member.Name}?.ToList()",
var original = named.OriginalDefinition.ConstructUnboundGenericType().ToDisplayString();

"HashSet" =>
$"this.{member.Name} != null ? new HashSet<{itemType}>(this.{member.Name}) : null",

"Dictionary" =>
$"this.{member.Name}?.ToDictionary(x => x.Key, x => x.Value)",
if (Helper.IsGenericCollection(original, out var kind))
{
var itemType = named.TypeArguments.FirstOrDefault()?.ToDisplayString() ?? "var";
assignment = kind switch
{
"List" or "Enumerable" =>
$"this.{member.Name}?.ToList()",

"HashSet" =>
$"this.{member.Name} != null ? new HashSet<{itemType}>(this.{member.Name}) : null",

"Dictionary" =>
$"this.{member.Name}?.ToDictionary(x => x.Key, x => x.Value)",

_ => null
};
}
else if (original.StartsWith("System.Tuple"))
{
assignment = $"this.{member.Name}";
}

_ => null
};
}
else if (original.StartsWith("System.Tuple"))
{
assignment = $"this.{member.Name}";
break;
}

break;
}

case INamedTypeSymbol named when named.ToDisplayString().StartsWith("System.ValueTuple"):
assignment = $"this.{member.Name}";
break;
Expand Down
22 changes: 22 additions & 0 deletions src/UtilityVerse.Copy/Helper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
/// </summary>

using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace UtilityVerse.Copy;

Expand All @@ -30,6 +33,7 @@ SpecialType.System_UInt64 or
SpecialType.System_Single or
SpecialType.System_Double or
SpecialType.System_Char or
SpecialType.System_Enum or
SpecialType.System_String => true,
_ => false
};
Expand Down Expand Up @@ -94,4 +98,22 @@ internal static IEnumerable<IPropertySymbol> GetAllProperties(INamedTypeSymbol?
}
}

internal static bool IsPartial(INamedTypeSymbol typeSymbol)
{
return typeSymbol.DeclaringSyntaxReferences.Any(syntaxRef =>
syntaxRef.GetSyntax() is TypeDeclarationSyntax syntax &&
syntax.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)));
}

internal static bool HasDeepCopyOptIn(INamedTypeSymbol typeSymbol)
{
return typeSymbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "UtilityVerse.Copy.DeepCopy") ||
typeSymbol.AllInterfaces.Any(i => i.ToDisplayString() == "UtilityVerse.Copy.IDeepCopy");
}

internal static bool HasShallowCopyOptIn(INamedTypeSymbol typeSymbol)
{
return typeSymbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "UtilityVerse.Copy.ShallowCopy") ||
typeSymbol.AllInterfaces.Any(i => i.ToDisplayString() == "UtilityVerse.Copy.IShallowCopy");
}
}
Loading