Skip to content

Commit 26b1f73

Browse files
committed
feat(ToString): add configurable collection formatting to ToString generator
- Introduce `CollectionFormat` enum for `ToString` generation, enabling customizable rendering of collection properties (`Count`, `Elements`, `TypeAndCount`). - Add unit tests for all modes and null handling. - Update attribute, generator logic, and `README`.
1 parent 4250e52 commit 26b1f73

5 files changed

Lines changed: 340 additions & 15 deletions

File tree

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ UserProfile second = builder.WithId(2).Build();
663663

664664
### 6. ToString Generator
665665

666-
Generates a `ToString()` override for classes, returning a formatted string containing the class name and all (or selected) public readable property values in the format `ClassName { Prop1 = val1, Prop2 = val2 }`.
666+
Generates a `ToString()` override for classes, returning a formatted string containing the class name and all (or selected) public readable property values in the format `ClassName { Prop1 = val1, Prop2 = val2 }`. Collection properties (lists, arrays, dictionaries) are rendered with configurable formatting instead of the default type name.
667667

668668
#### Attribute
669669

@@ -674,6 +674,17 @@ Generates a `ToString()` override for classes, returning a formatted string cont
674674
**Parameters:**
675675

676676
- `excludeProperties` - Optional list of property names to exclude from the generated `ToString()` output
677+
- `CollectionFormat` - Controls how collection properties are rendered (named argument, default: `CollectionFormat.Count`)
678+
679+
**Collection Format Modes:**
680+
681+
| Mode | Description | Example output |
682+
| ------------------------------------ | ----------------------------------------------------------------------- | ------------------------------ |
683+
| `CollectionFormat.Count` _(default)_ | Shows element count only | `Count = 3` |
684+
| `CollectionFormat.Elements` | Inline elements for lists/arrays; `{key: value}` pairs for dictionaries | `[Alice, Bob]` / `{Alice: 10}` |
685+
| `CollectionFormat.TypeAndCount` | Shows runtime type name and element count | `List\`1 (Count = 3)` |
686+
687+
`null` collections are always rendered as `null` regardless of the format mode.
677688

678689
#### Example
679690

@@ -698,6 +709,23 @@ public partial class User
698709
public string PasswordHash { get; set; }
699710
public string InternalNotes { get; set; }
700711
}
712+
713+
// Collection properties — explicit Elements mode for full display
714+
[GenerateToString(CollectionFormat = CollectionFormat.Elements)]
715+
public partial class Team
716+
{
717+
public string Name { get; set; }
718+
public List<string> Members { get; set; }
719+
public Dictionary<string, int> Scores { get; set; }
720+
}
721+
722+
// Collection properties — Count mode
723+
[GenerateToString(CollectionFormat = CollectionFormat.Count)]
724+
public partial class TeamSummary
725+
{
726+
public string Name { get; set; }
727+
public List<string> Members { get; set; }
728+
}
701729
```
702730

703731
#### Generated Code
@@ -708,6 +736,11 @@ The generator creates a `ToString()` override on the partial class that:
708736
- Excludes properties specified in the attribute parameter
709737
- Formats output as `ClassName { Prop1 = val1, Prop2 = val2 }`
710738
- Returns `ClassName { }` when all properties are excluded
739+
- For collection properties, emits inline formatting based on `CollectionFormat`:
740+
- `Count`: `Count = N`
741+
- `Elements`: `[item1, item2]` for arrays/lists, `{key1: val1, key2: val2}` for dictionaries
742+
- `TypeAndCount`: `TypeName (Count = N)`
743+
- For dictionary properties in `Elements` mode, emits a private generic helper `DictionaryToString<TKey, TValue>`
711744

712745
#### Usage Example
713746

@@ -733,6 +766,25 @@ var user = new User
733766

734767
Console.WriteLine(user.ToString());
735768
// Output: User { Id = 42, Name = John Doe }
769+
770+
var team = new Team
771+
{
772+
Name = "Alpha",
773+
Members = new List<string> { "Alice", "Bob", "Charlie" },
774+
Scores = new Dictionary<string, int> { ["Alice"] = 10, ["Bob"] = 20 }
775+
};
776+
777+
Console.WriteLine(team.ToString());
778+
// Output: Team { Name = Alpha, Members = [Alice, Bob, Charlie], Scores = {Alice: 10, Bob: 20} }
779+
780+
var summary = new TeamSummary
781+
{
782+
Name = "Beta",
783+
Members = new List<string> { "Alice", "Bob", "Charlie" }
784+
};
785+
786+
Console.WriteLine(summary.ToString());
787+
// Output: TeamSummary { Name = Beta, Members = Count = 3 }
736788
```
737789

738790
### 7. Validator Generator

src/BB84.SourceGenerators/Attributes/GenerateToStringAttribute.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@
55
// LICENSE file in the root directory of this source tree.
66
namespace BB84.SourceGenerators.Attributes;
77

8+
/// <summary>
9+
/// Specifies how collection properties are formatted in the generated <c>ToString()</c> output.
10+
/// </summary>
11+
internal enum CollectionFormat
12+
{
13+
/// <summary>
14+
/// Shows only the element count: <c>Count = 3</c>.
15+
/// </summary>
16+
Count = 0,
17+
18+
/// <summary>
19+
/// Formats elements inline: <c>[item1, item2]</c> for list-like collections and
20+
/// <c>{key1: val1, key2: val2}</c> for dictionaries. This is the default.
21+
/// </summary>
22+
Elements = 1,
23+
24+
/// <summary>
25+
/// Shows the runtime type name and element count: e.g. <c>List`1 (Count = 3)</c>.
26+
/// </summary>
27+
TypeAndCount = 2,
28+
}
29+
830
/// <summary>
931
/// Indicates that the decorated <c>class</c> should have a <c>ToString()</c> override generated for it.
1032
/// The generated method returns a formatted string containing the class name and all (or selected)
@@ -24,4 +46,10 @@ public GenerateToStringAttribute() : this([])
2446
/// Gets the property names to exclude from the generated <c>ToString()</c> output.
2547
/// </summary>
2648
public string[] ExcludeProperties => excludeProperties;
49+
50+
/// <summary>
51+
/// Gets or sets how collection properties are formatted in the generated <c>ToString()</c> output.
52+
/// Defaults to <see cref="CollectionFormat.Count"/>.
53+
/// </summary>
54+
public CollectionFormat CollectionFormat { get; set; } = CollectionFormat.Count;
2755
}

src/BB84.SourceGenerators/Helpers/GeneratorHelpers.cs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,16 @@ internal static bool TryCreateContext(
254254
/// When not <see langword="null"/>, checks whether the property type is decorated with the specified attribute
255255
/// and sets <see cref="PropertyDescriptor.IsCloneable"/> accordingly.
256256
/// </param>
257+
/// <param name="detectCollections">
258+
/// When <see langword="true"/>, detects collection types and sets <see cref="PropertyDescriptor.CollectionKind"/> accordingly.
259+
/// </param>
257260
/// <returns>An immutable array of <see cref="PropertyDescriptor"/> instances.</returns>
258261
internal static ImmutableArray<PropertyDescriptor> GetPropertyDescriptors(
259262
INamedTypeSymbol classSymbol,
260263
HashSet<string>? excludedProperties = null,
261264
bool requireSetter = false,
262-
string? cloneableAttributeName = null)
265+
string? cloneableAttributeName = null,
266+
bool detectCollections = false)
263267
{
264268
ImmutableArray<PropertyDescriptor>.Builder builder = ImmutableArray.CreateBuilder<PropertyDescriptor>();
265269

@@ -293,8 +297,11 @@ internal static ImmutableArray<PropertyDescriptor> GetPropertyDescriptors(
293297
break;
294298
}
295299
}
300+
}
296301

297-
// Detect collection types
302+
// Detect collection types when explicitly requested or when cloneability checks are active
303+
if (cloneableAttributeName is not null || detectCollections)
304+
{
298305
(collectionKind, elementTypeName, isElementCloneable, dictionaryValueTypeName, isDictionaryValueCloneable) =
299306
DetectCollectionKind(propertySymbol.Type, cloneableAttributeName);
300307
}
@@ -309,7 +316,8 @@ internal static ImmutableArray<PropertyDescriptor> GetPropertyDescriptors(
309316
/// Detects the collection kind of a type symbol and extracts element type information.
310317
/// </summary>
311318
private static (CollectionKind Kind, string? ElementTypeName, bool IsElementCloneable, string? DictionaryValueTypeName, bool IsDictionaryValueCloneable) DetectCollectionKind(
312-
ITypeSymbol typeSymbol, string cloneableAttributeName)
319+
ITypeSymbol typeSymbol,
320+
string? cloneableAttributeName)
313321
{
314322
// Unwrap nullable
315323
if (typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable)
@@ -319,7 +327,7 @@ private static (CollectionKind Kind, string? ElementTypeName, bool IsElementClon
319327
if (typeSymbol is IArrayTypeSymbol arrayType)
320328
{
321329
string elementType = arrayType.ElementType.ToFullyQualifiedDisplayString();
322-
bool elementCloneable = HasAttribute(arrayType.ElementType, cloneableAttributeName);
330+
bool elementCloneable = cloneableAttributeName is not null && HasAttribute(arrayType.ElementType, cloneableAttributeName);
323331
return (CollectionKind.Array, elementType, elementCloneable, null, false);
324332
}
325333

@@ -332,23 +340,23 @@ private static (CollectionKind Kind, string? ElementTypeName, bool IsElementClon
332340
{
333341
string keyType = namedType.TypeArguments[0].ToFullyQualifiedDisplayString();
334342
string valueType = namedType.TypeArguments[1].ToFullyQualifiedDisplayString();
335-
bool valueCloneable = HasAttribute(namedType.TypeArguments[1], cloneableAttributeName);
343+
bool valueCloneable = cloneableAttributeName is not null && HasAttribute(namedType.TypeArguments[1], cloneableAttributeName);
336344
return (CollectionKind.Dictionary, keyType, false, valueType, valueCloneable);
337345
}
338346

339347
// List<T>
340348
if (originalDef == "System.Collections.Generic.List<T>")
341349
{
342350
string elementType = namedType.TypeArguments[0].ToFullyQualifiedDisplayString();
343-
bool elementCloneable = HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
351+
bool elementCloneable = cloneableAttributeName is not null && HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
344352
return (CollectionKind.List, elementType, elementCloneable, null, false);
345353
}
346354

347355
// ImmutableArray<T>
348356
if (originalDef == "System.Collections.Immutable.ImmutableArray<T>")
349357
{
350358
string elementType = namedType.TypeArguments[0].ToFullyQualifiedDisplayString();
351-
bool elementCloneable = HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
359+
bool elementCloneable = cloneableAttributeName is not null && HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
352360
return (CollectionKind.ImmutableArray, elementType, elementCloneable, null, false);
353361
}
354362

@@ -358,7 +366,7 @@ private static (CollectionKind Kind, string? ElementTypeName, bool IsElementClon
358366
or "System.Collections.Generic.IReadOnlyCollection<T>")
359367
{
360368
string elementType = namedType.TypeArguments[0].ToFullyQualifiedDisplayString();
361-
bool elementCloneable = HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
369+
bool elementCloneable = cloneableAttributeName is not null && HasAttribute(namedType.TypeArguments[0], cloneableAttributeName);
362370
return (CollectionKind.ReadOnlyCollection, elementType, elementCloneable, null, false);
363371
}
364372
}

src/BB84.SourceGenerators/ToStringGenerator.cs

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,33 @@ namespace BB84.SourceGenerators;
2222
[Generator(LanguageNames.CSharp)]
2323
public sealed class ToStringGenerator : IIncrementalGenerator
2424
{
25-
private static readonly string GeneratorAttributeName = typeof(GenerateToStringAttribute).FullName;
25+
private static readonly (string MetadataName, string FullName, string ShortName) AttributeNames =
26+
GeneratorHelpers.GetAttributeNames<GenerateToStringAttribute>();
2627

2728
/// <inheritdoc/>
2829
public void Initialize(IncrementalGeneratorInitializationContext context)
29-
=> GeneratorHelpers.RegisterClassGenerator(context, GeneratorAttributeName, Execute);
30+
=> GeneratorHelpers.RegisterClassGenerator(context, AttributeNames.MetadataName, Execute);
3031

3132
private void Execute(SourceProductionContext context, (ClassDeclarationSyntax ClassSyntax, SemanticModel SemanticModel)? input)
3233
{
3334
if (!GeneratorHelpers.TryCreateContext(input, out GeneratorContext ctx))
3435
return;
3536

37+
CollectionFormat collectionFormat = GetCollectionFormat(ctx.ClassDeclaration, ctx.SemanticModel);
3638
HashSet<string> excludedProperties = GeneratorHelpers.GetExcludedProperties(ctx.ClassDeclaration, ctx.SemanticModel, nameof(GenerateToStringAttribute));
37-
ImmutableArray<PropertyDescriptor> properties = GeneratorHelpers.GetPropertyDescriptors(ctx.ClassSymbol, excludedProperties);
39+
ImmutableArray<PropertyDescriptor> properties = GeneratorHelpers.GetPropertyDescriptors(ctx.ClassSymbol, excludedProperties, detectCollections: true);
3840

3941
SourceBuilder sb = new();
4042

4143
sb.AppendAutoGeneratedWarning(GeneratorNames.ToStringGeneratorFullName);
4244
sb.AppendNullableEnable();
45+
sb.AppendUsing(NamespaceNames.System);
46+
sb.AppendUsing(NamespaceNames.SystemCollectionsGeneric);
4347
sb.AppendLine();
4448
sb.OpenNamespace(ctx.NamespaceName);
4549
sb.OpenOuterClasses(ctx.OuterClasses);
4650

47-
AppendPartialClass(sb, ctx.ClassName, ctx.Accessibility, properties);
51+
AppendPartialClass(sb, ctx.ClassName, ctx.Accessibility, properties, collectionFormat);
4852

4953
sb.CloseOuterClasses(ctx.OuterClasses);
5054
sb.CloseNamespace();
@@ -56,19 +60,41 @@ private void Execute(SourceProductionContext context, (ClassDeclarationSyntax Cl
5660
context.AddSource(hintName, sb.ToString());
5761
}
5862

59-
private static void AppendPartialClass(SourceBuilder sb, string className, string accessibility, ImmutableArray<PropertyDescriptor> properties)
63+
private static void AppendPartialClass(SourceBuilder sb, string className, string accessibility, ImmutableArray<PropertyDescriptor> properties, CollectionFormat collectionFormat)
6064
{
65+
bool hasDictionary = collectionFormat == CollectionFormat.Elements
66+
&& properties.Any(static p => p.CollectionKind == CollectionKind.Dictionary);
67+
6168
sb.OpenClass(accessibility, className);
6269
sb.AppendLine("/// <inheritdoc/>");
6370
sb.AppendLine("public override string ToString()");
6471
sb.OpenBrace();
6572

6673
if (properties.Length == 0)
74+
{
6775
sb.AppendLine($"return \"{className} {{ }}\";");
76+
}
6877
else
78+
{
79+
foreach (PropertyDescriptor prop in properties)
80+
{
81+
if (prop.CollectionKind == CollectionKind.None)
82+
continue;
83+
84+
sb.AppendLine($"string {prop.Name.ToLowerInvariant()} = {BuildCollectionFormatExpression(prop, collectionFormat)};");
85+
}
86+
6987
sb.AppendLine($"return $\"{className} {{{{ {BuildFormatString(properties)} }}}}\";");
88+
}
7089

7190
sb.CloseBrace();
91+
92+
if (hasDictionary)
93+
{
94+
sb.AppendLine();
95+
AppendDictionaryFormatHelper(sb);
96+
}
97+
7298
sb.CloseClass();
7399
}
74100

@@ -81,9 +107,102 @@ private static string BuildFormatString(ImmutableArray<PropertyDescriptor> prope
81107
if (i > 0)
82108
format.Append(", ");
83109

84-
format.Append($"{properties[i].Name} = {{{properties[i].Name}}}");
110+
string token = properties[i].CollectionKind != CollectionKind.None
111+
? $"{properties[i].Name.ToLowerInvariant()}"
112+
: properties[i].Name;
113+
114+
format.Append($"{{nameof({properties[i].Name})}} = {{{token}}}");
85115
}
86116

87117
return format.ToString();
88118
}
119+
120+
private static string BuildCollectionFormatExpression(PropertyDescriptor prop, CollectionFormat format)
121+
{
122+
bool isImmutableArray = prop.CollectionKind == CollectionKind.ImmutableArray;
123+
bool isArray = prop.CollectionKind == CollectionKind.Array;
124+
bool isDictionary = prop.CollectionKind == CollectionKind.Dictionary;
125+
126+
// ImmutableArray<T> (non-nullable struct): IsValueType = true
127+
// ImmutableArray<T>? (Nullable<ImmutableArray<T>>): IsValueType = false
128+
bool isNullableImmutableArray = isImmutableArray && !prop.IsValueType;
129+
bool isNonNullableImmutableArray = isImmutableArray && prop.IsValueType;
130+
131+
// Arrays and ImmutableArrays use .Length; all other collection types use .Count
132+
bool usesLength = isArray || isImmutableArray;
133+
134+
// Expression for the non-null collection value
135+
string directAccess = isNullableImmutableArray ? $"{prop.Name}.Value" : prop.Name;
136+
string countAccess = usesLength ? $"{directAccess}.Length" : $"{directAccess}.Count";
137+
138+
// Null guard (null means no null check required for non-nullable value types)
139+
string? nullGuard = isNonNullableImmutableArray
140+
? null
141+
: isNullableImmutableArray
142+
? $"!{prop.Name}.HasValue"
143+
: $"{prop.Name} == null";
144+
145+
string WrapGuard(string valueExpr)
146+
=> nullGuard is not null ? $"{nullGuard} ? \"null\" : {valueExpr}" : valueExpr;
147+
148+
return format switch
149+
{
150+
CollectionFormat.Elements when isDictionary => $"DictionaryToString({prop.Name})",// The DictionaryToString helper handles null internally
151+
CollectionFormat.Elements => WrapGuard($"\"[\" + string.Join(\", \", {directAccess}) + \"]\""),
152+
CollectionFormat.Count => WrapGuard($"\"Count = \" + {countAccess}"),
153+
CollectionFormat.TypeAndCount => WrapGuard($"{directAccess}.GetType().Name + \" (Count = \" + {countAccess} + \")\""),
154+
_ => prop.Name,
155+
};
156+
}
157+
158+
private static void AppendDictionaryFormatHelper(SourceBuilder sb)
159+
{
160+
sb.AppendLine("private static string DictionaryToString<TKey, TValue>(IDictionary<TKey, TValue>? dictionary)");
161+
sb.OpenBrace();
162+
sb.AppendLine("if (dictionary == null)");
163+
sb.Indent();
164+
sb.AppendLine("return \"null\";");
165+
sb.Outdent();
166+
sb.AppendLine("List<string> parts = new List<string>(dictionary.Count);");
167+
sb.AppendLine("foreach (KeyValuePair<TKey, TValue> kvp in dictionary)");
168+
sb.Indent();
169+
sb.AppendLine("parts.Add(kvp.Key + \": \" + kvp.Value);");
170+
sb.Outdent();
171+
sb.AppendLine("return \"{\" + string.Join(\", \", parts) + \"}\";");
172+
sb.CloseBrace();
173+
}
174+
175+
private static CollectionFormat GetCollectionFormat(ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel)
176+
{
177+
foreach (AttributeListSyntax attributeList in classDeclaration.AttributeLists)
178+
{
179+
foreach (AttributeSyntax attribute in attributeList.Attributes)
180+
{
181+
string name = attribute.Name.ToString();
182+
183+
if (name != AttributeNames.ShortName && name != AttributeNames.FullName)
184+
continue;
185+
186+
if (attribute.ArgumentList is null)
187+
return CollectionFormat.Count;
188+
189+
foreach (AttributeArgumentSyntax arg in attribute.ArgumentList.Arguments)
190+
{
191+
if (arg.NameEquals?.Name.Identifier.Text != nameof(GenerateToStringAttribute.CollectionFormat))
192+
continue;
193+
194+
Optional<object?> value = semanticModel.GetConstantValue(arg.Expression);
195+
196+
if (value.HasValue && value.Value is int intValue)
197+
return (CollectionFormat)intValue;
198+
199+
break;
200+
}
201+
202+
return CollectionFormat.Count;
203+
}
204+
}
205+
206+
return CollectionFormat.Count;
207+
}
89208
}

0 commit comments

Comments
 (0)