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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ Generates a `ToString()` override for classes, returning a formatted string cont
- `excludeProperties` - Optional list of property names to exclude from the generated `ToString()` output
- `CollectionFormat` - Controls how collection properties are rendered (named argument, default: `CollectionFormat.Count`)
- `Separator` - Custom separator string between properties (named argument, default: `", "`)
- `Formattable` - When `true`, generates an `IFormattable` implementation so the class participates in composite formatting with custom `IFormatProvider` support (named argument, default: `false`)

**Per-Property Formatting:**

Expand Down Expand Up @@ -753,6 +754,15 @@ public partial class Config
public string Host { get; set; }
public int Port { get; set; }
}

// IFormattable implementation for culture-aware formatting
[GenerateToString(Formattable = true)]
public partial class Invoice
{
public string Customer { get; set; }
public decimal Total { get; set; }
public DateTime IssuedAt { get; set; }
}
```

#### Generated Code
Expand All @@ -769,6 +779,7 @@ The generator creates a `ToString()` override on the partial class that:
- `Elements`: `[item1, item2]` for arrays/lists, `{key1: val1, key2: val2}` for dictionaries
- `TypeAndCount`: `TypeName (Count = N)`
- For dictionary properties in `Elements` mode, emits a private generic helper `DictionaryToString<TKey, TValue>`
- When `Formattable = true`, implements `IFormattable` with `ToString(string?, IFormatProvider?)` — properties whose types implement `IFormattable` receive the `formatProvider`; others use their default `ToString()`

#### Usage Example

Expand Down Expand Up @@ -832,6 +843,20 @@ var config = new Config

Console.WriteLine(config.ToString());
// Output: Config { Host = localhost | Port = 8080 }

var invoice = new Invoice
{
Customer = "Acme Corp",
Total = 1234.56m,
IssuedAt = new DateTime(2025, 1, 15)
};

// IFormattable: pass a custom format provider
Console.WriteLine(invoice.ToString(null, CultureInfo.InvariantCulture));
// Output: Invoice { Customer = Acme Corp, Total = 1234.56, IssuedAt = 01/15/2025 00:00:00 }

// Works with string.Format and interpolated strings
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0}", invoice));
```

### 7. Validator Generator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ public GenerateToStringAttribute() : this([])
/// Defaults to <c>", "</c>.
/// </summary>
public string Separator { get; set; } = ", ";

/// <summary>
/// Gets or sets a value indicating whether to generate an <see cref="IFormattable"/> implementation.
/// When <see langword="true"/>, the class implements <see cref="IFormattable"/> and properties whose types
/// implement <see cref="IFormattable"/> receive the <c>formatProvider</c> parameter.
/// Defaults to <see langword="false"/>.
/// </summary>
public bool Formattable { get; set; }
}
24 changes: 23 additions & 1 deletion src/BB84.SourceGenerators/Helpers/GeneratorHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,36 @@ internal static ImmutableArray<PropertyDescriptor> GetPropertyDescriptors(
}
}

bool isFormattable = false;

if (toStringFormatAttributeName is not null)
{
ITypeSymbol effectiveType = propertySymbol.Type;

if (effectiveType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T
&& effectiveType is INamedTypeSymbol namedNullable)
{
effectiveType = namedNullable.TypeArguments[0];
}

foreach (INamedTypeSymbol iface in effectiveType.AllInterfaces)
{
if (iface.ToDisplayString() == "System.IFormattable")
{
isFormattable = true;
break;
}
}
}

// Detect collection types when explicitly requested or when cloneability checks are active
if (cloneableAttributeName is not null || detectCollections)
{
(collectionKind, elementTypeName, isElementCloneable, dictionaryValueTypeName, isDictionaryValueCloneable) =
DetectCollectionKind(propertySymbol.Type, cloneableAttributeName);
}

builder.Add(new PropertyDescriptor(propertySymbol.Name, typeName, isValueType, isNullable, isCloneable, collectionKind, elementTypeName, isElementCloneable, dictionaryValueTypeName, isDictionaryValueCloneable, formatString));
builder.Add(new PropertyDescriptor(propertySymbol.Name, typeName, isValueType, isNullable, isCloneable, collectionKind, elementTypeName, isElementCloneable, dictionaryValueTypeName, isDictionaryValueCloneable, formatString, isFormattable));
}

return builder.ToImmutable();
Expand Down
7 changes: 6 additions & 1 deletion src/BB84.SourceGenerators/Models/PropertyDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace BB84.SourceGenerators.Models;
/// Describes a property discovered during source generation, carrying metadata
/// needed by multiple generators (Equality, Cloneable, Builder, ToString).
/// </summary>
internal sealed record PropertyDescriptor(string Name, string TypeName, bool IsValueType, bool IsNullable, bool IsCloneable, CollectionKind CollectionKind = CollectionKind.None, string? ElementTypeName = null, bool IsElementCloneable = false, string? DictionaryValueTypeName = null, bool IsDictionaryValueCloneable = false, string? FormatString = null)
internal sealed record PropertyDescriptor(string Name, string TypeName, bool IsValueType, bool IsNullable, bool IsCloneable, CollectionKind CollectionKind = CollectionKind.None, string? ElementTypeName = null, bool IsElementCloneable = false, string? DictionaryValueTypeName = null, bool IsDictionaryValueCloneable = false, string? FormatString = null, bool IsFormattable = false)
{
/// <summary>
/// Gets the name of the property.
Expand Down Expand Up @@ -74,4 +74,9 @@ internal sealed record PropertyDescriptor(string Name, string TypeName, bool IsV
/// When not <see langword="null"/>, the generated <c>ToString()</c> uses this format specifier.
/// </summary>
public string? FormatString { get; } = FormatString;

/// <summary>
/// Gets a value indicating whether the property type implements <see cref="System.IFormattable"/>.
/// </summary>
public bool IsFormattable { get; } = IsFormattable;
}
132 changes: 117 additions & 15 deletions src/BB84.SourceGenerators/ToStringGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ private void Execute(SourceProductionContext context, (ClassDeclarationSyntax Cl

CollectionFormat collectionFormat = GetCollectionFormat(ctx.ClassDeclaration, ctx.SemanticModel);
string separator = GetSeparator(ctx.ClassDeclaration, ctx.SemanticModel);
bool formattable = GetFormattable(ctx.ClassDeclaration, ctx.SemanticModel);
HashSet<string> excludedProperties = GeneratorHelpers.GetExcludedProperties(ctx.ClassDeclaration, ctx.SemanticModel, nameof(GenerateToStringAttribute));
ImmutableArray<PropertyDescriptor> properties = GeneratorHelpers.GetPropertyDescriptors(ctx.ClassSymbol, excludedProperties, detectCollections: true, toStringFormatAttributeName: ToStringFormatAttributeName);

Expand All @@ -51,7 +52,7 @@ private void Execute(SourceProductionContext context, (ClassDeclarationSyntax Cl
sb.OpenNamespace(ctx.NamespaceName);
sb.OpenOuterClasses(ctx.OuterClasses);

AppendPartialClass(sb, ctx.ClassName, ctx.Accessibility, properties, collectionFormat, separator);
AppendPartialClass(sb, ctx.ClassName, ctx.Accessibility, properties, collectionFormat, separator, formattable);

sb.CloseOuterClasses(ctx.OuterClasses);
sb.CloseNamespace();
Expand All @@ -63,35 +64,73 @@ private void Execute(SourceProductionContext context, (ClassDeclarationSyntax Cl
context.AddSource(hintName, sb.ToString());
}

private static void AppendPartialClass(SourceBuilder sb, string className, string accessibility, ImmutableArray<PropertyDescriptor> properties, CollectionFormat collectionFormat, string separator)
private static void AppendPartialClass(SourceBuilder sb, string className, string accessibility, ImmutableArray<PropertyDescriptor> properties, CollectionFormat collectionFormat, string separator, bool formattable)
{
bool hasDictionary = collectionFormat == CollectionFormat.Elements
&& properties.Any(static p => p.CollectionKind == CollectionKind.Dictionary);

sb.OpenClass(accessibility, className);
sb.AppendLine("/// <inheritdoc/>");
sb.AppendLine("public override string ToString()");
sb.OpenBrace();
if (formattable)
sb.OpenClass(accessibility, className, "IFormattable");
else
sb.OpenClass(accessibility, className);

if (properties.Length == 0)
if (formattable)
{
sb.AppendLine($"return \"{className} {{ }}\";");
sb.AppendLine("/// <inheritdoc/>");
sb.AppendLine("public override string ToString()");
sb.Indent();
sb.AppendLine("=> ToString(null, null);");
sb.Outdent();
sb.AppendLine();
sb.AppendLine("/// <inheritdoc/>");
sb.AppendLine("public string ToString(string? format, IFormatProvider? formatProvider)");
sb.OpenBrace();

if (properties.Length == 0)
{
sb.AppendLine($"return \"{className} {{ }}\";");
}
else
{
foreach (PropertyDescriptor prop in properties)
{
if (prop.CollectionKind == CollectionKind.None)
continue;

sb.AppendLine($"string {prop.Name.ToLowerInvariant()} = {BuildCollectionFormatExpression(prop, collectionFormat)};");
}

sb.AppendLine($"return $\"{className} {{{{ {BuildFormattableFormatString(properties, separator)} }}}}\";");
}

sb.CloseBrace();
}
else
{
foreach (PropertyDescriptor prop in properties)
sb.AppendLine("/// <inheritdoc/>");
sb.AppendLine("public override string ToString()");
sb.OpenBrace();

if (properties.Length == 0)
{
if (prop.CollectionKind == CollectionKind.None)
continue;
sb.AppendLine($"return \"{className} {{ }}\";");
}
else
{
foreach (PropertyDescriptor prop in properties)
{
if (prop.CollectionKind == CollectionKind.None)
continue;

sb.AppendLine($"string {prop.Name.ToLowerInvariant()} = {BuildCollectionFormatExpression(prop, collectionFormat)};");
sb.AppendLine($"string {prop.Name.ToLowerInvariant()} = {BuildCollectionFormatExpression(prop, collectionFormat)};");
}

sb.AppendLine($"return $\"{className} {{{{ {BuildFormatString(properties, separator)} }}}}\";");
}

sb.AppendLine($"return $\"{className} {{{{ {BuildFormatString(properties, separator)} }}}}\";");
sb.CloseBrace();
}

sb.CloseBrace();

if (hasDictionary)
{
sb.AppendLine();
Expand Down Expand Up @@ -122,6 +161,35 @@ private static string BuildFormatString(ImmutableArray<PropertyDescriptor> prope
return format.ToString();
}

private static string BuildFormattableFormatString(ImmutableArray<PropertyDescriptor> properties, string separator)
{
StringBuilder format = new();

for (int i = 0; i < properties.Length; i++)
{
if (i > 0)
format.Append(separator);

format.Append($"{{nameof({properties[i].Name})}} = ");

if (properties[i].CollectionKind != CollectionKind.None)
{
format.Append($"{{{properties[i].Name.ToLowerInvariant()}}}");
}
else if (properties[i].IsFormattable)
{
string nullConditional = properties[i].IsNullable ? "?" : "";
format.Append($"{{{properties[i].Name}{nullConditional}.ToString(format, formatProvider)}}");
}
else
{
format.Append($"{{{properties[i].Name}}}");
}
}

return format.ToString();
}

private static string BuildCollectionFormatExpression(PropertyDescriptor prop, CollectionFormat format)
{
bool isImmutableArray = prop.CollectionKind == CollectionKind.ImmutableArray;
Expand Down Expand Up @@ -177,6 +245,40 @@ private static void AppendDictionaryFormatHelper(SourceBuilder sb)
sb.CloseBrace();
}

private static bool GetFormattable(ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel)
{
foreach (AttributeListSyntax attributeList in classDeclaration.AttributeLists)
{
foreach (AttributeSyntax attribute in attributeList.Attributes)
{
string name = attribute.Name.ToString();

if (name != AttributeNames.ShortName && name != AttributeNames.FullName)
continue;

if (attribute.ArgumentList is null)
return false;

foreach (AttributeArgumentSyntax arg in attribute.ArgumentList.Arguments)
{
if (arg.NameEquals?.Name.Identifier.Text != nameof(GenerateToStringAttribute.Formattable))
continue;

Optional<object?> value = semanticModel.GetConstantValue(arg.Expression);

if (value.HasValue && value.Value is bool boolValue)
return boolValue;

break;
}

return false;
}
}

return false;
}

private static string GetSeparator(ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel)
{
foreach (AttributeListSyntax attributeList in classDeclaration.AttributeLists)
Expand Down
53 changes: 53 additions & 0 deletions tests/BB84.SourceGenerators.Tests/ToStringGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
//
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
using System.Globalization;

using BB84.SourceGenerators.Attributes;

namespace BB84.SourceGenerators.Tests;
Expand Down Expand Up @@ -282,6 +284,49 @@ public void ToStringShouldUseDefaultSeparatorWhenNotSpecified()

Assert.Contains(", ", result);
}

[TestMethod]
public void ToStringFormattableShouldDelegateToParameterlessOverride()
{
ToStringFormattableTestModel model = new()
{
Name = "Order",
Total = 1234.56m,
CreatedAt = new DateTime(2025, 1, 15)
};

string? result = model.ToString();
string? formattableResult = ((IFormattable)model).ToString(null, null);

Assert.AreEqual(result, formattableResult);
}

[TestMethod]
public void ToStringFormattableShouldRespectFormatProvider()
{
ToStringFormattableTestModel model = new()
{
Name = "Order",
Total = 1234.56m,
CreatedAt = new DateTime(2025, 1, 15)
};

string? result = model.ToString(null, CultureInfo.InvariantCulture);
string expected = nameof(ToStringFormattableTestModel) + " { " +
"Name = Order, " +
"Total = " + model.Total.ToString(null, CultureInfo.InvariantCulture) + ", " +
"CreatedAt = " + model.CreatedAt.ToString(null, CultureInfo.InvariantCulture) + " }";

Assert.AreEqual(expected, result);
}

[TestMethod]
public void ToStringFormattableShouldImplementIFormattable()
{
ToStringFormattableTestModel model = new();

Assert.IsInstanceOfType<IFormattable>(model);
}
}

[GenerateToString]
Expand Down Expand Up @@ -376,3 +421,11 @@ public partial class ToStringCustomSeparatorTestModel
public string? Host { get; set; }
public int Port { get; set; }
}

[GenerateToString(Formattable = true)]
public partial class ToStringFormattableTestModel
{
public string? Name { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
}
Loading