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
29 changes: 28 additions & 1 deletion docs/wiki/Attribute-Data-Models.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The generator emits marker attributes into your compilation:
| --- | --- |
| `[Generate(Type targetAttribute)]` | Placed on a `readonly partial record struct` to opt into generation. |
| `[Generate(string targetAttribute)]` | Resolves the attribute by fully-qualified name. Use when the attribute type is not available in the generator's compilation (e.g. `LengthAttribute` in .NET 8+ or a self-generated attribute). |
| `[Property]` | A record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). |
| `[Property]` | A record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). When combined with `[Argument]` on the same parameter, the named argument is read first. |
| `[Property(string name)]` | Explicit named property source. |
| `[Property(..., DefaultValue = ...)]` | Fallback value when the named property is not present. |
| `[Argument]` | Populated from a constructor argument by parameter name. |
Expand Down Expand Up @@ -105,6 +105,33 @@ public readonly partial record struct StringLengthAttributeData(
);
```

## Constructor and named arguments on the same property

A property can declare both sources:

```csharp
[Generate(typeof(GenerateServiceAttribute))]
public readonly partial record struct GenerateServiceAttributeData(
[Argument("lifetime", IsEnum = true, DefaultValue = "…ServiceLifetime.Singleton")] string? Lifetime,
[Argument("name")] [Property] string? Name
);
```

The named argument is read first. A named argument assigns the property/field *after* the constructor
runs, so it is the effective value whenever a caller supplies both — mirroring the attribute instance's
own assignment order. Reading it first also prevents an omitted optional constructor parameter's default
from shadowing an explicitly set property:

```csharp
[GenerateService(Name = "Billing")] // reads "Billing"
[GenerateService(ServiceLifetime.Scoped, "Billing")] // reads "Billing"
```

> [!IMPORTANT]
> Before this rule, the constructor argument was read first, so
> `[GenerateService(Name = "Billing")]` resolved to the `name` parameter's default (`null`) and the
> explicitly set property was silently ignored.

## Nested models

Any property whose type is itself annotated with `[Generate]` can be populated as a nested model.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-sourcegenerator-framework",
"version": "1.0.0-prerelease.52",
"version": "1.0.0-prerelease.53",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand Down
17 changes: 13 additions & 4 deletions src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,32 @@ public enum ServiceLifetime
/// Initializes a new instance of the <see cref="GenerateServiceAttribute"/> class.
/// </remarks>
/// <param name="lifetime">The service lifetime.</param>
/// <param name="name">The optional service name.</param>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class GenerateServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) : Attribute
public sealed class GenerateServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton, string? name = null)
: Attribute
{
/// <summary>
/// Gets the service lifetime.
/// </summary>
public ServiceLifetime Lifetime { get; } = lifetime;

/// <summary>
/// Gets or sets the optional service name.
/// Gets or sets the optional service name. This property can be supplied either as the constructor's
/// <c>name</c> argument or as a named argument (<c>[GenerateService(Name = "…")]</c>); the named
/// argument wins when both are supplied because it is assigned after the constructor runs.
/// </summary>
public string? Name { get; set; }
public string? Name { get; set; } = name;
}

/// <summary>
/// Attribute data model for <see cref="GenerateServiceAttribute"/>.
/// </summary>
/// <remarks>
/// <see cref="Name"/> demonstrates a property mapped from both a constructor argument and a named
/// argument: the named argument is read first so an explicitly set property is never shadowed by the
/// constructor parameter's default.
/// </remarks>
[Generate(typeof(GenerateServiceAttribute))]
public readonly partial record struct GenerateServiceAttributeData(
[Argument(
Expand All @@ -53,7 +62,7 @@ public readonly partial record struct GenerateServiceAttributeData(
DefaultValue = "Purview.SourceGeneratorFramework.Examples.ServiceLifetime.Singleton"
)]
string? Lifetime,
[Property] string? Name
[Argument("name")] [Property] string? Name
);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,18 @@ options with
{
DefaultValue = lifetimeValues[0].FullName,
},
new("name", PurviewTypeLibrary.System.String.MakeNullable(cw))
{
DefaultValue = "null",
},
],
}
),
body => body.Assignment("Lifetime", "lifetime")
body =>
{
body.Assignment("Lifetime", "lifetime");
body.Assignment("Name", "name");
}
);

cw.Property(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,13 @@ static ParameterAttributeInfo ReadParameterAttributes(

var ctorAttribute = GetAttribute(parameter, GeneratorTypeLibrary.Attirbutes.ArgumentAttribute);
var isEnum = false;

// Constructor sources are added from this index so the named-argument source below can be placed
// ahead of them. A named argument assigns the property/field after the constructor runs, so it is
// the effective value whenever both map to the same model property. Reading it first also stops an
// omitted optional constructor parameter's default from shadowing an explicitly set property.
var constructorSourceIndex = sources.Count;

if (ctorAttribute is not null && !hasExclusive)
{
var ctorName = GetCtorPropertyName(ctorAttribute);
Expand Down Expand Up @@ -457,7 +464,10 @@ static ParameterAttributeInfo ReadParameterAttributes(
);
isEnum = isEnum || GetNamedArgument(namedAttribute, "IsEnum", false);

sources.Add(new PropertySource(AttributePropertySource.NamedArgument, namedName ?? propertyName, -1));
sources.Insert(
constructorSourceIndex,
new PropertySource(AttributePropertySource.NamedArgument, namedName ?? propertyName, -1)
);

if (namedDefaultValue is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ await Assert
.That(generated)
.Contains("if (!attributeData.TryGetNamedArgument<bool>(\"GenerateOptions\", out generateOptions))");
await Assert.That(generated).Contains("generateOptions = true");

// A named argument is assigned after the constructor runs, so when both a constructor argument and
// a named argument map to the same property the named argument must be read first. This also stops
// an omitted optional constructor parameter's default from shadowing an explicitly set property.
var namedIndex = generated!.IndexOf("TryGetNamedArgument<bool>(\"GenerateOptions\"", StringComparison.Ordinal);
var ctorIndex = generated.IndexOf(
"TryGetConstructorArgument<bool>(\"generateOptions\"",
StringComparison.Ordinal
);
await Assert.That(namedIndex).IsGreaterThanOrEqualTo(0);
await Assert.That(ctorIndex).IsGreaterThanOrEqualTo(0);
await Assert
.That(namedIndex)
.IsLessThan(ctorIndex)
.Because("the named argument must be read before the constructor argument");
}

[Test]
Expand Down
Loading