diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5e49605..53801b2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,13 @@
+
+
+
+
+
+
+
diff --git a/Equatable.Generator.slnx b/Equatable.Generator.slnx
index bfdee4c..21b0e6c 100644
--- a/Equatable.Generator.slnx
+++ b/Equatable.Generator.slnx
@@ -12,5 +12,9 @@
+
+
+
+
diff --git a/README.md b/README.md
index 7065546..d92fdf3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Equatable.Generator
-Source generator for `Equals` and `GetHashCode` with attribute based control of equality implementation
+Source generator for `Equals` and `GetHashCode` with attribute-based control of equality implementation.
[](https://github.com/loresoft/Equatable.Generator/actions/workflows/dotnet.yml)
@@ -8,98 +8,613 @@ Source generator for `Equals` and `GetHashCode` with attribute based control of
[](https://www.nuget.org/packages/Equatable.Generator/)
-## Features
+## Overview
-- Override `Equals` and `GetHashCode`
-- Implement `IEquatable`
-- Support `class`, `record` and `struct` types
-- Support `EqualityComparer` per property via attribute
-- Attribute based control of equality implementation.
-- Attribute comparers supported: String, Sequence, Dictionary, HashSet, Reference, and Custom
-- No runtime dependencies. Library is compile time dependence only.
+By default, C# classes inherit `Equals` from `object`, which compares object references rather than values:
-### Usage
+```csharp
+var a = new Product { Id = 1, Name = "Widget" };
+var b = new Product { Id = 1, Name = "Widget" };
+Console.WriteLine(a == b); // false — distinct instances, even though all values are identical
+```
+
+Structural equality — comparing objects by their field values rather than by identity — is what most application code requires. C# `record` types partially address this by generating `Equals` and `GetHashCode` from all properties automatically. However, the generated equality is only structurally correct for value types and `string`. **Reference-type properties and collection properties still fall back to reference equality**, which is silent and easily overlooked:
+
+```csharp
+record Order(int Id, List Tags);
+
+var a = new Order(1, new List { "vip" });
+var b = new Order(1, new List { "vip" });
+Console.WriteLine(a == b); // false — List uses reference equality inside the record
+```
+
+Every reference type in the object graph requires its own correct `IEquatable` implementation. That obligation compounds rapidly in real domain models, and a missing implementation anywhere produces incorrect equality silently, with no compile-time indication.
+
+> `string` properties behave correctly inside records because `string` implements `IEquatable` with value semantics. This is not special treatment by the record infrastructure — any reference type that does not implement `IEquatable` (such as `List`, `Dictionary`, or a plain class) will silently use reference equality.
-#### Add package
+The standard remedy is a manual `IEquatable` implementation, but this introduces a different category of problems. A hand-written `Equals` method enumerates every property explicitly, and in any non-trivial codebase the following failure modes are common in practice:
-Add the nuget package to your projects.
+- **Omitted property** — a field is absent from `Equals`, causing equality to silently disregard data that should influence the result.
+- **Unintended inclusion** — a computed or infrastructure property is included, producing incorrect comparisons.
+- **Stale implementation** — a new property is added to the type but not reflected in `Equals` or `GetHashCode`.
+- **Hash contract violation** — `Equals` and `GetHashCode` are maintained independently and diverge over time. The contract that equal objects must produce equal hash codes is not enforced by the compiler; a violation causes silent corruption when instances are used as dictionary keys or hash set members.
-`dotnet add package Equatable.Generator`
+These defects are difficult to detect in code review because the missing or extraneous property is buried inside a method body rather than visible at the property declaration.
-Prevent including Equatable.Generator as a dependency
+This library addresses the problem through a declarative, annotation-driven approach. Equality intent is expressed directly on each property at its declaration site. The source generator produces correct `Equals` and `GetHashCode` implementations at compile time, and the accompanying Roslyn analyzer emits build warnings when an annotation is absent or incorrectly applied. There is no separate method body to maintain or keep consistent.
+
+```csharp
+[Equatable]
+public partial class Product
+{
+ public int Id { get; set; }
+
+ [StringEquality(StringComparison.OrdinalIgnoreCase)]
+ public string? Name { get; set; }
+
+ [IgnoreEquality] // excluded — computed, not part of identity
+ public decimal TaxAmount { get; set; }
+}
+```
+
+## Packages
+
+| Package | What it does |
+|---|---|
+| `Equatable.Generator` | Generates equality for `[Equatable]` classes, records, structs, and readonly structs. Includes all collection attributes. |
+| `Equatable.Generator.DataContract` | Adapter — includes `[DataMember(Order = n)]`, explicitly excludes `[IgnoreDataMember]`, silently skips unannotated properties (EQ0022 warns) |
+| `Equatable.Generator.MessagePack` | Adapter — includes `[Key(n)]`, explicitly excludes `[IgnoreMember]`, silently skips unannotated properties (EQ0023 warns) |
+| `Equatable.Comparers` | Ships the runtime comparers used by the generated code |
+
+## Getting started
```xml
+
```
-### Requirements
+When using the adapter packages, add the corresponding adapter generator the same way:
-This library requires:
+```xml
+
+
-- Target framework .NET Standard 2.0 or greater
-- Project C# `LangVersion` 8.0 or higher
+
+
+```
-### Equatable Attributes
+`PrivateAssets="all"` is optional on all generator packages — it prevents the compile-time-only package from flowing to consumers of your library. `Equatable.Comparers` is the runtime package the generated code calls into and must be a real dependency.
-Place `[Equatable]` attribute on a `class`, `record` or `struct`. The source generator will create a partial with overrides for `Equals` and `GetHashCode` for all public properties.
+Mark your class as `partial` and add `[Equatable]`:
-- `[Equatable]` Marks the class to generate overrides for `Equals` and `GetHashCode`
+```csharp
+[Equatable]
+public partial class Product
+{
+ public int Id { get; set; }
+ public string? Name { get; set; }
+ public decimal Price { get; set; }
+}
+```
- The default comparer used in the implementation of `Equals` and `GetHashCode` is `EqualityComparer.Default`. Customize the comparer used with the following attributes.
+The generator produces `Equals` and `GetHashCode` implementations covering every public property. Supported type declarations: `class`, `record`, `struct`, and `readonly struct`.
-- `[IgnoreEquality]` Ignore property in `Equals` and `GetHashCode` implementations
-- `[StringEquality]` Use specified `StringComparer` when comparing strings
-- `[SequenceEquality]` Use `Enumerable.SequenceEqual` to determine whether enumerables are equal
-- `[DictionaryEquality]` Use to determine if dictionaries are equal
-- `[HashSetEquality]` Use `ISet.SetEquals` to determine whether enumerables are equal
-- `[ReferenceEquality]` Use `Object.ReferenceEquals` to determines whether instances are the same instance
-- `[EqualityComparer]` Use the specified `EqualityComparer`
+## All attributes at a glance
-### Example Usage
+### `Equatable.Generator` — class-level trigger
-Example of using the attributes to customize the source generation of `Equals` and `GetHashCode`
+| Attribute | What it does |
+|---|---|
+| `[Equatable]` | Triggers generation; includes all public properties |
-``` c#
-[Equatable]
-public partial class UserImport
+### `Equatable.Generator` — property-level equality control
+
+These attributes live in `Equatable.Attributes` and control how each property is compared.
+
+| Attribute | What it generates | Default for |
+|---|---|---|
+| `[IgnoreEquality]` | Exclude this property from equality | — |
+| `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — |
+| `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom `IEqualityComparer` | — |
+| `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — |
+| `[SequenceEquality]` | `SequenceEqualityComparer` — order-sensitive element comparison | `List`, `T[]`, `T[,]`, `T[,,]` |
+| `[HashSetEquality]` | `HashSetEqualityComparer` — order-insensitive element comparison | `HashSet` |
+| `[DictionaryEquality]` | `DictionaryEqualityComparer` — key-value equality, insertion order irrelevant | `Dictionary` |
+
+### `Equatable.Generator.DataContract` — class-level trigger
+
+| Attribute | Package | What it does |
+|---|---|---|
+| `[DataContractEquatable]` | `Equatable.Generator.DataContract` | Triggers generation; reads `[DataMember]` to select properties |
+
+Property selection is driven by `System.Runtime.Serialization` attributes, which come from the BCL or the serialisation library already in use:
+
+| Attribute | Source | Effect |
+|---|---|---|
+| `[DataMember]` | `System.Runtime.Serialization` | Include this property in equality |
+| `[IgnoreDataMember]` | `System.Runtime.Serialization` | Explicitly exclude this property |
+| `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property |
+
+All property-level equality attributes from `Equatable.Generator` (`[SequenceEquality]`, `[DictionaryEquality]`, `[StringEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) are accepted as overrides on `[DataMember]` properties. Collection comparers are inferred automatically from the property type when no override is present.
+
+### `Equatable.Generator.MessagePack` — class-level trigger
+
+| Attribute | Package | What it does |
+|---|---|---|
+| `[MessagePackEquatable]` | `Equatable.Generator.MessagePack` | Triggers generation; reads `[Key(n)]` to select properties |
+
+Property selection is driven by MessagePack attributes from the `MessagePack` package already in use:
+
+| Attribute | Source | Effect |
+|---|---|---|
+| `[Key(n)]` | `MessagePack` | Include this property in equality |
+| `[IgnoreMember]` | `MessagePack` | Explicitly exclude this property |
+| `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property |
+
+All property-level equality attributes from `Equatable.Generator` are accepted as overrides on `[Key(n)]` properties. Collection comparers are inferred automatically when no override is present.
+
+## Adapter generators
+
+Use `[DataContractEquatable]` or `[MessagePackEquatable]` when the type is already annotated for serialisation. The adapter derives property selection from the existing serialisation attributes, requiring no duplication of intent.
+
+### Property selection
+
+| Adapter | Included | Explicitly excluded | Silently skipped → EQ0022/EQ0023 |
+|---|---|---|---|
+| `[DataContractEquatable]` | `[DataMember]` | `[IgnoreDataMember]` or `[IgnoreEquality]` | all other public properties |
+| `[MessagePackEquatable]` | `[Key(n)]` | `[IgnoreMember]` or `[IgnoreEquality]` | all other public properties |
+
+Public properties that carry no annotation are silently excluded from equality. When that omission is unintentional, the build emits EQ0022 or EQ0023. Resolve the warning by adding the appropriate inclusion or explicit exclusion attribute.
+
+### Comparer inference
+
+Adapter generators infer the correct collection comparer from the property type automatically. Properties annotated with `[DataMember]` or `[Key(n)]` do not require an explicit `[SequenceEquality]`, `[DictionaryEquality]`, or `[HashSetEquality]`. The same defaults apply as for `[Equatable]`: `List` / `T[]` → `SequenceEquality`; `HashSet` → `HashSetEquality`; `Dictionary` → `DictionaryEquality`.
+
+```csharp
+[DataContract]
+[DataContractEquatable]
+public partial class EventContract
+{
+ [DataMember(Order = 0)] public int EventId { get; set; }
+
+ // List → SequenceEqualityComparer inferred — no attribute required
+ [DataMember(Order = 1)] public List? Tags { get; set; }
+
+ // Dictionary → DictionaryEqualityComparer inferred — no attribute required
+ [DataMember(Order = 2)] public Dictionary? Scores { get; set; }
+
+ [IgnoreDataMember]
+ public DateTime LastSeen { get; set; } // excluded from equality
+}
+```
+
+```csharp
+[MessagePackObject]
+[MessagePackEquatable]
+public partial class LiveScore
{
+ [Key(0)] public int MatchId { get; set; }
+ [Key(1)] public int HomeScore { get; set; }
+
+ // HashSet → HashSetEqualityComparer inferred — no attribute required
+ [Key(2)] public HashSet? Tags { get; set; }
+
+ [IgnoreMember]
+ public DateTime ReceivedAt { get; set; } // excluded
+}
+```
+
+### Overriding the inferred comparer
+
+Explicit equality attributes take precedence over inference. All attributes from the `[Equatable]` table are applicable to adapter-included properties:
+
+```csharp
+[DataContract]
+[DataContractEquatable]
+public partial class EventContract
+{
+ // Override: treat list as a set — element order is irrelevant
+ [DataMember(Order = 0)]
+ [HashSetEquality]
+ public List? PermissionCodes { get; set; }
+
+ // Override: case-insensitive string comparison
+ [DataMember(Order = 1)]
[StringEquality(StringComparison.OrdinalIgnoreCase)]
- public string EmailAddress { get; set; } = null!;
+ public string? Region { get; set; }
- public string? DisplayName { get; set; }
+ // Override: fully custom comparer
+ [DataMember(Order = 2)]
+ [EqualityComparer(typeof(CountOnlyComparer))]
+ public Dictionary? Weights { get; set; }
+}
+```
+
+### Serialised but excluded from equality
- public string? FirstName { get; set; }
+A property can participate in serialisation while being excluded from equality. This is useful when a field tracks operational metadata — timestamps, audit info, internal sequence numbers — that is transmitted on the wire but must not affect the equality contract of the domain object.
- public string? LastName { get; set; }
+Combine `[DataMember]` or `[Key(n)]` with `[IgnoreEquality]`:
- public DateTimeOffset? LockoutEnd { get; set; }
+```csharp
+[DataContract]
+[DataContractEquatable]
+public partial class OrderDataContract
+{
+ [DataMember(Order = 0)]
+ public int Id { get; set; }
- public DateTimeOffset? LastLogin { get; set; }
+ [DataMember(Order = 1)]
+ public string? Name { get; set; }
+ // Included in serialisation (Order = 2) but excluded from equality.
+ // Two OrderDataContract values are equal when Id and Name match,
+ // regardless of when they were last modified.
+ [DataMember(Order = 2)]
[IgnoreEquality]
- public string FullName => $"{FirstName} {LastName}";
+ public DateTime LastModified { get; set; }
+}
+```
- [HashSetEquality]
- public HashSet? Roles { get; set; }
+```csharp
+[MessagePackObject]
+[MessagePackEquatable]
+public partial class PricingContract
+{
+ [Key(0)] public int MarketId { get; set; }
+ [Key(1)] public string? Name { get; set; }
+
+ // Serialised at key 2 but omitted from generated Equals / GetHashCode.
+ [Key(2)]
+ [IgnoreEquality]
+ public DateTime ReceivedAt { get; set; }
+}
+```
+
+The generated `Equals` and `GetHashCode` will not reference `LastModified` or `ReceivedAt` even though both properties are present in the serialised form.
+
+---
+
+## Collection attributes in detail
+
+### `[SequenceEquality]` — order-sensitive comparison
+
+**Default for:** `List`, `T[]` — no attribute required on these types.
+
+**Supported types:** any `IEnumerable` — `List`, `T[]`, `ICollection`, `IReadOnlyList`, `IEnumerable`, `HashSet` (via override), and more.
+
+```csharp
+public List? Tracks { get; set; } // SequenceEquality by default
+public int[]? Scores { get; set; } // SequenceEquality by default
+```
+
+`["A","B","C"]` equals `["A","B","C"]` ✓
+`["A","B","C"]` does NOT equal `["C","B","A"]` ✓
+
+**Direction override:** apply to `HashSet` to enforce order-sensitive comparison on a normally unordered type.
+
+```csharp
+[SequenceEquality]
+public HashSet? OrderedTags { get; set; } // override: element order now matters
+```
+
+---
+
+### `[HashSetEquality]` — order-insensitive comparison
+
+**Default for:** `HashSet` — no attribute required on plain hash sets.
+
+**Supported types:** any `IEnumerable` — `HashSet`, `ISet`, `IReadOnlySet`, `List`, `T[]` (via override), and more. When the value implements `ISet`, `SetEquals` is called directly (fast path). Otherwise a temporary `HashSet` is constructed for the comparison.
+
+```csharp
+public HashSet? Roles { get; set; } // HashSetEquality by default
+```
+
+`{"admin","editor"}` equals `{"editor","admin"}` ✓
+
+**Direction override:** apply to `List` or `T[]` to make the comparison order-insensitive.
+
+```csharp
+[HashSetEquality]
+public List? PermissionCodes { get; set; } // override: element order no longer matters
+```
+
+---
+
+### `[DictionaryEquality]` — key-value comparison, insertion order irrelevant
- [DictionaryEquality]
- public Dictionary? Permissions { get; set; }
+**Default for:** `Dictionary` — no attribute required on plain dictionaries.
- [SequenceEquality]
- public List? History { get; set; }
+**Supported types:** any `IReadOnlyDictionary` — `Dictionary`, `IReadOnlyDictionary`, `SortedDictionary`, `ConcurrentDictionary`, and more.
+
+```csharp
+public Dictionary? Prices { get; set; } // DictionaryEquality by default
+```
+
+`{a:1.85, b:1.90}` equals `{b:1.90, a:1.85}` ✓
+
+---
+
+## Nested collections
+
+Annotate the **outer property once** — the generator selects the appropriate comparer for every nested level automatically.
+
+### `[DictionaryEquality]`
+
+```csharp
+// outer: DictionaryEquality
+// inner Dictionary: DictionaryEquality (propagated)
+public Dictionary>? ByRegion { get; set; }
+
+// outer: DictionaryEquality
+// inner List: SequenceEquality (default for List)
+public Dictionary>? ScoresByRegion { get; set; }
+
+// outer: DictionaryEquality
+// inner HashSet: HashSetEquality (default for HashSet)
+public Dictionary>? TagsByRegion { get; set; }
+```
+
+### `[SequenceEquality]`
+
+```csharp
+// outer: SequenceEquality (element order matters for the outer list)
+// inner Dictionary: DictionaryEquality (default for Dictionary)
+[SequenceEquality]
+public List>? Steps { get; set; }
+
+// outer: SequenceEquality
+// inner List: SequenceEquality (default for List)
+[SequenceEquality]
+public List>? Matrix { get; set; }
+
+// outer: SequenceEquality
+// inner HashSet: HashSetEquality (default for HashSet)
+[SequenceEquality]
+public List>? Groups { get; set; }
+```
+
+### Explicit overrides propagate transparently
+
+The annotation on a property is the sole source of truth. A `List` property with no attribute uses `SequenceEquality`; with `[HashSetEquality]` it uses `HashSetEquality`. There is no implicit inference that could produce unexpected results.
+
+```csharp
+public List? Tags { get; set; } // SequenceEquality (default)
+
+[HashSetEquality]
+public List? Permissions { get; set; } // HashSetEquality (explicit override)
+
+[SequenceEquality]
+public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit override)
+```
+
+The same principle applies inside nested collections. The outer annotation establishes the comparer kind; inner types follow their own defaults unless the outer annotation overrides them.
+
+```csharp
+// outer List → SequenceEquality (default)
+// inner HashSet → HashSetEquality (default for HashSet)
+public List>? Groups { get; set; }
+
+// outer List → HashSetEquality (override — the list is treated as a set of sets)
+// inner HashSet → HashSetEquality (propagated from outer override)
+[HashSetEquality]
+public List>? GroupsUnordered { get; set; }
+
+// outer HashSet → SequenceEquality (override — element order now matters)
+// inner List → SequenceEquality (propagated from outer override)
+[SequenceEquality]
+public HashSet>? OrderedGroups { get; set; }
+```
+
+---
+
+## Multi-dimensional arrays
+
+`T[,]`, `T[,,]`, and higher-rank arrays are handled automatically by `MultiDimensionalArrayEqualityComparer` — no attribute is required.
+
+**Default for:** any array with rank ≥ 2. Single-dimensional `T[]` uses `SequenceEqualityComparer` instead.
+
+```csharp
+// 2D array — MultiDimensionalArrayEqualityComparer applied by default
+public int[,] Grid { get; set; }
+
+// 3D array — rank is detected at compile time; same default applies
+public double[,,] Cube { get; set; }
+```
+
+Two arrays are equal when:
+1. They have the **same rank** (`int[,]` ≠ `int[,,]`)
+2. Every **dimension length** matches (`[2,3]` ≠ `[3,2]`)
+3. Every **element** is equal **in row-major order** (position matters)
+
+```csharp
+var a = new int[,] { { 1, 2 }, { 3, 4 } };
+var b = new int[,] { { 1, 2 }, { 3, 4 } };
+// a == b ✓ (same rank, same dimensions, same elements in row-major order)
+
+var c = new int[,] { { 1, 3 }, { 2, 4 } }; // transposed
+// a != c ✓ (row-major order: [0,0]=1,[0,1]=2,[1,0]=3,[1,1]=4 vs [0,0]=1,[0,1]=3,...)
+
+var d = new int[,,] { { { 1, 2 }, { 3, 4 } } };
+// a != d ✓ (rank 2 vs rank 3 — always unequal regardless of content)
+```
+
+### Comparer overrides for multi-dimensional arrays
+
+`MultiDimensionalArrayEqualityComparer` is always used for rank ≥ 2 and cannot be replaced with `SequenceEqualityComparer` or `HashSetEqualityComparer`. Applying `[EqualityComparer(typeof(MyComparer))]` to a `T[,]` property does not wrap the comparer around `MultiDimensionalArrayEqualityComparer` — it bypasses it entirely, passing the array instance as a single opaque value to the custom comparer. The effective behaviour is reference equality, which is almost certainly incorrect. Rely on the default and ensure the element type defines its own correct equality.
+
+Single-dimensional `T[]` supports the full range of comparer overrides:
+
+```csharp
+// T[] default: SequenceEquality (order matters)
+public int[] Scores { get; set; }
+
+// T[] override: HashSetEquality (order no longer matters)
+[HashSetEquality]
+public int[] GroupIds { get; set; }
+```
+
+---
+
+## `[EqualityComparer]` — custom comparer
+
+When no built-in attribute is appropriate, supply a custom `IEqualityComparer`:
+
+```csharp
+public sealed class CountOnlyComparer : IEqualityComparer?>
+{
+ public static readonly CountOnlyComparer Default = new();
+ public bool Equals(Dictionary? x, Dictionary? y) =>
+ x is null ? y is null : y is not null && x.Count == y.Count;
+ public int GetHashCode(Dictionary? obj) => obj?.Count ?? 0;
}
+
+[EqualityComparer(typeof(CountOnlyComparer))]
+public Dictionary? AssetWeights { get; set; }
```
-Works for `record` types too
+---
-```c#
-[Equatable]
-public partial record StatusRecord(
- int Id,
- [property: StringEquality(StringComparison.OrdinalIgnoreCase)] string Name,
- string? Description,
- int DisplayOrder,
- bool IsActive,
- [property: SequenceEquality] List Versions
-);
+## Build-time diagnostics
+
+The Roslyn analyzer validates every `[Equatable]` type at compile time and emits warnings when attributes are absent or incorrectly applied. These diagnostics are designed to surface mistakes that would otherwise produce silent incorrect behaviour at runtime.
+
+### Missing attribute warnings
+
+| Diagnostic | Applies to | Condition | Example |
+|---|---|---|---|
+| `EQ0001` | `[Equatable]` | `IDictionary` or `IReadOnlyDictionary` property has no attribute | `Dictionary? Map` |
+| `EQ0002` | `[Equatable]` | `IEnumerable` property (including `T[]`) has no attribute | `List? Tags`, `int[]? Ids` |
+| `EQ0020` | `[DataContractEquatable]` | Class has no `[DataContract]` | — |
+| `EQ0021` | `[MessagePackEquatable]` | Class has no `[MessagePackObject]` | — |
+| `EQ0022` | `[DataContractEquatable]` | Public property has no `[DataMember]`, `[IgnoreDataMember]`, or `[IgnoreEquality]` | — |
+| `EQ0023` | `[MessagePackEquatable]` | Public property has no `[Key(n)]`, `[IgnoreMember]`, or `[IgnoreEquality]` | — |
+
+**EQ0001 / EQ0002** apply exclusively to `[Equatable]` types, where all public properties are included by default and collection or dictionary types must be explicitly annotated. Adapter generators infer the correct comparer automatically, so `[DataMember]` and `[Key(n)]` properties never require an additional `[SequenceEquality]` or `[DictionaryEquality]` annotation.
+
+Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always applied as the default — no annotation is required or accepted.
+
+**EQ0020 / EQ0021** detect the case where an adapter attribute is present but its corresponding serialisation attribute is absent. Without `[DataContract]`, the serialiser ignores all `[DataMember]` annotations; the generated equality would include no properties. The same applies to `[MessagePackObject]` and `[Key(n)]`.
+
+**EQ0022 / EQ0023** detect public properties that are silently excluded on adapter-annotated types. The adapters include only properties that carry the serialisation inclusion attribute (`[DataMember]` / `[Key(n)]`); all other public properties are excluded without warning. This is intentional for computed or infrastructure properties, but an accidental omission is difficult to identify. EQ0022 and EQ0023 require the intent to be made explicit: add the inclusion attribute or an explicit exclusion attribute to suppress the diagnostic.
+
+```csharp
+[DataContract]
+[DataContractEquatable]
+public partial class EventContract
+{
+ [DataMember(Order = 0)] public int EventId { get; set; } // included ✓
+
+ // EQ0022 — excluded without annotation; intent is ambiguous
+ public DateTime LastSeen { get; set; }
+
+ [IgnoreDataMember] public DateTime LastSeen { get; set; } // explicit exclusion ✓
+ // or
+ [IgnoreEquality] public DateTime LastSeen { get; set; } // explicit exclusion ✓
+}
+```
+
+### Invalid attribute warnings
+
+| Diagnostic | Condition |
+|---|---|
+| `EQ0010` | `[StringEquality]` applied to a non-`string` property |
+| `EQ0011` | `[DictionaryEquality]` applied to a type that does not implement `IDictionary` or `IReadOnlyDictionary` |
+| `EQ0012` | `[HashSetEquality]` applied to a type that does not implement `IEnumerable` |
+| `EQ0013` | `[SequenceEquality]` applied to a type that does not implement `IEnumerable` |
+| `EQ0014` | Any collection or equality attribute applied to a multi-dimensional array (`rank ≥ 2`) |
+| `EQ0015` | `[SequenceEquality]` or `[HashSetEquality]` applied to a dictionary type (`IDictionary` or `IReadOnlyDictionary`) |
+
+### EQ0014 — equality attributes have no effect on multi-dimensional arrays
+
+`EQ0014` is emitted when any collection or equality attribute (`[SequenceEquality]`, `[HashSetEquality]`, `[DictionaryEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) is placed on a property of type `T[,]` or higher rank. The comparer for multi-dimensional arrays cannot be overridden:
+
+- `[SequenceEquality]` and `[HashSetEquality]` are ignored; `MultiDimensionalArrayEqualityComparer` is used regardless.
+- `[EqualityComparer(typeof(MyComparer))]` bypasses `MultiDimensionalArrayEqualityComparer` entirely and passes the array instance as a single value to the custom comparer, producing reference equality behaviour.
+
+The diagnostic converts silent, incorrect behaviour into a visible compile-time warning:
+
+```csharp
+// EQ0014 — attribute has no effect on a rank-2 array
+[SequenceEquality]
+public int[,]? Grid { get; set; }
+
+// Correct — no attribute required; MultiDimensionalArrayEqualityComparer is the default
+public int[,]? Grid { get; set; }
```
+
+### EQ0015 — enumerable attributes are not applicable to dictionary types
+
+`EQ0015` is emitted when `[SequenceEquality]` or `[HashSetEquality]` is applied to a property whose type implements `IDictionary` or `IReadOnlyDictionary`. These attributes treat the dictionary as a flat sequence of `KeyValuePair` entries, discarding key-lookup semantics and producing insertion-order-sensitive comparisons. Use `[DictionaryEquality]` instead:
+
+```csharp
+// EQ0015 — treats Dictionary as a sequence of KeyValuePair entries (order-sensitive)
+[SequenceEquality]
+public Dictionary? Scores { get; set; }
+
+// EQ0015 — treats Dictionary as a set of KeyValuePair entries
+[HashSetEquality]
+public Dictionary? Scores { get; set; }
+
+// Correct — key-value equality, insertion order irrelevant
+[DictionaryEquality]
+public Dictionary? Scores { get; set; }
+```
+
+---
+
+## Equality invariants
+
+Every generated implementation satisfies the following properties:
+
+| Property | Guarantee |
+|---|---|
+| **Reflexive** | `a.Equals(a)` is always `true` |
+| **Symmetric** | `a.Equals(b) == b.Equals(a)` always |
+| **Null-safe** | `a.Equals(null)` is always `false` |
+| **Hash contract** | `a.Equals(b)` implies `a.GetHashCode() == b.GetHashCode()` |
+
+The hash contract is critical for correct behaviour when instances are used as dictionary keys or hash set members.
+
+---
+
+## What's new
+
+### New packages
+
+- **`Equatable.Generator.DataContract`** — adapter generator that reads `[DataMember]` attributes (`System.Runtime.Serialization`). Only properties annotated with `[DataMember]` are included in equality; `[IgnoreDataMember]` and unannotated properties are excluded. EQ0022 is emitted for any public property with no annotation, requiring the intent to be made explicit.
+- **`Equatable.Generator.MessagePack`** — adapter generator that reads `[Key(n)]` attributes. Only `[Key]` properties are included; `[IgnoreMember]` and unannotated properties are excluded. EQ0023 is emitted for unannotated properties.
+
+### New features
+
+- **Direction overrides** — `[HashSetEquality]` on `List` or `T[]` produces order-insensitive comparison; `[SequenceEquality]` on `HashSet` enforces order-sensitive comparison.
+- **Nested collection comparer propagation** — a single annotation on the outer property propagates the chosen comparer kind into all nested levels. `Dictionary>`, `Dictionary>`, and three-level nesting are all handled with a single annotation.
+- **`MultiDimensionalArrayEqualityComparer`** — structural equality for `T[,]`, `T[,,]`, and higher-rank arrays, applied automatically as the default. Checks rank, dimension lengths, and element values in row-major order.
+- **`IReadOnlyDictionary` support** — dictionary comparers accept any `IReadOnlyDictionary`, not only `Dictionary`.
+- **Base class delegation** — the generated `Equals` method calls `base.Equals()` when the base class is also an equatable-generated type, including across adapter boundaries.
+- **Analyzer diagnostics**
+ - `EQ0020` — `[DataContractEquatable]` without `[DataContract]`
+ - `EQ0021` — `[MessagePackEquatable]` without `[MessagePackObject]`
+ - `EQ0022` — unannotated public property on a `[DataContractEquatable]` type
+ - `EQ0023` — unannotated public property on a `[MessagePackEquatable]` type
+ - `EQ0014` — equality attribute on a multi-dimensional array (`rank ≥ 2`), where the comparer cannot be overridden
+ - `EQ0015` — `[SequenceEquality]` or `[HashSetEquality]` on a dictionary type
+
+### Bug fixes
+
+- **Empty collections hash differently from null** — a sentinel value ensures `GetHashCode(empty) != GetHashCode(null)`, satisfying the hash contract.
+- **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative accumulation strategy, consistent with `SetEquals`-based `Equals`.
+- **Value types without `==`** — `EqualityComparer.Default` is used instead of direct operator comparison.
+
+### Improvements
+
+- `Equatable.Generator.DataContract` and `Equatable.Generator.MessagePack` are independent NuGet packages — include only what the project requires.
+- `IsPublicInstanceProperty` extracted as a shared helper, eliminating duplication across adapter generators.
+- Allocation-free hash code computation for dictionary comparers.
+
+---
+
+## Requirements
+
+- Target framework: .NET Standard 2.0 or later
+- C# language version: 8.0 or higher
diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs
index 61ee694..10aa8ed 100644
--- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs
+++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs
@@ -53,9 +53,14 @@ public bool Equals(IDictionary? x, IDictionary? y)
if (x.Count != y.Count)
return false;
+ // y.TryGetValue uses y's own internal comparer, not this.KeyComparer.
+ // Build a lookup keyed by KeyComparer so the same comparer governs both
+ // Equals and GetHashCode — required for the hash contract to hold.
+ var yLookup = new Dictionary(y, KeyComparer);
+
foreach (var pair in x)
{
- if (!y.TryGetValue(pair.Key, out var value))
+ if (!yLookup.TryGetValue(pair.Key, out var value))
return false;
if (!ValueComparer.Equals(pair.Value, value))
@@ -71,15 +76,21 @@ public int GetHashCode(IDictionary obj)
if (obj == null)
return 0;
- var hash = new HashCode();
+ // Start at 1, not 0: an empty dictionary must not hash the same as null.
+ // Equals correctly returns false for (null, empty), so GetHashCode must also
+ // differ — otherwise a hash table would bucket them together and force an Equals
+ // call that returns false, producing unnecessary collisions. null returns 0 above;
+ // 1 here ensures an empty collection is always distinguishable.
+ int hashCode = 1;
- // sort by key to ensure dictionary with different order are the same
- foreach (var pair in obj.OrderBy(d => d.Key))
- {
- hash.Add(pair.Key, KeyComparer);
- hash.Add(pair.Value, ValueComparer);
- }
+ // Commutative SUM ensures hash is insertion-order independent, consistent with
+ // Equals which uses TryGetValue (also order-independent). Previously GetHashCode
+ // used OrderBy + sequential HashCode.Add, which was order-dependent and violated
+ // the contract: two equal dictionaries (same keys/values, different insertion order)
+ // would produce different hash codes.
+ foreach (var pair in obj)
+ hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!));
- return hash.ToHashCode();
+ return hashCode;
}
}
diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs
index cb02689..8ea56df 100644
--- a/src/Equatable.Comparers/HashSetEqualityComparer.cs
+++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs
@@ -56,12 +56,21 @@ public int GetHashCode(IEnumerable obj)
if (obj == null)
return 0;
- var hashCode = new HashCode();
+ // Start at 1, not 0: an empty set must not hash the same as null.
+ // Equals correctly returns false for (null, empty), so GetHashCode must also
+ // differ — otherwise a hash table would bucket them together and force an Equals
+ // call that returns false, producing unnecessary collisions. null returns 0 above;
+ // 1 here ensures an empty collection is always distinguishable.
+ int hashCode = 1;
- // sort to ensure set with different order are the same
- foreach (var item in obj.OrderBy(s => s))
- hashCode.Add(item, Comparer);
+ // Commutative SUM ensures hash is iteration-order independent, consistent with
+ // Equals which uses SetEquals (also order-independent). Previously GetHashCode
+ // used OrderBy + sequential HashCode.Add, which was order-dependent and violated
+ // the contract: two equal sets (same elements, different insertion order) could
+ // produce different hash codes.
+ foreach (var item in obj)
+ hashCode += Comparer.GetHashCode(item!);
- return hashCode.ToHashCode();
+ return hashCode;
}
}
diff --git a/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs b/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs
new file mode 100644
index 0000000..8ef3afa
--- /dev/null
+++ b/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs
@@ -0,0 +1,78 @@
+namespace Equatable.Comparers;
+
+///
+/// Structural equality comparer for multi-dimensional arrays (T[,], T[,,], etc.).
+/// Compares element-by-element in row-major order without LINQ or intermediate allocations.
+///
+/// The element type of the array.
+public class MultiDimensionalArrayEqualityComparer : IEqualityComparer
+{
+ ///
+ /// Gets the default equality comparer for the specified element type.
+ ///
+ public static MultiDimensionalArrayEqualityComparer Default { get; } = new();
+
+ ///
+ /// Initializes a new instance using the default element comparer.
+ ///
+ public MultiDimensionalArrayEqualityComparer() : this(EqualityComparer.Default)
+ {
+ }
+
+ ///
+ /// Initializes a new instance using the specified element comparer.
+ ///
+ public MultiDimensionalArrayEqualityComparer(IEqualityComparer comparer)
+ {
+ Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
+ }
+
+ ///
+ /// Gets the element comparer.
+ ///
+ public IEqualityComparer Comparer { get; }
+
+ ///
+ public bool Equals(Array? x, Array? y)
+ {
+ if (ReferenceEquals(x, y))
+ return true;
+
+ if (x is null || y is null)
+ return false;
+
+ if (x.Rank != y.Rank)
+ return false;
+
+ for (int dim = 0; dim < x.Rank; dim++)
+ {
+ if (x.GetLength(dim) != y.GetLength(dim))
+ return false;
+ }
+
+ var ex = x.GetEnumerator();
+ var ey = y.GetEnumerator();
+ while (ex.MoveNext())
+ {
+ ey.MoveNext();
+ if (!Comparer.Equals((TValue)ex.Current!, (TValue)ey.Current!))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ public int GetHashCode(Array? obj)
+ {
+ if (obj is null)
+ return 0;
+
+ var hashCode = new HashCode();
+ var e = obj.GetEnumerator();
+ while (e.MoveNext())
+ hashCode.Add((TValue)e.Current!, Comparer);
+
+ return hashCode.ToHashCode();
+ }
+}
diff --git a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs
new file mode 100644
index 0000000..7ea05bc
--- /dev/null
+++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs
@@ -0,0 +1,91 @@
+namespace Equatable.Comparers;
+
+///
+/// equality comparer instance
+///
+/// The type of the keys in the dictionary.
+/// The type of the values in the dictionary.
+public class ReadOnlyDictionaryEqualityComparer : IEqualityComparer>
+{
+ ///
+ /// Gets the default equality comparer for specified generic argument.
+ ///
+ public static ReadOnlyDictionaryEqualityComparer Default { get; } = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ReadOnlyDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The that is used to determine equality of keys in a dictionary
+ /// The that is used to determine equality of values in a dictionary
+ /// or is null
+ public ReadOnlyDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer)
+ {
+ KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer));
+ ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer));
+ }
+
+ ///
+ /// Gets the that is used to determine equality of keys in a dictionary
+ ///
+ public IEqualityComparer KeyComparer { get; }
+
+ ///
+ /// Gets the that is used to determine equality of values in a dictionary
+ ///
+ public IEqualityComparer ValueComparer { get; }
+
+ ///
+ public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary? y)
+ {
+ if (ReferenceEquals(x, y))
+ return true;
+
+ if (x is null || y is null)
+ return false;
+
+ if (x.Count != y.Count)
+ return false;
+
+ // y.TryGetValue uses y's own internal comparer, not this.KeyComparer.
+ // Build a lookup keyed by KeyComparer so the same comparer governs both
+ // Equals and GetHashCode — required for the hash contract to hold.
+ var yLookup = new Dictionary(y, KeyComparer);
+
+ foreach (var pair in x)
+ {
+ if (!yLookup.TryGetValue(pair.Key, out var value))
+ return false;
+
+ if (!ValueComparer.Equals(pair.Value, value))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ public int GetHashCode(IReadOnlyDictionary obj)
+ {
+ if (obj == null)
+ return 0;
+
+ // Start at 1, not 0: an empty dictionary must not hash the same as null.
+ // Equals correctly returns false for (null, empty), so GetHashCode must also
+ // differ — otherwise a hash table would bucket them together and force an Equals
+ // call that returns false, producing unnecessary collisions. null returns 0 above;
+ // 1 here ensures an empty collection is always distinguishable.
+ int hashCode = 1;
+
+ foreach (var pair in obj)
+ hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!));
+
+ return hashCode;
+ }
+}
diff --git a/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs
new file mode 100644
index 0000000..6c949d8
--- /dev/null
+++ b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs
@@ -0,0 +1,12 @@
+using System.Diagnostics;
+
+namespace Equatable.Attributes.DataContract;
+
+///
+/// Marks the class to source generate overrides for Equals and GetHashCode,
+/// including only properties decorated with
+/// and excluding properties decorated with .
+///
+[Conditional("EQUATABLE_GENERATOR")]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
+public class DataContractEquatableAttribute : Attribute;
diff --git a/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj b/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj
new file mode 100644
index 0000000..47ebc80
--- /dev/null
+++ b/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj
@@ -0,0 +1,15 @@
+
+
+
+ netstandard2.0;net8.0;net9.0;net10.0
+ Equatable
+ Source generator for Equals and GetHashCode for types using System.Runtime.Serialization DataMember attributes
+
+
+
+
+
+
+
+
+
diff --git a/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs
new file mode 100644
index 0000000..5c67097
--- /dev/null
+++ b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs
@@ -0,0 +1,12 @@
+using System.Diagnostics;
+
+namespace Equatable.Attributes.MessagePack;
+
+///
+/// Marks the class to source generate overrides for Equals and GetHashCode,
+/// including only properties decorated with MessagePack.KeyAttribute
+/// and excluding properties decorated with MessagePack.IgnoreMemberAttribute.
+///
+[Conditional("EQUATABLE_GENERATOR")]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
+public class MessagePackEquatableAttribute : Attribute;
diff --git a/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj b/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj
new file mode 100644
index 0000000..799c37f
--- /dev/null
+++ b/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj
@@ -0,0 +1,15 @@
+
+
+
+ netstandard2.0;net8.0;net9.0;net10.0
+ Equatable
+ Source generator for Equals and GetHashCode for types using MessagePack Key attributes
+
+
+
+
+
+
+
+
+
diff --git a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs
index c7bf7b9..a8f30ee 100644
--- a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs
+++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs
@@ -3,8 +3,11 @@
namespace Equatable.Attributes;
///
-/// Use a dictionary based comparer to determine if dictionaries are equal
+/// Use a dictionary based comparer to determine if dictionaries are equal.
+/// Two dictionaries are equal when they contain the same key/value pairs, regardless of insertion order.
///
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
-public class DictionaryEqualityAttribute : Attribute;
+public class DictionaryEqualityAttribute : Attribute
+{
+}
diff --git a/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs
index 9efa192..3bd0191 100644
--- a/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs
+++ b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs
@@ -3,7 +3,8 @@
namespace Equatable.Attributes;
///
-/// Use in Equals and GetHashCode implementations
+/// Use in Equals and GetHashCode implementations.
+/// Comparison is order-independent — use for order-sensitive comparison.
///
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
diff --git a/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..ad21bcb
--- /dev/null
+++ b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..940a865
--- /dev/null
+++ b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,8 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|------------------
+EQ0020 | Usage | Warning | DataContractEquatableAnalyzer
diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs
new file mode 100644
index 0000000..9a06433
--- /dev/null
+++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs
@@ -0,0 +1,105 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Equatable.SourceGenerator.DataContract;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class DataContractEquatableAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor MissingDataContractAttribute = new(
+ id: "EQ0020",
+ title: "Missing DataContract Attribute",
+ messageFormat: "'{0}' is marked with [DataContractEquatable] but is missing [DataContract]. DataMember attributes are ignored by DataContractSerializer without it.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ private static readonly DiagnosticDescriptor UnannotatedPropertyOnDataContractEquatable = new(
+ id: "EQ0022",
+ title: "Unannotated Property on DataContractEquatable Type",
+ messageFormat: "Property '{0}' on [DataContractEquatable] type '{1}' has no [DataMember] or [IgnoreDataMember] attribute. It will be silently excluded from equality. Add [DataMember] to include it or [IgnoreDataMember] to suppress this warning.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ public override ImmutableArray SupportedDiagnostics =>
+ ImmutableArray.Create(MissingDataContractAttribute, UnannotatedPropertyOnDataContractEquatable);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
+ }
+
+ private static void AnalyzeNamedType(SymbolAnalysisContext context)
+ {
+ var typeSymbol = (INamedTypeSymbol)context.Symbol;
+
+ if (!HasDataContractEquatableAttribute(typeSymbol))
+ return;
+
+ if (!HasDataContractAttribute(typeSymbol))
+ {
+ var location = typeSymbol.Locations.FirstOrDefault();
+ context.ReportDiagnostic(Diagnostic.Create(MissingDataContractAttribute, location, typeSymbol.Name));
+ }
+
+ foreach (var property in typeSymbol.GetMembers().OfType())
+ {
+ if (!EquatableGenerator.IsPublicInstanceProperty(property))
+ continue;
+
+ var attributes = property.GetAttributes();
+
+ if (HasDataMemberAttribute(attributes) || HasIgnoreDataMemberAttribute(attributes) || HasIgnoreEqualityAttribute(attributes))
+ continue;
+
+ var propertyLocation = property.Locations.FirstOrDefault();
+ context.ReportDiagnostic(Diagnostic.Create(
+ UnannotatedPropertyOnDataContractEquatable,
+ propertyLocation,
+ property.Name,
+ typeSymbol.Name));
+ }
+ }
+
+ private static bool HasDataContractEquatableAttribute(INamedTypeSymbol typeSymbol) =>
+ typeSymbol.GetAttributes().Any(a => a.AttributeClass is
+ {
+ Name: "DataContractEquatableAttribute",
+ ContainingNamespace: { Name: "DataContract", ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } }
+ });
+
+ private static bool HasDataContractAttribute(INamedTypeSymbol typeSymbol) =>
+ typeSymbol.GetAttributes().Any(a => a.AttributeClass is
+ {
+ Name: "DataContractAttribute",
+ ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } }
+ });
+
+ private static bool HasDataMemberAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is
+ {
+ Name: "DataMemberAttribute",
+ ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } }
+ });
+
+ private static bool HasIgnoreDataMemberAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreDataMemberAttribute",
+ ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } }
+ });
+
+ private static bool HasIgnoreEqualityAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreEqualityAttribute",
+ ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" }
+ });
+}
diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs
new file mode 100644
index 0000000..ca06cc4
--- /dev/null
+++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs
@@ -0,0 +1,46 @@
+using Microsoft.CodeAnalysis;
+
+namespace Equatable.SourceGenerator.DataContract;
+
+[Generator]
+public class DataContractEquatableGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ EquatableGenerator.RegisterProvider(context,
+ fullyQualifiedMetadataName: "Equatable.Attributes.DataContract.DataContractEquatableAttribute",
+ trackingName: "DataContractEquatableAttribute",
+ propertyFilter: IsIncludedDataContract,
+ postProcessProperty: EquatableGenerator.InferCollectionComparer);
+ }
+
+ private static bool IsIncludedDataContract(IPropertySymbol propertySymbol)
+ {
+ if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol))
+ return false;
+
+ var attributes = propertySymbol.GetAttributes();
+ if (attributes.Length == 0)
+ return false;
+
+ if (attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreDataMemberAttribute",
+ ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } }
+ }))
+ return false;
+
+ if (attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreEqualityAttribute",
+ ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" }
+ }))
+ return false;
+
+ return attributes.Any(a => a.AttributeClass is
+ {
+ Name: "DataMemberAttribute",
+ ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } }
+ });
+ }
+}
diff --git a/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj b/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj
new file mode 100644
index 0000000..5364bd9
--- /dev/null
+++ b/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj
@@ -0,0 +1,19 @@
+
+
+
+ netstandard2.0
+ true
+ false
+ cs
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..ad21bcb
--- /dev/null
+++ b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..70e2847
--- /dev/null
+++ b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,8 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|------------------
+EQ0021 | Usage | Warning | MessagePackEquatableAnalyzer
diff --git a/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj b/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj
new file mode 100644
index 0000000..5364bd9
--- /dev/null
+++ b/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj
@@ -0,0 +1,19 @@
+
+
+
+ netstandard2.0
+ true
+ false
+ cs
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs
new file mode 100644
index 0000000..0a7bdae
--- /dev/null
+++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs
@@ -0,0 +1,97 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Equatable.SourceGenerator.MessagePack;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class MessagePackEquatableAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor MissingMessagePackObjectAttribute = new(
+ id: "EQ0021",
+ title: "Missing MessagePackObject Attribute",
+ messageFormat: "'{0}' is marked with [MessagePackEquatable] but is missing [MessagePackObject]. Key attributes are ignored by MessagePack serializer without it.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ private static readonly DiagnosticDescriptor UnannotatedPropertyOnMessagePackEquatable = new(
+ id: "EQ0023",
+ title: "Unannotated Property on MessagePackEquatable Type",
+ messageFormat: "Property '{0}' on [MessagePackEquatable] type '{1}' has no [Key] or [IgnoreMember] attribute. It will be silently excluded from equality. Add [Key(n)] to include it or [IgnoreMember] to suppress this warning.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ public override ImmutableArray SupportedDiagnostics =>
+ ImmutableArray.Create(MissingMessagePackObjectAttribute, UnannotatedPropertyOnMessagePackEquatable);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
+ }
+
+ private static void AnalyzeNamedType(SymbolAnalysisContext context)
+ {
+ var typeSymbol = (INamedTypeSymbol)context.Symbol;
+
+ if (!HasMessagePackEquatableAttribute(typeSymbol))
+ return;
+
+ if (!HasMessagePackObjectAttribute(typeSymbol))
+ {
+ var location = typeSymbol.Locations.FirstOrDefault();
+ context.ReportDiagnostic(Diagnostic.Create(MissingMessagePackObjectAttribute, location, typeSymbol.Name));
+ }
+
+ foreach (var property in typeSymbol.GetMembers().OfType())
+ {
+ if (!EquatableGenerator.IsPublicInstanceProperty(property))
+ continue;
+
+ var attributes = property.GetAttributes();
+
+ if (HasKeyAttribute(attributes) || HasIgnoreMemberAttribute(attributes) || HasIgnoreEqualityAttribute(attributes))
+ continue;
+
+ var propertyLocation = property.Locations.FirstOrDefault();
+ context.ReportDiagnostic(Diagnostic.Create(
+ UnannotatedPropertyOnMessagePackEquatable,
+ propertyLocation,
+ property.Name,
+ typeSymbol.Name));
+ }
+ }
+
+ private static bool HasMessagePackEquatableAttribute(INamedTypeSymbol typeSymbol) =>
+ typeSymbol.GetAttributes().Any(a => a.AttributeClass is
+ {
+ Name: "MessagePackEquatableAttribute",
+ ContainingNamespace: { Name: "MessagePack", ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } }
+ });
+
+ private static bool HasMessagePackObjectAttribute(INamedTypeSymbol typeSymbol) =>
+ typeSymbol.GetAttributes().Any(a => a.AttributeClass is
+ {
+ Name: "MessagePackObjectAttribute",
+ ContainingNamespace.Name: "MessagePack"
+ });
+
+ private static bool HasKeyAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" });
+
+ private static bool HasIgnoreMemberAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" });
+
+ private static bool HasIgnoreEqualityAttribute(ImmutableArray attributes) =>
+ attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreEqualityAttribute",
+ ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" }
+ });
+}
diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs
new file mode 100644
index 0000000..6ca4017
--- /dev/null
+++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs
@@ -0,0 +1,38 @@
+using Microsoft.CodeAnalysis;
+
+namespace Equatable.SourceGenerator.MessagePack;
+
+[Generator]
+public class MessagePackEquatableGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ EquatableGenerator.RegisterProvider(context,
+ fullyQualifiedMetadataName: "Equatable.Attributes.MessagePack.MessagePackEquatableAttribute",
+ trackingName: "MessagePackEquatableAttribute",
+ propertyFilter: IsIncludedMessagePack,
+ postProcessProperty: EquatableGenerator.InferCollectionComparer);
+ }
+
+ private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol)
+ {
+ if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol))
+ return false;
+
+ var attributes = propertySymbol.GetAttributes();
+ if (attributes.Length == 0)
+ return false;
+
+ if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" }))
+ return false;
+
+ if (attributes.Any(a => a.AttributeClass is
+ {
+ Name: "IgnoreEqualityAttribute",
+ ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" }
+ }))
+ return false;
+
+ return attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" });
+ }
+}
diff --git a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs
index 21757d7..1027128 100644
--- a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs
+++ b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs
@@ -7,7 +7,7 @@ internal static class DiagnosticDescriptors
public static readonly DiagnosticDescriptor MissingDictionaryEqualityAttribute = new(
id: "EQ0001",
title: "Missing DictionaryEquality Attribute",
- messageFormat: "Property '{0}' type implements IDictionary but does not have the [DictionaryEquality] attribute",
+ messageFormat: "Property '{0}' type implements IDictionary or IReadOnlyDictionary but does not have the [DictionaryEquality] attribute",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
@@ -34,7 +34,7 @@ internal static class DiagnosticDescriptors
public static readonly DiagnosticDescriptor InvalidDictionaryEqualityAttributeUsage = new(
id: "EQ0011",
title: "Invalid DictionaryEquality Attribute Usage",
- messageFormat: "Invalid DictionaryEquality attribute usage for property '{0}'. Property type does not implement IDictionary.",
+ messageFormat: "Invalid DictionaryEquality attribute usage for property '{0}'. Property type does not implement IDictionary or IReadOnlyDictionary.",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
@@ -58,4 +58,22 @@ internal static class DiagnosticDescriptors
isEnabledByDefault: true
);
+ public static readonly DiagnosticDescriptor InvalidAttributeOnMultiDimensionalArray = new(
+ id: "EQ0014",
+ title: "Invalid Attribute on Multi-Dimensional Array",
+ messageFormat: "Attribute on property '{0}' has no effect on a multi-dimensional array (rank ≥ 2). MultiDimensionalArrayEqualityComparer is always used regardless of this attribute.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidEnumerableAttributeOnDictionary = new(
+ id: "EQ0015",
+ title: "Invalid Enumerable Attribute on Dictionary Type",
+ messageFormat: "Invalid attribute usage for property '{0}'. Property type implements IDictionary or IReadOnlyDictionary; use [DictionaryEquality] instead of [SequenceEquality] or [HashSetEquality].",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
}
diff --git a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs
index 86c2d04..a8f2f92 100644
--- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs
+++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs
@@ -16,7 +16,9 @@ public class EquatableAnalyzer : DiagnosticAnalyzer
DiagnosticDescriptors.InvalidStringEqualityAttributeUsage,
DiagnosticDescriptors.InvalidDictionaryEqualityAttributeUsage,
DiagnosticDescriptors.InvalidHashSetEqualityAttributeUsage,
- DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage
+ DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage,
+ DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray,
+ DiagnosticDescriptors.InvalidEnumerableAttributeOnDictionary
);
public override void Initialize(AnalysisContext context)
@@ -51,7 +53,7 @@ private static IEnumerable GetAnalyzableProperties(INamedTypeSy
if (IsSystemBaseType(currentSymbol))
break;
- // If a base type (not the target itself) has [Equatable], stop: it will be analyzed separately
+ // If a base type (not the target itself) has any generator attribute, stop: it will be analyzed separately
if (!SymbolEqualityComparer.Default.Equals(currentSymbol, typeSymbol) && HasEquatableAttribute(currentSymbol))
break;
@@ -72,6 +74,7 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb
{
var attributes = property.GetAttributes();
var hasEqualityAttribute = false;
+ var isMultiDimArray = property.Type is IArrayTypeSymbol { Rank: > 1 };
foreach (var attribute in attributes)
{
@@ -85,6 +88,27 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb
?.GetSyntax(context.CancellationToken).GetLocation()
?? property.Locations.FirstOrDefault();
+ // Any collection/equality attribute on a multi-dimensional array has no effect:
+ // MultiDimensionalArrayEqualityComparer is always used regardless of the annotation.
+ if (isMultiDimArray && IsCollectionOrEqualityAttribute(className))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray,
+ attributeLocation,
+ property.Name));
+ }
+
+ // [SequenceEquality] or [HashSetEquality] on a dictionary type treats the dict as a
+ // sequence of KeyValuePair entries, discarding key-lookup semantics entirely.
+ if ((className == "SequenceEqualityAttribute" || className == "HashSetEqualityAttribute")
+ && ImplementsDictionary(property.Type))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DiagnosticDescriptors.InvalidEnumerableAttributeOnDictionary,
+ attributeLocation,
+ property.Name));
+ }
+
if (className == "StringEqualityAttribute" && !IsString(property.Type))
{
context.ReportDiagnostic(Diagnostic.Create(
@@ -118,8 +142,10 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb
}
}
- // Warn when a collection/dictionary property has no equality attribute
- if (!hasEqualityAttribute)
+ // Warn when a collection/dictionary property has no equality attribute.
+ // Multi-dimensional arrays are exempt: they always use MultiDimensionalArrayEqualityComparer
+ // by default and do not require an explicit annotation.
+ if (!hasEqualityAttribute && !isMultiDimArray)
{
var propertyLocation = property.Locations.FirstOrDefault();
@@ -140,6 +166,14 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb
}
}
+ private static bool IsCollectionOrEqualityAttribute(string? className) =>
+ className is "SequenceEqualityAttribute"
+ or "HashSetEqualityAttribute"
+ or "DictionaryEqualityAttribute"
+ or "EqualityComparerAttribute"
+ or "ReferenceEqualityAttribute"
+ or "StringEqualityAttribute";
+
private static bool HasEquatableAttribute(INamedTypeSymbol typeSymbol)
{
return typeSymbol.GetAttributes().Any(
@@ -193,6 +227,11 @@ private static bool ImplementsDictionary(ITypeSymbol type)
private static bool ImplementsEnumerable(ITypeSymbol type)
{
+ // Arrays (including multi-dimensional) are always valid for [SequenceEquality]:
+ // single-dim arrays implement IEnumerable; multi-dim use MultiDimensionalArrayEqualityComparer.
+ if (type is IArrayTypeSymbol)
+ return true;
+
return (type is INamedTypeSymbol named && IsEnumerable(named))
|| type.AllInterfaces.Any(IsEnumerable);
}
diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs
index ff54a97..54bf4f7 100644
--- a/src/Equatable.SourceGenerator/EquatableGenerator.cs
+++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs
@@ -13,25 +13,34 @@ public class EquatableGenerator : IIncrementalGenerator
private static readonly SymbolDisplayFormat NameAndNamespaces = new(SymbolDisplayGlobalNamespaceStyle.Omitted, SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, SymbolDisplayGenericsOptions.None);
public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ RegisterProvider(context,
+ fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute",
+ trackingName: "EquatableAttribute",
+ propertyFilter: IsIncluded);
+ }
+
+ public static void RegisterProvider(
+ IncrementalGeneratorInitializationContext context,
+ string fullyQualifiedMetadataName,
+ string trackingName,
+ Func propertyFilter,
+ Func? postProcessProperty = null)
{
var provider = context.SyntaxProvider
.ForAttributeWithMetadataName(
- fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute",
+ fullyQualifiedMetadataName: fullyQualifiedMetadataName,
predicate: SyntacticPredicate,
- transform: SemanticTransform
+ transform: (ctx, ct) => SemanticTransform(ctx, ct, propertyFilter, postProcessProperty)
)
- .Where(static context => context is not null)
- .WithTrackingName("EquatableAttribute");
+ .Where(static item => item is not null)
+ .WithTrackingName(trackingName);
- // output code
- var entityClasses = provider
- .Where(static item => item is not null);
-
- context.RegisterSourceOutput(entityClasses, Execute);
+ context.RegisterSourceOutput(provider, Execute);
}
- private static void Execute(SourceProductionContext context, EquatableClass? entityClass)
+ public static void Execute(SourceProductionContext context, EquatableClass? entityClass)
{
if (entityClass == null)
return;
@@ -49,7 +58,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken
|| (syntaxNode is StructDeclarationSyntax structDeclaration && !structDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword));
}
- private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
+ private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken, Func propertyFilter, Func? postProcessProperty = null)
{
if (context.TargetSymbol is not INamedTypeSymbol targetSymbol)
return null;
@@ -67,10 +76,10 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken
var baseEquals = GetBaseEqualsMethod(targetSymbol);
var baseEquatable = GetBaseEquatableType(targetSymbol);
- var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null);
+ var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null, propertyFilter);
var propertyArray = propertySymbols
- .Select(CreateProperty)
+ .Select(p => postProcessProperty != null ? postProcessProperty(p, CreateProperty(p)) : CreateProperty(p))
.ToArray() ?? [];
// the seed value of the hash code method
@@ -103,7 +112,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken
}
- private static IEnumerable GetProperties(INamedTypeSymbol targetSymbol, bool includeBaseProperties = true)
+ private static IEnumerable GetProperties(INamedTypeSymbol targetSymbol, bool includeBaseProperties, Func propertyFilter)
{
var properties = new Dictionary();
@@ -116,7 +125,7 @@ private static IEnumerable GetProperties(INamedTypeSymbol targe
.GetMembers()
.Where(m => m.Kind == SymbolKind.Property)
.OfType()
- .Where(IsIncluded)
+ .Where(propertyFilter)
.Where(p => !properties.ContainsKey(p.Name));
foreach (var propertySymbol in propertySymbols)
@@ -136,19 +145,10 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol)
var propertyType = propertySymbol.Type.ToDisplayString(FullyQualifiedNullableFormat);
var propertyName = propertySymbol.Name;
var isValueType = propertySymbol.Type.IsValueType;
- var defaultComparer = isValueType ? ComparerTypes.ValueType : ComparerTypes.Default;
+ var defaultComparer = isValueType && HasEqualityOperator(propertySymbol.Type) ? ComparerTypes.ValueType : ComparerTypes.Default;
- // look for custom equality
+ // look for an explicit equality attribute
var attributes = propertySymbol.GetAttributes();
- if (attributes.Length == 0)
- {
- return new EquatableProperty(
- propertyName,
- propertyType,
- defaultComparer);
- }
-
- // search for known attribute
foreach (var attribute in attributes)
{
(var comparerType, var comparerName, var comparerInstance) = GetComparer(attribute);
@@ -158,44 +158,363 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol)
var isValid = ValidateComparer(propertySymbol, comparerType);
if (!isValid)
+ return new EquatableProperty(propertyName, propertyType, defaultComparer);
+
+ // for collection attributes, check if the element type itself contains nested collections
+ // that need a composed comparer rather than EqualityComparer.Default.
+ // BuildArrayComparerExpression always returns a non-null expression for arrays
+ // (including multi-dimensional, which use MultiDimensionalArrayEqualityComparer).
+ if (comparerType is ComparerTypes.Dictionary or ComparerTypes.HashSet or ComparerTypes.Sequence)
{
- return new EquatableProperty(
- propertyName,
- propertyType,
- defaultComparer);
+ // enumKind is always propagated for explicit HashSet/Sequence annotations — the user's
+ // explicit declaration signals intent for ALL nested collection levels, not just the
+ // outermost one. [SequenceEquality] on List> means every level is
+ // order-sensitive; [HashSetEquality] on HashSet> means every level is
+ // order-insensitive. Without an explicit annotation (inference path), nested
+ // collections keep their own natural comparer.
+ var enumKind = comparerType is ComparerTypes.HashSet or ComparerTypes.Sequence
+ ? comparerType
+ : (ComparerTypes?)null;
+ string? expression = propertySymbol.Type switch
+ {
+ INamedTypeSymbol namedType => BuildCollectionComparerExpression(namedType, comparerType.Value, enumKind),
+ IArrayTypeSymbol arrayType when comparerType == ComparerTypes.HashSet
+ => BuildHashSetArrayComparerExpression(arrayType, enumKind),
+ IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType, enumKind),
+ _ => null
+ };
+ if (expression != null)
+ return new EquatableProperty(propertyName, propertyType, ComparerTypes.Expression, ComparerExpression: expression);
}
- return new EquatableProperty(
- propertyName,
- propertyType,
- comparerType.Value,
- comparerName,
- comparerInstance);
+ return new EquatableProperty(propertyName, propertyType, comparerType.Value, comparerName, comparerInstance);
}
- return new EquatableProperty(
- propertyName,
- propertyType,
- defaultComparer);
+ return new EquatableProperty(propertyName, propertyType, defaultComparer);
+ }
+
+ // Infers the appropriate equality comparer for a property based on its type shape.
+ // Called by adapter generators (DataContractEquatable, MessagePackEquatable) as a
+ // post-processing step so that collection/dictionary properties don't require explicit
+ // [DictionaryEquality] / [SequenceEquality] annotations.
+ // The base [Equatable] generator does NOT call this — developers must be explicit there.
+ public static EquatableProperty InferCollectionComparer(IPropertySymbol propertySymbol, EquatableProperty property)
+ {
+ // Only apply inference when no explicit equality attribute was already resolved
+ if (property.ComparerType != ComparerTypes.Default && property.ComparerType != ComparerTypes.ValueType)
+ return property;
+
+ var inferredExpression = propertySymbol.Type switch
+ {
+ IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType),
+ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfaces.Any(IsDictionary)
+ => BuildInferredCollectionExpression(namedType, ComparerTypes.Dictionary),
+ INamedTypeSymbol namedType when !IsString(namedType)
+ && (IsEnumerable(namedType) || namedType.AllInterfaces.Any(IsEnumerable))
+ => BuildInferredCollectionExpression(namedType, ComparerTypes.Sequence),
+ _ => null
+ };
+
+ if (inferredExpression != null)
+ return property with { ComparerType = ComparerTypes.Expression, ComparerExpression = inferredExpression };
+
+ return property;
+ }
+
+ // Like BuildCollectionComparerExpression but always emits a comparer even when element types are
+ // simple. Used by InferCollectionComparer so that adapter generators always produce structural
+ // equality for collection properties, not EqualityComparer.Default (reference equality).
+ private static string? BuildInferredCollectionExpression(INamedTypeSymbol collectionType, ComparerTypes kind)
+ {
+ var unwrapped = collectionType.IsGenericType
+ && collectionType.OriginalDefinition.SpecialType == SpecialType.None
+ && collectionType.Name == "Nullable"
+ ? collectionType.TypeArguments[0] as INamedTypeSymbol ?? collectionType
+ : collectionType;
+
+ INamedTypeSymbol? dictInterface = IsDictionary(unwrapped) ? unwrapped
+ : unwrapped.AllInterfaces.FirstOrDefault(IsDictionary);
+
+ INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped
+ : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable);
+
+ if (kind == ComparerTypes.Dictionary && dictInterface != null)
+ {
+ var keyType = dictInterface.TypeArguments[0];
+ var valueType = dictInterface.TypeArguments[1];
+ var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ var keyExpr = BuildElementComparerExpression(keyType)
+ ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default";
+ var valueExpr = BuildElementComparerExpression(valueType)
+ ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default";
+
+ var isReadOnly = IsReadOnlyDictionary(unwrapped)
+ || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary));
+
+ var comparerClass = isReadOnly
+ ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer"
+ : "global::Equatable.Comparers.DictionaryEqualityComparer";
+
+ return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})";
+ }
+
+ if (kind == ComparerTypes.Sequence && enumInterface != null)
+ {
+ var elementType = enumInterface.TypeArguments[0];
+ var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ var innerExpr = BuildElementComparerExpression(elementType);
+
+ if (innerExpr != null)
+ return $"new global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>({innerExpr})";
+
+ return $"global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>.Default";
+ }
+
+ return null;
+ }
+
+ // Returns a fully-qualified IEqualityComparer instance expression for a collection type
+ // when its element type is itself a collection (requires composition).
+ // Returns null when EqualityComparer.Default is sufficient (element is a plain type).
+ // Depth is unbounded — recursion terminates naturally when an element type is not a
+ // recognised collection interface (BuildElementComparerExpression returns null).
+ private static string? BuildCollectionComparerExpression(INamedTypeSymbol collectionType, ComparerTypes kind, ComparerTypes? enumKind = null)
+ {
+ // unwrap nullable wrapper
+ var unwrapped = collectionType.IsGenericType
+ && collectionType.OriginalDefinition.SpecialType == SpecialType.None
+ && collectionType.Name == "Nullable"
+ ? collectionType.TypeArguments[0] as INamedTypeSymbol ?? collectionType
+ : collectionType;
+
+ // find the collection interface and extract element type(s)
+ INamedTypeSymbol? dictInterface = IsDictionary(unwrapped) ? unwrapped
+ : unwrapped.AllInterfaces.FirstOrDefault(IsDictionary);
+
+ INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped
+ : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable);
+
+ if (kind == ComparerTypes.Dictionary && dictInterface != null)
+ {
+ var keyType = dictInterface.TypeArguments[0];
+ var valueType = dictInterface.TypeArguments[1];
+
+ var keyExpr = BuildElementComparerExpression(keyType, dictKind: kind);
+ var valueExpr = BuildElementComparerExpression(valueType, dictKind: kind);
+
+ var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ var isReadOnly = IsReadOnlyDictionary(unwrapped)
+ || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary));
+
+ // only compose when at least one argument needs a non-default comparer
+ if (keyExpr == null && valueExpr == null)
+ return null;
+
+ keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default";
+ valueExpr ??= $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default";
+
+ var comparerClass = isReadOnly
+ ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer"
+ : "global::Equatable.Comparers.DictionaryEqualityComparer";
+
+ return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})";
+ }
+
+ if ((kind == ComparerTypes.HashSet || kind == ComparerTypes.Sequence) && enumInterface != null)
+ {
+ var elementType = enumInterface.TypeArguments[0];
+ var elementExpr = BuildElementComparerExpression(elementType, enumKind: enumKind);
+
+ if (elementExpr == null)
+ return null;
+
+ var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var comparerClass = kind == ComparerTypes.HashSet
+ ? "global::Equatable.Comparers.HashSetEqualityComparer"
+ : "global::Equatable.Comparers.SequenceEqualityComparer";
+
+ return $"new {comparerClass}<{elementTypeFq}>({elementExpr})";
+ }
+
+ return null;
+ }
+
+ // Returns a comparer expression for a single element type, or null if EqualityComparer.Default suffices.
+ // Terminates naturally when elementType is not a recognised collection interface.
+ // visited guards against self-referential types (e.g. class A : IEnumerable).
+ // dictKind propagates the outer dictionary ordering intent into nested dicts.
+ // enumKind propagates the outer enumerable intent (HashSet/Sequence) into nested enumerables/arrays:
+ // when set, all nested List/array/HashSet levels use the same comparer class as the outermost
+ // explicit annotation, so [HashSetEquality] on List> makes every level order-insensitive.
+ private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary, ComparerTypes? enumKind = null)
+ {
+ if (elementType is IArrayTypeSymbol arrayType)
+ return enumKind == ComparerTypes.HashSet
+ ? BuildHashSetArrayComparerExpression(arrayType, enumKind)
+ : BuildArrayComparerExpression(arrayType, enumKind);
+
+ if (elementType is not INamedTypeSymbol named)
+ return null;
+
+ // string implements IEnumerable but must use default equality, not SequenceEqualityComparer
+ if (IsString(named))
+ return null;
+
+ visited ??= new HashSet(SymbolEqualityComparer.Default);
+ if (!visited.Add(elementType))
+ return null; // cycle detected
+
+ // always scan AllInterfaces — covers concrete types (List, HashSet, Dictionary, etc.)
+ // dictionary check takes priority over enumerable
+ var asDictInterface = IsDictionary(named) ? named
+ : named.AllInterfaces.FirstOrDefault(IsDictionary);
+
+ if (asDictInterface != null)
+ {
+ var isReadOnly = IsReadOnlyDictionary(named)
+ || (IsDictionary(named) is false && named.AllInterfaces.Any(IsReadOnlyDictionary));
+ return BuildDictComparerExpression(asDictInterface, isReadOnly, visited, dictKind);
+ }
+
+ var asEnumInterface = IsEnumerable(named) ? named
+ : named.AllInterfaces.FirstOrDefault(IsEnumerable);
+
+ if (asEnumInterface != null)
+ {
+ var innerType = asEnumInterface.TypeArguments[0];
+ var innerExpr = BuildElementComparerExpression(innerType, visited, dictKind, enumKind);
+ var innerTypeFq = innerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ // enumKind overrides the natural comparer choice for this nested level
+ string comparerClass;
+ if (enumKind == ComparerTypes.HashSet)
+ comparerClass = "global::Equatable.Comparers.HashSetEqualityComparer";
+ else if (enumKind == ComparerTypes.Sequence)
+ comparerClass = "global::Equatable.Comparers.SequenceEqualityComparer";
+ else
+ {
+ var isSet = named.AllInterfaces.Any(i => i is { Name: "ISet" or "IReadOnlySet", IsGenericType: true })
+ || named is { Name: "ISet" or "IReadOnlySet", IsGenericType: true };
+ comparerClass = isSet
+ ? "global::Equatable.Comparers.HashSetEqualityComparer"
+ : "global::Equatable.Comparers.SequenceEqualityComparer";
+ }
+
+ if (innerExpr != null)
+ return $"new {comparerClass}<{innerTypeFq}>({innerExpr})";
+
+ return $"{comparerClass}<{innerTypeFq}>.Default";
+ }
+
+ return null;
+ }
+
+ private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface, bool isReadOnly, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary)
+ {
+ var keyType = dictInterface.TypeArguments[0];
+ var valueType = dictInterface.TypeArguments[1];
+ var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ var keyExpr = BuildElementComparerExpression(keyType, visited, dictKind)
+ ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default";
+ var valueExpr = BuildElementComparerExpression(valueType, visited, dictKind)
+ ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default";
+
+ var comparerClass = isReadOnly
+ ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer"
+ : "global::Equatable.Comparers.DictionaryEqualityComparer";
+
+ return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})";
+ }
+
+ private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType, ComparerTypes? enumKind = null)
+ {
+ var elementType = arrayType.ElementType;
+ var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var innerExpr = BuildElementComparerExpression(elementType, enumKind: enumKind);
+
+ if (arrayType.Rank > 1)
+ {
+ // Multi-dimensional arrays don't implement IEnumerable; use the dedicated comparer
+ // that iterates via Array.GetEnumerator() with no LINQ or intermediate allocations.
+ if (innerExpr != null)
+ return $"new global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer<{elementTypeFq}>({innerExpr})";
+ return $"global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer<{elementTypeFq}>.Default";
+ }
+
+ // When enumKind overrides to HashSet, outer arrays also use HashSetEqualityComparer
+ var comparerClass = enumKind == ComparerTypes.HashSet
+ ? "global::Equatable.Comparers.HashSetEqualityComparer"
+ : "global::Equatable.Comparers.SequenceEqualityComparer";
+
+ if (innerExpr != null)
+ return $"new {comparerClass}<{elementTypeFq}>({innerExpr})";
+ return $"{comparerClass}<{elementTypeFq}>.Default";
+ }
+
+ private static string BuildHashSetArrayComparerExpression(IArrayTypeSymbol arrayType, ComparerTypes? enumKind = null)
+ {
+ var elementType = arrayType.ElementType;
+ var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var innerExpr = BuildElementComparerExpression(elementType, enumKind: enumKind);
+
+ // When enumKind overrides to Sequence, outer arrays also use SequenceEqualityComparer
+ var comparerClass = enumKind == ComparerTypes.Sequence
+ ? "global::Equatable.Comparers.SequenceEqualityComparer"
+ : "global::Equatable.Comparers.HashSetEqualityComparer";
+
+ if (innerExpr != null)
+ return $"new {comparerClass}<{elementTypeFq}>({innerExpr})";
+ return $"{comparerClass}<{elementTypeFq}>.Default";
+ }
+
+ private static bool IsReadOnlyDictionary(INamedTypeSymbol targetSymbol)
+ {
+ return targetSymbol is
+ {
+ Name: "IReadOnlyDictionary",
+ IsGenericType: true,
+ TypeArguments.Length: 2,
+ ContainingNamespace:
+ {
+ Name: "Generic",
+ ContainingNamespace:
+ {
+ Name: "Collections",
+ ContainingNamespace.Name: "System"
+ }
+ }
+ };
}
private static bool ValidateComparer(IPropertySymbol propertySymbol, ComparerTypes? comparerType)
{
// don't need to validate these types
- if (comparerType is null or ComparerTypes.Default or ComparerTypes.Reference or ComparerTypes.Custom)
+ if (comparerType is null or ComparerTypes.Default or ComparerTypes.Reference or ComparerTypes.Custom or ComparerTypes.Expression)
return true;
if (comparerType == ComparerTypes.String)
return IsString(propertySymbol.Type);
if (comparerType == ComparerTypes.Dictionary)
- return propertySymbol.Type.AllInterfaces.Any(IsDictionary);
+ return (propertySymbol.Type is INamedTypeSymbol nt && IsDictionary(nt))
+ || propertySymbol.Type.AllInterfaces.Any(IsDictionary);
if (comparerType == ComparerTypes.HashSet)
- return propertySymbol.Type.AllInterfaces.Any(IsEnumerable);
+ return propertySymbol.Type is IArrayTypeSymbol { Rank: 1 }
+ || (propertySymbol.Type is INamedTypeSymbol ntHs && IsEnumerable(ntHs))
+ || propertySymbol.Type.AllInterfaces.Any(IsEnumerable);
if (comparerType == ComparerTypes.Sequence)
- return propertySymbol.Type.AllInterfaces.Any(IsEnumerable);
+ return propertySymbol.Type is IArrayTypeSymbol
+ || (propertySymbol.Type is INamedTypeSymbol ntSeq && IsEnumerable(ntSeq))
+ || propertySymbol.Type.AllInterfaces.Any(IsEnumerable);
return true;
}
@@ -210,7 +529,7 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa
return className switch
{
- "DictionaryEqualityAttribute" => (ComparerTypes.Dictionary, null, null),
+ "DictionaryEqualityAttribute" => GetDictionaryComparer(attribute),
"HashSetEqualityAttribute" => (ComparerTypes.HashSet, null, null),
"ReferenceEqualityAttribute" => (ComparerTypes.Reference, null, null),
"SequenceEqualityAttribute" => (ComparerTypes.Sequence, null, null),
@@ -220,6 +539,11 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa
};
}
+ private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetDictionaryComparer(AttributeData? attribute)
+ {
+ return (ComparerTypes.Dictionary, null, null);
+ }
+
private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetStringComparer(AttributeData? attribute)
{
if (attribute == null || attribute.ConstructorArguments.Length != 1)
@@ -262,6 +586,9 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa
}
+ public static bool IsPublicInstanceProperty(IPropertySymbol propertySymbol) =>
+ !propertySymbol.IsIndexer && propertySymbol.DeclaredAccessibility == Accessibility.Public;
+
private static bool IsIncluded(IPropertySymbol propertySymbol)
{
var attributes = propertySymbol.GetAttributes();
@@ -295,7 +622,37 @@ private static bool IsKnownAttribute(AttributeData? attribute)
ContainingNamespace.Name: "Equatable"
}
};
+ }
+
+ // Recognises any *EquatableAttribute from any Equatable adapter namespace:
+ // - Equatable.Attributes (base [Equatable])
+ // - Equatable.Attributes.DataContract ([DataContractEquatable])
+ // - Equatable.Attributes.MessagePack ([MessagePackEquatable])
+ private static bool IsEquatableGeneratorAttribute(AttributeData? a)
+ {
+ if (a?.AttributeClass?.Name.EndsWith("EquatableAttribute") != true)
+ return false;
+ var ns = a.AttributeClass.ContainingNamespace;
+ // Equatable.Attributes.*
+ return ns?.ContainingNamespace?.Name == "Equatable" && ns.Name == "Attributes"
+ || ns?.ContainingNamespace?.ContainingNamespace?.Name == "Equatable"
+ && ns.ContainingNamespace?.Name == "Attributes";
+ }
+
+ private static bool HasEqualityOperator(ITypeSymbol typeSymbol)
+ {
+ // For Nullable, check the underlying type T
+ var typeToCheck = typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable
+ ? nullable.TypeArguments[0]
+ : typeSymbol;
+
+ // Primitive types and enums always support ==
+ if (typeToCheck.SpecialType != SpecialType.None || typeToCheck.TypeKind == TypeKind.Enum)
+ return true;
+
+ // Check for user-defined == operator
+ return typeToCheck.GetMembers("op_Equality").Any();
}
private static bool IsValueType(INamedTypeSymbol targetSymbol)
@@ -321,10 +678,7 @@ private static bool IsEnumerable(INamedTypeSymbol targetSymbol)
ContainingNamespace:
{
Name: "Collections",
- ContainingNamespace:
- {
- Name: "System"
- }
+ ContainingNamespace.Name: "System"
}
}
};
@@ -334,7 +688,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol)
{
return targetSymbol is
{
- Name: "IDictionary",
+ Name: "IDictionary" or "IReadOnlyDictionary",
IsGenericType: true,
TypeArguments.Length: 2,
TypeParameters.Length: 2,
@@ -344,10 +698,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol)
ContainingNamespace:
{
Name: "Collections",
- ContainingNamespace:
- {
- Name: "System"
- }
+ ContainingNamespace.Name: "System"
}
}
};
@@ -475,7 +826,7 @@ private static EquatableArray GetContainingTypes(INamedTypeSymb
return null;
var attributes = currentSymbol.GetAttributes();
- if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) && a.AttributeClass?.Name == "EquatableAttribute"))
+ if (attributes.Length > 0 && attributes.Any(IsEquatableGeneratorAttribute))
{
return currentSymbol;
}
diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs
index 8179dc5..ee41e73 100644
--- a/src/Equatable.SourceGenerator/EquatableWriter.cs
+++ b/src/Equatable.SourceGenerator/EquatableWriter.cs
@@ -135,7 +135,6 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab
.Append(", other.")
.Append(entityProperty.PropertyName)
.Append(")");
-
break;
case ComparerTypes.HashSet:
codeBuilder
@@ -187,6 +186,17 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab
.Append(entityProperty.PropertyName)
.Append(")");
+ break;
+ case ComparerTypes.Expression:
+ codeBuilder
+ .Append(" (")
+ .Append(entityProperty.ComparerExpression)
+ .Append(").Equals(")
+ .Append(entityProperty.PropertyName)
+ .Append(", other.")
+ .Append(entityProperty.PropertyName)
+ .Append(")");
+
break;
case ComparerTypes.ValueType:
codeBuilder
@@ -232,7 +242,7 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder
if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary))
{
codeBuilder
- .AppendLine("static bool DictionaryEquals(global::System.Collections.Generic.IDictionary? left, global::System.Collections.Generic.IDictionary? right)")
+ .AppendLine("static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right)")
.AppendLine("{")
.IncrementIndent()
.AppendLine("if (global::System.Object.ReferenceEquals(left, right))")
@@ -241,28 +251,51 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder
.AppendLine("if (left is null || right is null)")
.AppendLine(" return false;")
.AppendLine()
- .AppendLine("if (left.Count != right.Count)")
+ .AppendLine("if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection &&")
+ .AppendLine(" right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection &&")
+ .AppendLine(" leftCollection.Count != rightCollection.Count)")
.AppendLine(" return false;")
- .AppendLine();
-
- codeBuilder
+ .AppendLine()
+ .AppendLine("if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly)")
+ .AppendLine("{")
+ .IncrementIndent()
.AppendLine("foreach (var pair in left)")
.AppendLine("{")
.IncrementIndent()
- .AppendLine("if (!right.TryGetValue(pair.Key, out var value))")
+ .AppendLine("if (!rightReadOnly.TryGetValue(pair.Key, out var value))")
.AppendLine(" return false;")
.AppendLine()
.AppendLine("if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value))")
.AppendLine(" return false;")
- .AppendLine()
.DecrementIndent()
- .AppendLine("}"); // foreach
-
- codeBuilder
+ .AppendLine("}")
+ .AppendLine("return true;")
+ .DecrementIndent()
+ .AppendLine("}")
+ .AppendLine()
+ .AppendLine("if (right is global::System.Collections.Generic.IDictionary rightDictionary)")
+ .AppendLine("{")
+ .IncrementIndent()
+ .AppendLine("foreach (var pair in left)")
+ .AppendLine("{")
+ .IncrementIndent()
+ .AppendLine("if (!rightDictionary.TryGetValue(pair.Key, out var value))")
+ .AppendLine(" return false;")
.AppendLine()
+ .AppendLine("if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value))")
+ .AppendLine(" return false;")
+ .DecrementIndent()
+ .AppendLine("}")
.AppendLine("return true;")
.DecrementIndent()
.AppendLine("}")
+ .AppendLine()
+ .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(")
+ .AppendLine(" global::System.Linq.Enumerable.OrderBy(left, p => p.Key),")
+ .AppendLine(" global::System.Linq.Enumerable.OrderBy(right, p => p.Key),")
+ .AppendLine(" global::System.Collections.Generic.EqualityComparer>.Default);")
+ .DecrementIndent()
+ .AppendLine("}")
.AppendLine();
}
@@ -465,6 +498,14 @@ private static void GenerateHashCode(IndentedStringBuilder codeBuilder, Equatabl
.Append(entityProperty.PropertyName)
.AppendLine("!);");
break;
+ case ComparerTypes.Expression:
+ codeBuilder
+ .Append("hashCode = (hashCode * -1521134295) + (")
+ .Append(entityProperty.ComparerExpression)
+ .Append(").GetHashCode(")
+ .Append(entityProperty.PropertyName)
+ .AppendLine("!);");
+ break;
case ComparerTypes.ValueType:
codeBuilder
.Append("hashCode = (hashCode * -1521134295) + ")
@@ -503,28 +544,18 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder,
if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary))
{
codeBuilder
- .AppendLine("static int DictionaryHashCode(global::System.Collections.Generic.IDictionary? items)")
+ .AppendLine("static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items)")
.AppendLine("{")
.IncrementIndent()
.AppendLine("if (items is null)")
.AppendLine(" return 0;")
- .AppendLine();
-
- codeBuilder
- .Append("int hashCode = ")
- .Append(entityClass.SeedHash)
- .AppendLine(";")
.AppendLine()
- .AppendLine("// sort by key to ensure dictionary with different order are the same")
- .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d.Key))")
- .AppendLine("{")
- .IncrementIndent()
- .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!);")
- .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!);")
- .DecrementIndent()
- .AppendLine("}"); // foreach
-
- codeBuilder
+ .AppendLine("int hashCode = 1;")
+ .AppendLine()
+ .AppendLine("foreach (var item in items)")
+ .AppendLine(" hashCode += global::System.HashCode.Combine(")
+ .AppendLine(" global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!),")
+ .AppendLine(" global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!));")
.AppendLine()
.AppendLine("return hashCode;")
.DecrementIndent()
@@ -541,13 +572,10 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder,
.AppendLine("if (items is null)")
.AppendLine(" return 0;")
.AppendLine()
- .Append("int hashCode = ")
- .Append(entityClass.SeedHash)
- .AppendLine(";")
+ .AppendLine("int hashCode = 1;")
.AppendLine()
- .AppendLine("// sort to ensure set with different order are the same")
- .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d))")
- .AppendLine(" hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);")
+ .AppendLine("foreach (var item in items)")
+ .AppendLine(" hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);")
.AppendLine()
.AppendLine("return hashCode;")
.DecrementIndent()
diff --git a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs
index c27caa5..8839db2 100644
--- a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs
+++ b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs
@@ -9,5 +9,7 @@ public enum ComparerTypes
Sequence,
String,
ValueType,
- Custom
+ Custom,
+ // a fully-composed IEqualityComparer expression built by the generator for nested collections
+ Expression
}
diff --git a/src/Equatable.SourceGenerator/Models/EquatableProperty.cs b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs
index 652a353..7c7a753 100644
--- a/src/Equatable.SourceGenerator/Models/EquatableProperty.cs
+++ b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs
@@ -5,4 +5,6 @@ public record EquatableProperty(
string PropertyType,
ComparerTypes ComparerType = ComparerTypes.Default,
string? ComparerName = null,
- string? ComparerInstance = null);
+ string? ComparerInstance = null,
+ // fully-composed comparer instance expression for ComparerTypes.Expression
+ string? ComparerExpression = null);
diff --git a/test/Equatable.Entities/Equatable.Entities.csproj b/test/Equatable.Entities/Equatable.Entities.csproj
index 3ecf986..31ede8b 100644
--- a/test/Equatable.Entities/Equatable.Entities.csproj
+++ b/test/Equatable.Entities/Equatable.Entities.csproj
@@ -12,15 +12,27 @@
+
+
+
+
Analyzer
false
+
+ Analyzer
+ false
+
+
+ Analyzer
+ false
+
diff --git a/test/Equatable.Entities/LookupTable.cs b/test/Equatable.Entities/LookupTable.cs
new file mode 100644
index 0000000..eabb54e
--- /dev/null
+++ b/test/Equatable.Entities/LookupTable.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+using Equatable.Attributes;
+
+namespace Equatable.Entities;
+
+[Equatable]
+public partial class LookupTable
+{
+ [DictionaryEquality]
+ public IReadOnlyDictionary? FlatEntries { get; set; }
+
+ // nested: generator auto-composes ReadOnlyDictionaryEqualityComparer for the value type
+ [DictionaryEquality]
+ public IReadOnlyDictionary>? NestedEntries { get; set; }
+}
diff --git a/test/Equatable.Entities/NestedCollections.cs b/test/Equatable.Entities/NestedCollections.cs
new file mode 100644
index 0000000..6df2523
--- /dev/null
+++ b/test/Equatable.Entities/NestedCollections.cs
@@ -0,0 +1,94 @@
+using System.Collections.Generic;
+using Equatable.Attributes;
+
+namespace Equatable.Entities;
+
+///
+/// Covers the cross-product of outer × inner collection shapes for auto-composed comparers.
+/// All combinations up to 3 levels of nesting.
+///
+[Equatable]
+public partial class NestedCollections
+{
+ // ── 2-level: Dict outer ────────────────────────────────────────────────
+
+ // Dict>
+ [DictionaryEquality]
+ public Dictionary>? DictOfLists { get; set; }
+
+ // Dict>
+ [DictionaryEquality]
+ public Dictionary>? DictOfSets { get; set; }
+
+ // Dict>
+ [DictionaryEquality]
+ public Dictionary>? DictOfDicts { get; set; }
+
+ // ── 2-level: List outer ────────────────────────────────────────────────
+
+ // List>
+ [SequenceEquality]
+ public List>? ListOfDicts { get; set; }
+
+ // List>
+ [SequenceEquality]
+ public List>? ListOfSets { get; set; }
+
+ // List>
+ [SequenceEquality]
+ public List>? ListOfLists { get; set; }
+
+ // ── 2-level: HashSet outer ────────────────────────────────────────────
+
+ // HashSet> — set of sequences (unusual but valid)
+ [HashSetEquality]
+ public HashSet>? SetOfLists { get; set; }
+
+ // HashSet>
+ [HashSetEquality]
+ public HashSet>? SetOfDicts { get; set; }
+
+ // ── 3-level ────────────────────────────────────────────────────────────
+
+ // Dict>>
+ [DictionaryEquality]
+ public Dictionary>>? ThreeLevelNested { get; set; }
+
+ // Dict>>
+ [DictionaryEquality]
+ public Dictionary>>? DictOfListOfSets { get; set; }
+
+ // Dict>>
+ [DictionaryEquality]
+ public Dictionary>>? DictOfListOfDicts { get; set; }
+
+ // List>>
+ [SequenceEquality]
+ public List>>? ListOfDictOfLists { get; set; }
+
+ // List>>
+ [SequenceEquality]
+ public List>>? ListOfDictOfSets { get; set; }
+
+ // List>>
+ [SequenceEquality]
+ public List>>? ListOfListOfDicts { get; set; }
+
+ // ── Arrays ─────────────────────────────────────────────────────────────
+
+ // int[] — plain array
+ [SequenceEquality]
+ public int[]? FlatArray { get; set; }
+
+ // int[][] — array of arrays
+ [SequenceEquality]
+ public int[][]? ArrayOfArrays { get; set; }
+
+ // Dictionary[] — array of dicts
+ [SequenceEquality]
+ public Dictionary[]? ArrayOfDicts { get; set; }
+
+ // int[][] as a value in a dict
+ [DictionaryEquality]
+ public Dictionary? DictOfArrays { get; set; }
+}
diff --git a/test/Equatable.Entities/OrderDataContract.cs b/test/Equatable.Entities/OrderDataContract.cs
new file mode 100644
index 0000000..5cfdc87
--- /dev/null
+++ b/test/Equatable.Entities/OrderDataContract.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using Equatable.Attributes.DataContract;
+
+namespace Equatable.Entities;
+
+[DataContract]
+[DataContractEquatable]
+public partial class OrderDataContract
+{
+ [DataMember(Order = 0)]
+ public int Id { get; set; }
+
+ [DataMember(Order = 1)]
+ public string? Name { get; set; }
+
+ // not included — no [DataMember]
+ public string? InternalNote { get; set; }
+
+ [IgnoreDataMember]
+ public string? IgnoredField { get; set; }
+}
diff --git a/test/Equatable.Entities/OrderDataContractNested.cs b/test/Equatable.Entities/OrderDataContractNested.cs
new file mode 100644
index 0000000..4c4e41d
--- /dev/null
+++ b/test/Equatable.Entities/OrderDataContractNested.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using Equatable.Attributes.DataContract;
+
+namespace Equatable.Entities;
+
+[DataContract]
+[DataContractEquatable]
+public partial class OrderDataContractNested
+{
+ [DataMember(Order = 0)]
+ public int Id { get; set; }
+
+ // Dict> — inferred: DictionaryEqualityComparer(…, SequenceEqualityComparer.Default)
+ [DataMember(Order = 1)]
+ public Dictionary>? TagGroups { get; set; }
+
+ // Dict> — inferred: DictionaryEqualityComparer(…, DictionaryEqualityComparer(…))
+ [DataMember(Order = 2)]
+ public Dictionary>? NestedMap { get; set; }
+
+ // List> — inferred: SequenceEqualityComparer(DictionaryEqualityComparer(…))
+ [DataMember(Order = 3)]
+ public List>? Records { get; set; }
+
+ // IReadOnlyDictionary> — inferred: ReadOnlyDictionaryEqualityComparer(…, SequenceEqualityComparer.Default)
+ [DataMember(Order = 4)]
+ public IReadOnlyDictionary>? ReadOnlyTagGroups { get; set; }
+}
diff --git a/test/Equatable.Entities/SerializedRecord.cs b/test/Equatable.Entities/SerializedRecord.cs
new file mode 100644
index 0000000..9ed7503
--- /dev/null
+++ b/test/Equatable.Entities/SerializedRecord.cs
@@ -0,0 +1,21 @@
+using Equatable.Attributes.MessagePack;
+using MessagePack;
+
+namespace Equatable.Entities;
+
+[MessagePackEquatable]
+[MessagePackObject]
+public partial class SerializedRecord
+{
+ [Key(0)]
+ public int Id { get; set; }
+
+ [Key(1)]
+ public double Score { get; set; }
+
+ [IgnoreMember]
+ public string? Metadata { get; set; }
+
+ // not included — no [Key]
+ public string? Extra { get; set; }
+}
diff --git a/test/Equatable.Entities/SerializedRecordNested.cs b/test/Equatable.Entities/SerializedRecordNested.cs
new file mode 100644
index 0000000..778df74
--- /dev/null
+++ b/test/Equatable.Entities/SerializedRecordNested.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using Equatable.Attributes.MessagePack;
+using MessagePack;
+
+namespace Equatable.Entities;
+
+[MessagePackObject]
+[MessagePackEquatable]
+public partial class SerializedRecordNested
+{
+ [Key(0)]
+ public int Id { get; set; }
+
+ // Dict> — inferred: DictionaryEqualityComparer(…, SequenceEqualityComparer.Default)
+ [Key(1)]
+ public Dictionary>? TagGroups { get; set; }
+
+ // Dict