From 53e995d5f5885fb120af8013442c53a0d156a0a0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 13:22:22 +0000 Subject: [PATCH] fix(attrib-model): data model skipped properties, regression --- AGENTS.md | 1 + CHANGELOG.md | 63 +++ docs/wiki/Analyzers.md | 10 +- docs/wiki/Attribute-Data-Models.md | 106 +++- docs/wiki/Testing-TUnit.md | 29 + docs/wiki/Testing.md | 35 ++ package.json | 2 +- .../SKILL.md | 146 +++++ .../SKILL.md | 22 +- .../purview-sourcegenerator-testing/SKILL.md | 44 +- .../SKILL.md | 18 +- .../AnalyzerReleases.Unshipped.md | 5 + .../AttributeDataModelNamingAnalyzer.cs | 504 ++++++++++++++++++ .../LogAttributeData.cs | 6 +- .../LogEventAttributeData.cs | 28 + .../LogEventAttributes.cs | 98 ++++ .../Models.cs | 2 +- .../README.md | 26 +- .../AttributeDataModelGenerator.cs | 94 +++- .../Helpers/AttributeDataModelLibrary.cs | 21 + .../README.md | 15 +- .../Assertions/AttributeDataAssertions.cs | 168 ++++++ .../Sdk/README.md | 29 + .../AttributeArgument.cs | 174 ++++++ .../AttributeArgumentForm.cs | 28 + .../AttributeArguments.cs | 134 +++++ .../AttributeDataFactory.cs | 217 ++++++++ .../Sdk/README.md | 35 ++ .../SourceGeneratorFramework/Sdk/README.md | 8 +- .../AttributeDataModelDiagnosticRules.cs | 79 +++ .../AttributeDataModelNamingAnalyzerTests.cs | 315 +++++++++++ .../LogAttributeDataTests.cs | 29 +- .../LogEventAttributeDataTests.cs | 207 +++++++ .../AttributeDataModelGeneratorTests.cs | 117 +++- .../AttributeDataTestingTests.cs | 153 ++++++ 35 files changed, 2914 insertions(+), 54 deletions(-) create mode 100644 skills/purview-sourcegenerator-attribute-data-models/SKILL.md create mode 100644 src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributeData.cs create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributes.cs create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/AttributeArgumentForm.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/AttributeDataFactory.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelNamingAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogEventAttributeDataTests.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/AttributeDataTestingTests.cs diff --git a/AGENTS.md b/AGENTS.md index 4b0a778..2c997fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ npx skills add purview-dev/sourcegenerator-framework | `skills/purview-sourcegenerator-testing/` | the runner layer, `CodeQuery`, options, stage-by-stage cache tests | | `skills/purview-sourcegenerator-tunit-tests/` | which TUnit base class and method, and the assertion extensions | | `skills/purview-sourcegenerator-packaging/` | the self-contained merged analyzer, embedding a generator in another package, Roslyn compatibility | +| `skills/purview-sourcegenerator-attribute-data-models/` | declaring `[Generate]` attribute-data models: argument mapping, defaults, `[NestedModel]`, the `ADM` rules, testing a model with `AttributeDataFactory` | | `skills/purview-sourcegenerator-diagnostics/` | what a `PSGFR`/`PSGF`/`PRSGD` diagnostic means, and why a component is silently not running | Every skill carries the `purview-sourcegenerator` prefix. Skills install into one flat directory per diff --git a/CHANGELOG.md b/CHANGELOG.md index daf8376..6360ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,68 @@ that version to nuget.org. This file was reconstructed from the conventional-commit history at 1.1.1, so entries before that release summarise what the commits recorded rather than what was written at the time. +## [1.3.0] - 2026-11-03 + +### Changed + +- A bare `[Argument]` on an attribute-data model parameter now reads the constructor parameter named after + the record parameter, camel-cased (`MessageTemplate` → `messageTemplate`), as `ArgumentAttribute` has + always documented. It previously added no constructor source at all, so the property fell back to reading + a *named* argument of the same name. A model relying on that should use `[Property]` instead; the naming + analyzer reports a bare `[Argument]` whose implied parameter does not exist (`ADM0012`). + +### Fixed + +- `AttributeDataModelGenerator` emitted a local variable named after each property, camel-cased, without + escaping it. A property whose camel-cased name is a C# keyword (`Event`, `Class`, `Default`) produced + `var event = …` and did not compile, and a property named `AttributeData` shadowed the parse method's + parameter. Keyword names are now emitted with `@`, and `AttributeData` gets a distinct local. +- The example generator's `LogAttributeData` mapped `Message` with `[Property]`, but `LogAttribute.Message` + has a private setter, so the message only ever arrives through the constructor and `[Log("text")]` lost + it. It now reads the `message` constructor parameter. +- The example generator's mirror of the emitted `GenerateServiceAttribute` declared `Name` with an + `internal init` setter, unlike the emitted attribute's public setter. Found by `ADM0012`. + +- `AttributeDataModelGenerator` populated a `[NestedModel]` parameter by calling the nested model's + public `FromAttributeData(AttributeData)`, which begins with its own `TargetAttribute` check. When the + outer attribute was not the nested model's target (directly or through `MatchByInheritance`), the check + always failed and the nested model was always `Empty`: every value was `default(T)` and no + `[Property]`/`[Argument]` `DefaultValue` was applied. A level-shortcut attribute such as `[Info("…")]` + nesting the model of a general `[Log]` attribute lost its message template, event ID and name. Every + generated model now has a `FromAttributeDataUnchecked(AttributeData)` method holding the argument + reading without the target check, and nested models call it. The nested model is read from the outer + attribute's constructor and named arguments, `Exists` is `true`, and a mapped argument the outer + attribute does not declare takes its `DefaultValue`. The guarded `FromAttributeData` and + `TryFromAttributeData` overloads keep their check and their behaviour; they now delegate to + `FromAttributeDataUnchecked` after it. + +### Added + +- **`AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`).** The generated code reads names + case-sensitively and falls back to a default when a name is missing, so a mapping mistake compiled + cleanly and silently produced default values. The analyzer mirrors the generator's source resolution + and reports: a mapped name that matches an attribute member only by case (`ADM0011`, warning); a named + source that is not a public settable member, or a bare `[Argument]` whose implied parameter does not + exist (`ADM0012`, warning); a `[NestedModel]` property that reads only names the outer attribute lacks + (`ADM0013`, info); a property named like a generated member, for which generation is now skipped + (`ADM0014`, error); and a nested model reading a constructor index that names a different parameter on + the outer attribute (`ADM0015`, warning). `ADM0003` is unchanged. +- **Attribute testing helpers in `Purview.SourceGeneratorFramework.Testing`.** `AttributeDataFactory` + compiles an attribute application (`FromUsage`, placed on a declaration its `AttributeUsage` allows) or + a source (`FromSource`) into real `AttributeData`, throwing on compilation errors. `AttributeArguments` + describes each argument as an `AttributeArgument` — name, ordinal, value, and `AttributeArgumentForm` + (`Positional`, `NamedParameter`, `Defaulted`, `NamedArgument`) — with case-sensitive lookups and + value comparison across numeric types, enum members, `typeof` arguments and arrays. +- **`AttributeDataAssertions` in `Purview.SourceGeneratorFramework.Testing.TUnit`:** + `HasConstructorArgument` by ordinal or parameter name (optionally with the expected form), + `DoesNotHaveConstructorArgument`, `HasNamedArgument` and `DoesNotHaveNamedArgument`. A failure lists + every argument of the application. +- The `purview-sourcegenerator-attribute-data-models` skill: mapping methodology, nesting, the `ADM` + rules and how to test a model. The diagnostics, testing and TUnit skills cover the new rules and helpers. +- `LogEventAttribute` / `InfoEventAttribute` and their models in the example generator: a level-shortcut + attribute that does not derive from the attribute its nested model targets, with tests that use + `AttributeDataFactory` and the attribute-argument assertions against the generated models. + ## [1.2.2] - 2026-10-27 ### Fixed @@ -152,6 +214,7 @@ release summarise what the commits recorded rather than what was written at the - Removed the separate `Shared` assembly; its sources are link-compiled into the framework. [Unreleased]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.2.0...HEAD +[1.3.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.2.2...v1.3.0 [1.2.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.0.0...v1.1.0 diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index 77c3570..43b232b 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -79,11 +79,15 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Type-library and attribute-model diagnostics The bundled generators carry their own diagnostic families, reported by the -`TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model validation -analyzers. These are documented on their feature pages: +`TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model analyzers: +`AttributeDataModelValidationAnalyzer` (`ADM0001`–`ADM0009`), `AttributeDataModelSymbolPropertyAnalyzer` +(`ADM0010`) and `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`). The naming rules catch mapped +names the generated code would never find at run time — a case-only mismatch, a named argument without a +public setter, a `[NestedModel]` mapping the outer attribute does not declare — which otherwise compile +cleanly and silently produce default values. These are documented on their feature pages: - [Type-Library.md](Type-Library.md#validation) -- [Attribute-Data-Models.md](Attribute-Data-Models.md) +- [Attribute-Data-Models.md](Attribute-Data-Models.md) ([naming analyzer](Attribute-Data-Models.md#naming-analyzer)) ## Code fixes diff --git a/docs/wiki/Attribute-Data-Models.md b/docs/wiki/Attribute-Data-Models.md index 26ff310..210d4c8 100644 --- a/docs/wiki/Attribute-Data-Models.md +++ b/docs/wiki/Attribute-Data-Models.md @@ -20,11 +20,11 @@ The generator emits marker attributes into your compilation: | `[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. | +| `[Argument]` | Populated from the constructor parameter named after the record parameter, camel-cased (`MessageTemplate` → `messageTemplate`). | | `[Argument(int index)]` | Constructor argument by parameter index. | | `[Argument(string name)]` | Constructor argument by parameter name. | | `[Argument(..., DefaultValue = ...)]` | Fallback when the constructor argument is not present. | -| `[NestedModel]` | Populated by recursively calling `FromAttributeData` on a nested generated model. | +| `[NestedModel]` | Populated from the same attribute by calling the nested generated model's `FromAttributeDataUnchecked`, which skips its target-attribute check. | | `[Exclude]` | Skips auto-discovery for this parameter. | | `[GenericTypeArgument]` | Populated from a generic type argument of the attribute class. | | `[GenericTypeArgument(int index)]` | Generic type argument by position. | @@ -62,12 +62,21 @@ readonly record struct RequiredAttributeData(bool Exists, bool AllowEmptyStrings if (!TargetAttribute.Equals(attributeData.AttributeClass)) return Empty; + return FromAttributeDataUnchecked(attributeData); + } + + public static RequiredAttributeData FromAttributeDataUnchecked(AttributeData attributeData) + { attributeData.TryGetNamedArgument("AllowEmptyStrings", out var allowEmptyStrings); return new(true, allowEmptyStrings); } } ``` +`FromAttributeData` and `TryFromAttributeData` check the attribute's type against `TargetAttribute`. +`FromAttributeDataUnchecked` holds the argument reading without that check; it exists for +[nested models](#nested-models), and is rarely called directly. + ## String target names When the attribute type is not referenced in the generator project, pass the fully-qualified name as @@ -153,8 +162,97 @@ public readonly partial record struct RequiredAttributeData( ); ``` -Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that -derives from `ValidationAttribute`, including `RequiredAttribute`. +The nested model is read from the **outer** attribute's constructor and named arguments, using its own +`[Argument]`/`[Property]` mappings, through its `FromAttributeDataUnchecked` method. Its own +`TargetAttribute` is not checked, because the outer model has already matched its attribute. So the +outer attribute does not have to derive from the nested model's target: it only needs to declare the +same constructor parameter and property names. `ValidationAttributeData` also uses +`MatchByInheritance = true`, so it can be used on its own against any attribute deriving from +`ValidationAttribute`. + +A nested model populated this way always has `Exists` set to `true`. A mapped argument or property that +the outer attribute does not declare takes the nested model's `DefaultValue`, or `default(T)` when it +has none: + +```csharp +[Generate(typeof(LogEventAttribute))] +public readonly partial record struct LogEventAttributeData( + [Argument("level")] [Property(DefaultValue = LogLevel.Information)] LogLevel Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoEventAttribute does not derive from LogEventAttribute, and has no `level`. +[Generate(typeof(InfoEventAttribute))] +public readonly partial record struct InfoEventAttributeData([NestedModel] LogEventAttributeData Log); +``` + +`[InfoEvent("Cycle done: {Count}")]` gives `Log.MessageTemplate == "Cycle done: {Count}"`, +`Log.EventId == -1` and `Log.Level == LogLevel.Information`. + +> [!IMPORTANT] +> Up to 1.2.2, the nested model was read through its guarded `FromAttributeData`. Unless the outer +> attribute matched the nested model's `TargetAttribute`, directly or through `MatchByInheritance`, the +> nested model was always `Empty`: every value was `default(T)` and no `DefaultValue` was applied. + +## Naming rules + +The generated code looks every name up **case-sensitively at run time**, and a name it cannot find yields +the property's `DefaultValue` (or `default(T)`). A mapping mistake therefore compiles cleanly and produces +default values. Follow these rules, and let the naming analyzer check them: + +- A constructor source must name a parameter exactly: `[Argument("messageTemplate")]`, not + `[Argument("MessageTemplate")]`. A bare `[Argument]` uses the record parameter's name, camel-cased. +- A named source (`[Property]`, or a parameter with no mapping attribute) must name a **public settable** + property or field of the attribute. A property with a private or `internal` setter can only be read + through the constructor parameter that sets it. +- Prefer constructor sources by name over `[Argument(index)]`: an index silently moves to another parameter + when overloads change, and means a different parameter on each attribute a nested model is read from. +- A property must not be named `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`; those are generated. Keyword names such as `Event` + or `Class` are supported. + +### Naming analyzer + +`AttributeDataModelNamingAnalyzer` mirrors the generator's source resolution and reports at the mapping: + +| Rule | Severity | Reported when | +| --- | --- | --- | +| `ADM0011` | Warning | A mapped constructor parameter or named argument matches an attribute member only when case is ignored. | +| `ADM0012` | Warning | A named source is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter the attribute lacks. | +| `ADM0013` | Info | A `[NestedModel]` property reads only names the outer attribute lacks, so it always takes its default. | +| `ADM0014` | Error | A property has the name of a generated member. Generation is skipped for the model. | +| `ADM0015` | Warning | A `[NestedModel]` property reads a constructor index that names a different parameter on the outer attribute. | + +An explicit `[Argument("name")]` or `[Argument(index)]` that matches nothing at all remains `ADM0003`. +`ADM0013` is informational because a nested model may read a value the outer attribute deliberately does +not have (the `level` of a `[Log]` model nested in an `[Info]` model); check each one. + +## Testing a model + +Test a model against the `AttributeData` Roslyn really produces. `AttributeDataFactory` +(`Purview.SourceGeneratorFramework.Testing`) compiles an application without running a generator, and the +TUnit `AttributeDataAssertions` pin each argument by ordinal, parameter name or named argument, including +whether it was written positionally, by parameter name, as a named argument, or defaulted: + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[InfoEvent(\"custom {Value}\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + +var model = InfoEventAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.EventId).IsEqualTo(-1); // DefaultValue, not default(int) +await Assert.That(model.Log.Level).IsEqualTo(LogLevel.Information); +``` + +For each model, cover positional, by-name and named-argument applications, an application that omits every +optional argument, and — for a nested model — an outer attribute missing a mapped member. See +[Testing.md](Testing.md#testing-attribute-applications) for the factory and +[Testing-TUnit.md](Testing-TUnit.md#assertion-extensions) for the assertions. ## Generic type arguments diff --git a/docs/wiki/Testing-TUnit.md b/docs/wiki/Testing-TUnit.md index b45a9b1..46d9436 100644 --- a/docs/wiki/Testing-TUnit.md +++ b/docs/wiki/Testing-TUnit.md @@ -208,6 +208,9 @@ All assertion extensions are under `Purview.SourceGeneratorFramework.Testing.TUn - `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` / `HasNoErrorDiagnostics`. - `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- `HasConstructorArgument(ordinal | parameterName, expected[, form])` / `DoesNotHaveConstructorArgument` / + `HasNamedArgument` / `DoesNotHaveNamedArgument` — on an `AttributeData`; see + [Attribute argument assertions](#attribute-argument-assertions). - `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened). The `CodeQuery` assertions operate on a `CodeQuery` directly, so they accept a query from any test @@ -231,6 +234,32 @@ CodeQueryResult attributeClass = await Assert.That(query await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); ``` +### Attribute argument assertions + +`AttributeDataAssertions` assert on an `AttributeData`, typically one compiled by `AttributeDataFactory` +from the Testing package. They are the way to test attribute parsing — usually a generated attribute-data +model — against real input: pin the application first, then the model, so a failure says which half is +wrong. + +```csharp +var attribute = AttributeDataFactory.FromUsage("[InfoEvent(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument(0, "custom {Value}"); // by ordinal +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); // by parameter name +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); +await Assert.That(attribute).DoesNotHaveNamedArgument("EventId"); + +var model = InfoEventAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +``` + +Names are matched case-sensitively, as generated models match them. Values compare with +`AttributeArgument.ValueEquals`: numbers across numeric types, enums by value or member name (`"High"`, +`"Level.High"`, `"Sample.Level.High"`), `typeof` arguments by `Type`, `TypeIdentity` or name, arrays +element-wise. Each assertion returns the matched `AttributeArgument`, and a failure lists every argument of +the application, for example +`[InfoEventAttribute(#0 messageTemplate: "custom {Value}", Name = "N")]`. ## Incremental cache tests `GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 489df8c..4b16442 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -23,6 +23,9 @@ dotnet add package Purview.SourceGeneratorFramework.Testing - **`DriverRunResultExtensions`** — assertion helpers such as `AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, `AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, and more. +- **`AttributeDataFactory`** / **`AttributeArguments`** — compile an attribute application into real + `AttributeData`, and inspect its arguments by ordinal, constructor parameter name and named argument, + with how each was supplied. See [Testing attribute applications](#testing-attribute-applications). - **`ITestOutput`** / **`NullTestOutput`** — abstraction for capturing generator log output during tests. @@ -237,6 +240,38 @@ var query = result.Generated(); query.GetClass("Service").HasProperty("Name", query.MakeNullable(TypeReference.Create())); ``` +## Testing attribute applications + +`AttributeDataFactory` compiles attribute applications into the `AttributeData` Roslyn produces, without +running a generator, so attribute parsing — typically a generated attribute-data model — can be tested +against real input: + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application, placed on a declaration its AttributeUsage allows; brackets optional. +var attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +References and usings come from the options (`AdditionalAssemblyTypes`, `AdditionalNamespaces`, and +`AdditionalSources` for an attribute declared in the test). A compilation error throws +`InvalidOperationException` listing the errors, so a misspelt argument name fails the test. + +`AttributeArguments.From(attribute)` describes every argument as an `AttributeArgument`: its name +(constructor parameter or property), ordinal, value, and `AttributeArgumentForm` — `Positional`, +`NamedParameter` (`message: "x"`), `Defaulted` (an optional parameter that was not written) or +`NamedArgument` (`Name = "x"`). Look arguments up with `TryGetConstructorArgument(ordinal)`, +`TryGetConstructorArgument(parameterName)` and `TryGetNamedArgument(name)`; names are case-sensitive, as +generated models are. `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member +name, `typeof` arguments by `Type`, `TypeIdentity` or name, and arrays element-wise. `ToString()` gives +`[LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")]`. + +The TUnit package wraps these in assertions (`HasConstructorArgument`, `HasNamedArgument`, …). ## Refactoring tests `RefactoringTestRunner` runs a `CodeRefactoringProvider` against a test document: diff --git a/package.json b/package.json index a6ce1bb..88ab007 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sourcegenerator-framework", - "version": "1.2.2", + "version": "1.3.0", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 0000000..714ddbf --- /dev/null +++ b/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/skills/purview-sourcegenerator-diagnostics/SKILL.md b/skills/purview-sourcegenerator-diagnostics/SKILL.md index 21d9522..b80ebe7 100644 --- a/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -1,7 +1,7 @@ --- name: purview-sourcegenerator-diagnostics description: >- - Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix provider is silently not running at all. metadata: @@ -106,8 +106,21 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Type-library and attribute-model diagnostics The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` -(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are -documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. ## Code fixes @@ -139,4 +152,5 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: ## Related skills `purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you -towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. +towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/skills/purview-sourcegenerator-testing/SKILL.md b/skills/purview-sourcegenerator-testing/SKILL.md index aaeff09..a6da554 100644 --- a/skills/purview-sourcegenerator-testing/SKILL.md +++ b/skills/purview-sourcegenerator-testing/SKILL.md @@ -3,8 +3,9 @@ name: purview-sourcegenerator-testing description: >- Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, - configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting - incremental pipeline caching stage by stage. + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. metadata: product: Purview.SourceGeneratorFramework repository: purview-dev/sourcegenerator-framework @@ -167,8 +168,47 @@ an assertion, nor calling a helper that is handed the result — an extension on parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and `result.AssertNoGenerationExceptions()` are not flagged. +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + ## Best practices +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. - **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. - **Generator references**: to use a generated type in the test project AND pass the generator type to a diff --git a/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/skills/purview-sourcegenerator-tunit-tests/SKILL.md index bc711c5..2a3a061 100644 --- a/skills/purview-sourcegenerator-tunit-tests/SKILL.md +++ b/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -3,7 +3,8 @@ name: purview-sourcegenerator-tunit-tests description: >- Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and - method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including + attribute-argument assertions for testing attribute-data models), and modernising existing tests. metadata: product: Purview.SourceGeneratorFramework.Testing.TUnit @@ -125,6 +126,21 @@ when awaited. ``` - **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` - **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. - **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` (whitespace-flattened string comparison). diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index c97ae24..14af6b1 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -5,6 +5,11 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +ADM0011 | Naming | Warning | Mapped name differs from the attribute member only by case +ADM0012 | Naming | Warning | Mapped attribute member does not exist +ADM0013 | Naming | Info | Nested model property is not declared on the outer attribute +ADM0014 | Naming | Error | Property name conflicts with a generated member +ADM0015 | Naming | Warning | Nested model constructor index names a different parameter on the outer attribute PSGFR30 | Purview.SourceGeneratorFramework | Warning | Prefer static lambdas in incremental generator pipelines PSGFR31 | Purview.SourceGeneratorFramework | Warning | Prefer TargetSymbol over GetDeclaredSymbol(TargetNode) PSGFR32 | Purview.SourceGeneratorFramework | Warning | Avoid NormalizeWhitespace when generating source diff --git a/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs new file mode 100644 index 0000000..a80db01 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs @@ -0,0 +1,504 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Reports attribute-data model naming diagnostics (ADM0011-ADM0015): mapped constructor parameter and named +/// argument names that the generated code would never find on the attribute, property names that collide with +/// generated members, and [NestedModel] mappings that do not line up with the outer attribute. +/// +/// +/// The generated code looks names up case-sensitively at run time and falls back to a default when a name is +/// missing, so a naming mistake compiles cleanly and silently produces default values. These rules mirror the +/// generator's source resolution so the mistake is reported where the mapping is declared. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AttributeDataModelNamingAnalyzer : DiagnosticAnalyzer +{ + const string ConstructorParameterKind = "constructor parameter"; + const string NamedArgumentKind = "settable property or field"; + + public static DiagnosticDescriptor MappedNameCaseMismatch => + AttributeDataModelDiagnosticRules.MappedNameCaseMismatch; + + public static DiagnosticDescriptor MappedMemberNotFound => AttributeDataModelDiagnosticRules.MappedMemberNotFound; + + public static DiagnosticDescriptor NestedModelMemberNotDeclared => + AttributeDataModelDiagnosticRules.NestedModelMemberNotDeclared; + + public static DiagnosticDescriptor PropertyNameConflictsWithGeneratedMember => + AttributeDataModelDiagnosticRules.PropertyNameConflictsWithGeneratedMember; + + public static DiagnosticDescriptor NestedModelConstructorIndexMismatch => + AttributeDataModelDiagnosticRules.NestedModelConstructorIndexMismatch; + + public override ImmutableArray SupportedDiagnostics => + [ + MappedNameCaseMismatch, + MappedMemberNotFound, + NestedModelMemberNotDeclared, + PropertyNameConflictsWithGeneratedMember, + NestedModelConstructorIndexMismatch, + ]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(context => + { + var markers = MarkerTypes.Resolve(context.Compilation); + if (markers is null) + return; + + context.RegisterSymbolAction(context => AnalyzeNamedType(context, markers.Value), SymbolKind.NamedType); + }); + } + + static void AnalyzeNamedType(SymbolAnalysisContext context, MarkerTypes markers) + { + if (context.Symbol is not INamedTypeSymbol { TypeKind: TypeKind.Struct } model) + return; + + var generateAttribute = GetAttribute(model, markers.Generate); + if (generateAttribute is null) + return; + + var targetAttribute = ResolveTargetAttribute(context.Compilation, generateAttribute); + + foreach (var property in ReadProperties(model, markers)) + { + if (property.Parameter.Locations.FirstOrDefault(static l => l.IsInSource) is not { } location) + continue; + + if (AttributeDataModelDiagnosticRules.GeneratedMemberNames.Contains(property.PropertyName)) + { + context.ReportDiagnostic( + Diagnostic.Create(PropertyNameConflictsWithGeneratedMember, location, property.PropertyName) + ); + } + + if (targetAttribute is null) + continue; + + if (property.NestedModel is not null) + { + AnalyzeNestedModel( + context, + location, + property.NestedModel, + targetAttribute, + markers, + ImmutableHashSet.Create(SymbolEqualityComparer.Default, model) + ); + continue; + } + + foreach (var source in property.Sources) + AnalyzeDirectSource(context, location, property, source, targetAttribute); + } + } + + static void AnalyzeDirectSource( + SymbolAnalysisContext context, + Location location, + MappedProperty property, + MappedSource source, + INamedTypeSymbol targetAttribute + ) + { + // An explicit [Argument(index)] or [Argument("name")] that matches nothing is ADM0003, reported by the + // validation analyzer. Only the case-only mismatch it accepts, and the names this analyzer infers, are + // checked here. + if (source.Kind == MappedSourceKind.ConstructorIndex) + return; + + var match = Match(source, targetAttribute); + if (match.Kind == MatchKind.Exact) + return; + + if (match.Kind == MatchKind.CaseOnly) + { + context.ReportDiagnostic( + Diagnostic.Create( + MappedNameCaseMismatch, + location, + source.Name, + KindText(source), + match.ActualName, + targetAttribute.Name + ) + ); + return; + } + + if (source.Kind == MappedSourceKind.ConstructorName && !source.IsImplied) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + MappedMemberNotFound, + location, + property.PropertyName, + KindText(source), + source.Name, + targetAttribute.Name + ) + ); + } + + static void AnalyzeNestedModel( + SymbolAnalysisContext context, + Location location, + INamedTypeSymbol nestedModel, + INamedTypeSymbol outerAttribute, + MarkerTypes markers, + ImmutableHashSet visited + ) + { + if (visited.Contains(nestedModel)) + return; + + visited = visited.Add(nestedModel); + + var nestedGenerate = GetAttribute(nestedModel, markers.Generate); + var nestedTarget = nestedGenerate is null ? null : ResolveTargetAttribute(context.Compilation, nestedGenerate); + + foreach (var property in ReadProperties(nestedModel, markers)) + { + // The nested model's own nested models are read from the same outer attribute. + if (property.NestedModel is not null) + { + AnalyzeNestedModel(context, location, property.NestedModel, outerAttribute, markers, visited); + continue; + } + + var resolved = false; + var reported = false; + foreach (var source in property.Sources) + { + if (source.Kind == MappedSourceKind.ConstructorIndex) + { + var outerNames = ParameterNamesAt(outerAttribute, source.Index); + if (outerNames.IsEmpty) + continue; + + var nestedNames = nestedTarget is null ? [] : ParameterNamesAt(nestedTarget, source.Index); + if (!nestedNames.IsEmpty && !outerNames.Overlaps(nestedNames)) + { + context.ReportDiagnostic( + Diagnostic.Create( + NestedModelConstructorIndexMismatch, + location, + nestedModel.Name, + property.PropertyName, + source.Index, + nestedNames.First(), + nestedTarget!.Name, + outerNames.First(), + outerAttribute.Name + ) + ); + reported = true; + continue; + } + + resolved = true; + continue; + } + + var match = Match(source, outerAttribute); + if (match.Kind == MatchKind.Exact) + { + resolved = true; + } + else if (match.Kind == MatchKind.CaseOnly) + { + context.ReportDiagnostic( + Diagnostic.Create( + MappedNameCaseMismatch, + location, + source.Name, + KindText(source), + match.ActualName, + outerAttribute.Name + ) + ); + reported = true; + } + } + + if (!resolved && !reported && !property.Sources.IsEmpty) + { + context.ReportDiagnostic( + Diagnostic.Create( + NestedModelMemberNotDeclared, + location, + nestedModel.Name, + property.PropertyName, + DescribeSources(property.Sources), + outerAttribute.Name + ) + ); + } + } + } + + static string KindText(MappedSource source) => + source.Kind == MappedSourceKind.NamedArgument ? NamedArgumentKind : ConstructorParameterKind; + + static string DescribeSources(ImmutableArray sources) + { + StringBuilder builder = new(); + for (var i = 0; i < sources.Length; i++) + { + if (i > 0) + builder.Append(i == sources.Length - 1 ? " or " : ", "); + + var source = sources[i]; + if (source.Kind == MappedSourceKind.ConstructorIndex) + builder.Append("constructor index ").Append(source.Index); + else + builder.Append(KindText(source)).Append(" '").Append(source.Name).Append('\''); + } + + return builder.ToString(); + } + + static (MatchKind Kind, string? ActualName) Match(MappedSource source, INamedTypeSymbol attribute) + { + var candidates = + source.Kind == MappedSourceKind.ConstructorName + ? attribute.InstanceConstructors.SelectMany(static c => c.Parameters).Select(static p => p.Name) + : SettableMemberNames(attribute); + + string? caseOnly = null; + foreach (var candidate in candidates) + { + if (string.Equals(candidate, source.Name, StringComparison.Ordinal)) + return (MatchKind.Exact, candidate); + + if (caseOnly is null && string.Equals(candidate, source.Name, StringComparison.OrdinalIgnoreCase)) + caseOnly = candidate; + } + + return caseOnly is null ? (MatchKind.None, null) : (MatchKind.CaseOnly, caseOnly); + } + + // A named argument can only set a public, writable instance property or field, including inherited ones. + static IEnumerable SettableMemberNames(INamedTypeSymbol attribute) + { + for (var type = attribute; type is not null; type = type.BaseType) + { + foreach (var member in type.GetMembers()) + { + if (member.IsStatic || member.DeclaredAccessibility != Accessibility.Public) + continue; + + var settable = member switch + { + IPropertySymbol property => property.SetMethod is { DeclaredAccessibility: Accessibility.Public }, + IFieldSymbol field => !field.IsReadOnly && !field.IsConst, + _ => false, + }; + + if (settable) + yield return member.Name; + } + } + } + + static ImmutableHashSet ParameterNamesAt(INamedTypeSymbol attribute, int index) => + attribute + .InstanceConstructors.Where(c => index < c.Parameters.Length) + .Select(c => c.Parameters[index].Name) + .ToImmutableHashSet(StringComparer.Ordinal); + + /// + /// Reads a model's mapped properties the way AttributeDataModelLibrary does: from the primary + /// constructor's parameters, skipping [Exclude] and [GenericTypeArgument]. + /// + static IEnumerable ReadProperties(INamedTypeSymbol model, MarkerTypes markers) + { + var constructor = model.InstanceConstructors.FirstOrDefault(static c => + c.DeclaringSyntaxReferences.Any(static r => r.GetSyntax() is TypeDeclarationSyntax) + ); + if (constructor is null) + yield break; + + foreach (var parameter in constructor.Parameters) + { + if ( + GetAttribute(parameter, markers.Exclude) is not null + || GetAttribute(parameter, markers.GenericTypeArgument) is not null + ) + continue; + + var propertyName = ToPascalCase(parameter.Name); + + if (GetAttribute(parameter, markers.NestedModel) is not null) + { + var nestedModel = + parameter.Type is INamedTypeSymbol named && GetAttribute(named, markers.Generate) is not null + ? named + : null; + + // A [NestedModel] type without [Generate] is ADM0004; there is nothing to compare. + if (nestedModel is not null) + yield return new(parameter, propertyName, [], nestedModel); + + continue; + } + + var sources = ImmutableArray.CreateBuilder(); + + // The named argument is read first, matching the generator's source order. + var propertyAttribute = GetAttribute(parameter, markers.Property); + if (propertyAttribute is not null) + { + var name = GetNamedArgument(propertyAttribute, "Name"); + sources.Add(new(MappedSourceKind.NamedArgument, name ?? propertyName, -1, IsImplied: name is null)); + } + + var argumentAttribute = GetAttribute(parameter, markers.Argument); + if (argumentAttribute is not null) + { + var first = argumentAttribute.ConstructorArguments.FirstOrDefault().Value; + var name = first as string ?? GetNamedArgument(argumentAttribute, "Name"); + var index = first is int i ? i : GetNamedArgument(argumentAttribute, "Index") ?? -1; + + if (name is not null) + sources.Add(new(MappedSourceKind.ConstructorName, name, -1, IsImplied: false)); + else if (index >= 0) + sources.Add(new(MappedSourceKind.ConstructorIndex, null, index, IsImplied: false)); + else + sources.Add(new(MappedSourceKind.ConstructorName, ToCamelCase(propertyName), -1, IsImplied: true)); + } + + if (sources.Count == 0) + sources.Add(new(MappedSourceKind.NamedArgument, propertyName, -1, IsImplied: true)); + + yield return new(parameter, propertyName, sources.ToImmutable(), null); + } + } + + static INamedTypeSymbol? ResolveTargetAttribute(Compilation compilation, AttributeData generateAttribute) + { + if (generateAttribute.ConstructorArguments.Length == 0) + return null; + + return generateAttribute.ConstructorArguments[0].Value switch + { + INamedTypeSymbol type => type.IsUnboundGenericType ? type.OriginalDefinition : type, + string name => compilation.GetTypeByMetadataName(name), + _ => null, + }; + } + + static AttributeData? GetAttribute(ISymbol symbol, INamedTypeSymbol? attributeType) + { + if (attributeType is null) + return null; + + foreach (var attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) + return attribute; + } + + return null; + } + + static T? GetNamedArgument(AttributeData attributeData, string name) + { + foreach (var argument in attributeData.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is T value) + return value; + } + + return default; + } + + // Must match AttributeDataModelLibrary.ToPascalCase: the model property is named from the parameter. + static string ToPascalCase(string value) + { + StringBuilder builder = new(value.Length); + var newWord = true; + foreach (var c in value) + { + if (c == '_') + { + newWord = true; + continue; + } + + builder.Append(newWord ? char.ToUpperInvariant(c) : c); + newWord = false; + } + + return builder.ToString(); + } + + static string ToCamelCase(string value) => + value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value.Substring(1); + + enum MappedSourceKind + { + NamedArgument, + ConstructorName, + ConstructorIndex, + } + + enum MatchKind + { + None, + CaseOnly, + Exact, + } + + readonly record struct MappedSource(MappedSourceKind Kind, string? Name, int Index, bool IsImplied); + + sealed record MappedProperty( + IParameterSymbol Parameter, + string PropertyName, + ImmutableArray Sources, + INamedTypeSymbol? NestedModel + ); + + readonly record struct MarkerTypes( + INamedTypeSymbol Generate, + INamedTypeSymbol? Property, + INamedTypeSymbol? Argument, + INamedTypeSymbol? NestedModel, + INamedTypeSymbol? Exclude, + INamedTypeSymbol? GenericTypeArgument + ) + { + const string Namespace = "Purview.SourceGeneratorFramework.Generators."; + + public static MarkerTypes? Resolve(Compilation compilation) + { + var generate = compilation.GetTypeByMetadataName(Namespace + "GenerateAttribute"); + if (generate is null) + return null; + + return new( + generate, + compilation.GetTypeByMetadataName(Namespace + "PropertyAttribute"), + compilation.GetTypeByMetadataName(Namespace + "ArgumentAttribute"), + compilation.GetTypeByMetadataName(Namespace + "NestedModelAttribute"), + compilation.GetTypeByMetadataName(Namespace + "ExcludeAttribute"), + compilation.GetTypeByMetadataName(Namespace + "GenericTypeArgumentAttribute") + ); + } + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs index e457886..4c3aa3f 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs @@ -6,10 +6,14 @@ namespace Purview.SourceGeneratorFramework.Examples; /// /// MatchByInheritance = true makes this model accept derived attribute types too, so it can read a /// application through the same extraction logic. +/// +/// has a private setter, so it can only be supplied through the +/// message constructor parameter; a [Property] mapping would never be read (ADM0012). +/// /// [Generate(typeof(LogAttribute), MatchByInheritance = true)] public readonly partial record struct LogAttributeData( - [Property] string? Message, + [Argument("message")] string? Message, [Property] int EventId, [Property] string? CategoryName, [Property(DefaultValue = LogLevel.Information)] LogLevel Level diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributeData.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributeData.cs new file mode 100644 index 0000000..090ce2a --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributeData.cs @@ -0,0 +1,28 @@ +namespace Purview.SourceGeneratorFramework.Examples; + +/// +/// Attribute data model for . +/// +/// +/// Each value can be supplied positionally or as a named argument, so both sources are mapped. The named +/// argument is read first because it is assigned after the constructor runs. +/// +[Generate(typeof(LogEventAttribute))] +public readonly partial record struct LogEventAttributeData( + [Argument("level")] [Property(DefaultValue = LogLevel.Information)] LogLevel Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +/// +/// Attribute data model for . +/// +/// +/// does not derive from , yet [NestedModel] +/// reads it into 's shape: the nested model is populated from this attribute's +/// arguments through LogEventAttributeData.FromAttributeDataUnchecked, skipping its own target-attribute +/// check. level is not declared on , so it takes the mapped default. +/// +[Generate(typeof(InfoEventAttribute))] +public readonly partial record struct InfoEventAttributeData([NestedModel] LogEventAttributeData Log); diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributes.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributes.cs new file mode 100644 index 0000000..32be4cf --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LogEventAttributes.cs @@ -0,0 +1,98 @@ +namespace Purview.SourceGeneratorFramework.Examples; + +/// +/// Marks a method as a log event with an explicit level. +/// +/// +/// is a level shortcut for this attribute. It does not derive from it, but +/// declares the same constructor parameter and property names minus level, so its model can reuse this +/// attribute's model through [NestedModel]. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class LogEventAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public LogEventAttribute() { } + + /// + /// Initializes a new instance of the class. + /// + /// The log level. + /// The message template. + /// The event identifier. + public LogEventAttribute(LogLevel level, string? messageTemplate = null, int eventId = -1) + { + Level = level; + MessageTemplate = messageTemplate; + EventId = eventId; + } + + /// + /// Gets or sets the log level. + /// + public LogLevel Level { get; set; } = LogLevel.Information; + + /// + /// Gets or sets the message template. + /// + public string? MessageTemplate { get; set; } + + /// + /// Gets or sets the event identifier. -1 means one is generated. + /// + public int EventId { get; set; } = -1; + + /// + /// Gets or sets the event name. + /// + public string? Name { get; set; } +} + +/// +/// Marks a method as an log event. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class InfoEventAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public InfoEventAttribute() { } + + /// + /// Initializes a new instance of the class. + /// + /// The message template. + public InfoEventAttribute(string? messageTemplate) + { + MessageTemplate = messageTemplate; + } + + /// + /// Initializes a new instance of the class. + /// + /// The message template. + /// The event identifier. + public InfoEventAttribute(string? messageTemplate, int eventId) + { + MessageTemplate = messageTemplate; + EventId = eventId; + } + + /// + /// Gets or sets the message template. + /// + public string? MessageTemplate { get; set; } + + /// + /// Gets or sets the event identifier. -1 means one is generated. + /// + public int EventId { get; set; } = -1; + + /// + /// Gets or sets the event name. + /// + public string? Name { get; set; } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs index cb2abff..21d4450 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs @@ -43,7 +43,7 @@ public sealed class GenerateServiceAttribute(ServiceLifetime lifetime = ServiceL /// name argument or as a named argument ([GenerateService(Name = "…")]); the named /// argument wins when both are supplied because it is assigned after the constructor runs. /// - public string? Name { get; internal init; } = name; + public string? Name { get; set; } = name; } /// diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md index 222d57a..8085209 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md @@ -41,7 +41,7 @@ Reference implementation of an incremental C# source generator built with `Purvi ```csharp [Generate(typeof(LogAttribute), MatchByInheritance = true)] public readonly partial record struct LogAttributeData( - [Property] string? Message, + [Argument("message")] string? Message, [Property] int EventId, [Property] string? CategoryName, [Property(DefaultValue = LogLevel.Information)] LogLevel Level @@ -54,12 +54,36 @@ public readonly partial record struct DebugAttributeData( ); ``` +- `LogAttribute.Message` has a private setter, so it can only arrive through the `message` constructor parameter. Mapping it with `[Property]` would never read it; the naming analyzer reports that as `ADM0012`. - `MatchByInheritance = true` makes `LogAttributeData` accept `[Debug]` applications too. - `[NestedModel]` reuses the parent's full mapping without duplicating the property list. - `[Property(DefaultValue = ...)]` supplies the fallback. Roslyn's `AttributeData` does not surface values assigned inside the attribute constructor body, so the "Debug through inheritance" default is declared on the model rather than relied on from `DebugAttribute`'s constructor. Tests are in [`LogAttributeDataTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.UnitTests). +## Level-shortcut attribute mapping (LogEvent → InfoEvent) + +`InfoEventAttribute` is a level shortcut for `LogEventAttribute` that does **not** derive from it. It declares the same `messageTemplate`/`eventId` constructor parameters and `MessageTemplate`/`EventId`/`Name` properties, but no `level`: + +```csharp +[Generate(typeof(LogEventAttribute))] +public readonly partial record struct LogEventAttributeData( + [Argument("level")] [Property(DefaultValue = LogLevel.Information)] LogLevel Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +[Generate(typeof(InfoEventAttribute))] +public readonly partial record struct InfoEventAttributeData([NestedModel] LogEventAttributeData Log); +``` + +- `[NestedModel]` reads `[InfoEvent(...)]`'s arguments into `LogEventAttributeData`'s shape through `LogEventAttributeData.FromAttributeDataUnchecked`, which skips the `LogEventAttribute` target check. +- `level` is not declared on `InfoEventAttribute`, so `Log.Level` takes the mapped default, `LogLevel.Information`. An omitted `eventId` takes `-1`. +- `LogEventAttributeData.FromAttributeData` still returns `Empty` for an `[InfoEvent]` application. + +Tests are in [`LogEventAttributeDataTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.UnitTests). They show the recommended way to test a model: `AttributeDataFactory.FromSource` compiles the applications into real `AttributeData`, the attribute-argument assertions (`HasConstructorArgument`, `HasNamedArgument`, …) pin what each application supplies, and the generated models are then checked against it. + ## Refactoring sample (Logging) `LoggingRefactoringProvider` lives in the separate diff --git a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs index 6d39246..2744ab2 100644 --- a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs +++ b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Purview.SourceGeneratorFramework.Generators.Helpers; using Purview.SourceGeneratorFramework.Logging; @@ -129,6 +130,7 @@ static void Struct(CodeWriter writer, AttributeDataModelTarget target) // AttributeData extraction methods FromAttributeDataMethod(writer, target); TryFromAttributeDataMethod(writer, target); + FromAttributeDataUncheckedMethod(writer, target); } } @@ -513,21 +515,52 @@ static void FromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarget } w.Block(null, body => body.Line("return Empty;")); - foreach (var property in target.Properties) - { - PropertyExtraction(w, property); - } + w.Return("FromAttributeDataUnchecked(attributeData)"); + } + ); + } - List returnValues = ["true"]; - foreach (var property in target.Properties) + // The extraction body, without the TargetAttribute check. [NestedModel] parameters call this: the outer + // model has already matched its own attribute, and the nested model's TargetAttribute is usually a different + // type, so going through the guarded FromAttributeData would always return Empty. + static void FromAttributeDataUncheckedMethod(CodeWriter writer, AttributeDataModelTarget target) + { + MethodDeclarationOptions methodOptions = new( + "FromAttributeDataUnchecked", + TypeReference(target.StructName), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + Parameters = [new("attributeData", TypeReference("global::Microsoft.CodeAnalysis.AttributeData"))], + }; + + writer + .XmlSummary( + "Reads the model from the attribute's constructor and named arguments without checking the attribute type against ." + ) + .XmlRemarks( + "Used to populate a [NestedModel] parameter from the outer model's attribute. The result always has set to , and arguments the attribute does not declare take their mapped default values." + ) + .Method( + methodOptions, + w => { - var value = ToCamelCase(property.PropertyName); - returnValues.Add(property.IsNonNullableReferenceType ? value + "!" : value); - } + foreach (var property in target.Properties) + { + PropertyExtraction(w, property); + } - w.Line($"return new({string.Join(", ", returnValues)});"); - } - ); + List returnValues = ["true"]; + foreach (var property in target.Properties) + { + var value = ToLocalName(property.PropertyName); + returnValues.Add(property.IsNonNullableReferenceType ? value + "!" : value); + } + + w.Line($"return new({string.Join(", ", returnValues)});"); + } + ); } static void TryFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarget target) @@ -563,19 +596,7 @@ static void TryFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarg } w.Block(null, body => body.Line("return false;")); - foreach (var property in target.Properties) - { - PropertyExtraction(w, property); - } - - List returnValues = ["true"]; - foreach (var property in target.Properties) - { - var value = ToCamelCase(property.PropertyName); - returnValues.Add(property.IsNonNullableReferenceType ? value + "!" : value); - } - - w.Assignment("attribute", $"new({string.Join(", ", returnValues)})"); + w.Assignment("attribute", "FromAttributeDataUnchecked(attributeData)"); w.Return("attribute.Exists"); } @@ -584,7 +605,7 @@ static void TryFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarg static void PropertyExtraction(CodeWriter writer, AttributeDataModelProperty property) { - var variableName = ToCamelCase(property.PropertyName); + var variableName = ToLocalName(property.PropertyName); var typeName = GetNonNullableTypeName(property.FullyQualifiedTypeName); var localTypeName = property.IsNullableValueType ? typeName @@ -593,7 +614,9 @@ static void PropertyExtraction(CodeWriter writer, AttributeDataModelProperty pro if (property.IsNestedModel) { - writer.Line($"var {variableName} = {property.NestedModelTypeName}.FromAttributeData(attributeData);"); + writer.Line( + $"var {variableName} = {property.NestedModelTypeName}.FromAttributeDataUnchecked(attributeData);" + ); return; } @@ -748,7 +771,7 @@ string defaultValueExpression ) { var typeName = "global::Microsoft.CodeAnalysis.TypedConstant"; - var tempName = $"__{variableName}Tc"; + var tempName = $"__{variableName.TrimStart('@')}Tc"; writer.Line($"{typeName} {tempName} = default;"); @@ -837,6 +860,21 @@ static string GetTryGetMethodCall(PropertySource source, string variableName, st static string GetNonNullableTypeName(string typeName) => typeName.EndsWith("?", StringComparison.Ordinal) ? typeName.Substring(0, typeName.Length - 1) : typeName; + // Each property is read into a local named after it, so the camel-cased name must not be a keyword + // (Event -> event) or shadow the method's attributeData parameter. + static string ToLocalName(string propertyName) + { + var name = ToCamelCase(propertyName); + if (string.Equals(name, "attributeData", StringComparison.Ordinal)) + return "__attributeData"; + + return + SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None + || SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + ? "@" + name + : name; + } + static string ToCamelCase(string value) => string.IsNullOrEmpty(value) ? value : char.ToLowerInvariant(value[0]) + value.Substring(1); } diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs index 531f491..3e769be 100644 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs @@ -311,6 +311,18 @@ CancellationToken cancellationToken continue; } + if (AttributeDataModelDiagnosticRules.GeneratedMemberNames.Contains(propertyName)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + AttributeDataModelDiagnosticRules.PropertyNameConflictsWithGeneratedMember, + isBlocking: true, + parameter, + propertyName + ) + ); + } + if (info.IsNestedModel && !IsGeneratedAttributeModel(propertyType)) { diagnostics.Add( @@ -456,6 +468,12 @@ static ParameterAttributeInfo ReadParameterAttributes( sources.Add(new PropertySource(AttributePropertySource.ConstructorName, ctorName, -1)); else if (ctorIndex >= 0) sources.Add(new PropertySource(AttributePropertySource.ConstructorIndex, null, ctorIndex)); + else + { + // A bare [Argument] names the constructor parameter after the model property, camel-cased + // (MessageTemplate -> messageTemplate), as ArgumentAttribute.Name documents. + sources.Add(new PropertySource(AttributePropertySource.ConstructorName, ToCamelCase(propertyName), -1)); + } if (ctorDefaultValue is not null) { @@ -1295,6 +1313,9 @@ static string ToPascalCase(string value) } return builder.ToString(); } + + static string ToCamelCase(string value) => + string.IsNullOrEmpty(value) ? value : char.ToLowerInvariant(value[0]) + value.Substring(1); } /// diff --git a/src/src/SourceGeneratorFramework.Generators/README.md b/src/src/SourceGeneratorFramework.Generators/README.md index e43fbe6..4aeafee 100644 --- a/src/src/SourceGeneratorFramework.Generators/README.md +++ b/src/src/SourceGeneratorFramework.Generators/README.md @@ -17,16 +17,18 @@ The generator emits marker attributes into your compilation: - `[Property]` — a record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). - `[Property(string name)]` — explicit named property source. - `[Property(..., DefaultValue = ...)]` — provides a fallback value when the named property is not present. -- `[Argument]` — a record parameter is populated from a constructor argument by parameter name. +- `[Argument]` — a record parameter is populated from the constructor parameter named after it, camel-cased (`MessageTemplate` → `messageTemplate`). - `[Argument(int index)]` — constructor argument by parameter index. - `[Argument(string name)]` — constructor argument by parameter name. - `[Argument(..., DefaultValue = ...)]` — provides a fallback value when the constructor argument is not present. -- `[NestedModel]` — a record parameter is populated by recursively calling `FromAttributeData` on a nested generated model. +- `[NestedModel]` — a record parameter is populated from the same attribute by calling the nested generated model's `FromAttributeDataUnchecked`, which skips its target-attribute check. - `[Exclude]` — skips auto-discovery for this parameter. - `[GenericTypeArgument]` — a record parameter is populated from a generic type argument of the attribute class. - `[GenericTypeArgument(int index)]` — generic type argument by position. - `[GenericTypeArgument(string name)]` — generic type argument by type parameter name. +Names are looked up case-sensitively at run time, and a name that is not found yields the default value, so a mapping mistake compiles cleanly. `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`) reports case-only mismatches, named sources that are not public settable members, generated-member name clashes, and `[NestedModel]` mappings the outer attribute does not line up with. See the [naming rules](../../../docs/wiki/Attribute-Data-Models.md#naming-rules). + ### Default values `DefaultValue` is a runtime fallback. If the requested property or argument is not found on the attribute, the generated `FromAttributeData` method uses the supplied `DefaultValue` instead of `default(T)`. @@ -65,12 +67,19 @@ readonly record struct RequiredAttributeData(bool Exists, bool AllowEmptyStrings if (!TargetAttribute.Equals(attributeData.AttributeClass)) return Empty; + return FromAttributeDataUnchecked(attributeData); + } + + public static RequiredAttributeData FromAttributeDataUnchecked(AttributeData attributeData) + { attributeData.TryGetNamedArgument("AllowEmptyStrings", out var allowEmptyStrings); return new(true, allowEmptyStrings); } } ``` +`FromAttributeDataUnchecked` holds the argument reading without the `TargetAttribute` check; it exists for nested models. + ### String target names When the attribute type is not referenced in the generator project, pass the fully-qualified name as a string. This is useful for attributes that are newer than the generator's target framework (e.g., `LengthAttribute` in .NET 8+) or attributes that are generated by the same generator. @@ -123,7 +132,7 @@ public readonly partial record struct RequiredAttributeData( ); ``` -Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that derives from `ValidationAttribute`, including `RequiredAttribute`. +The nested model is read from the outer attribute's arguments, using its own `[Argument]`/`[Property]` mappings, without checking its own `TargetAttribute`. The outer attribute does not have to derive from the nested model's target; it only needs the same constructor parameter and property names. The nested model always has `Exists` set to `true`, and a mapped argument the outer attribute does not declare takes the nested model's `DefaultValue`. `ValidationAttributeData` also uses `MatchByInheritance = true`, so it can be used on its own against any attribute deriving from `ValidationAttribute`. ### Generic type arguments diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs new file mode 100644 index 0000000..a54a4f1 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs @@ -0,0 +1,168 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +/// +/// Contains assertion methods for the arguments of an application: constructor +/// arguments by ordinal or parameter name, and named arguments by property or field name. +/// +/// +/// Names are matched case-sensitively, as generated attribute-data model code matches them. Values are compared +/// with , so enums match by value or member name and +/// typeof arguments match a , or type name. A failure lists +/// every argument of the application. +/// +public static partial class AttributeDataAssertions +{ + /// + /// Asserts that the constructor argument at has the value. + /// + /// The attribute application. + /// The zero-based constructor parameter position. + /// The expected value. + /// The argument when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasConstructorArgument( + this AttributeData attribute, + int ordinal, + object? expected + ) + { + if (attribute is null) + return AssertionResult.Failed($"expected {nameof(AttributeData)} is null"); + + var arguments = AttributeArguments.From(attribute); + return arguments.TryGetConstructorArgument(ordinal, out var argument) + ? Check(arguments, argument, expected, null) + : AssertionResult.Failed($"there is no constructor argument #{ordinal} in {arguments}"); + } + + /// + /// Asserts that the constructor argument for has the + /// value, however it was supplied. + /// + /// The attribute application. + /// The constructor parameter name. + /// The expected value. + /// The argument when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasConstructorArgument( + this AttributeData attribute, + string parameterName, + object? expected + ) => HasConstructorArgument(attribute, parameterName, expected, null); + + /// + /// Asserts that the constructor argument for has the + /// value and was supplied in the expected . + /// + /// The attribute application. + /// The constructor parameter name. + /// The expected value. + /// How the argument must have been supplied, or for any form. + /// The argument when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasConstructorArgument( + this AttributeData attribute, + string parameterName, + object? expected, + AttributeArgumentForm? form + ) + { + if (attribute is null) + return AssertionResult.Failed($"expected {nameof(AttributeData)} is null"); + + var arguments = AttributeArguments.From(attribute); + return arguments.TryGetConstructorArgument(parameterName, out var argument) + ? Check(arguments, argument, expected, form) + : AssertionResult.Failed($"there is no constructor parameter '{parameterName}' in {arguments}"); + } + + /// + /// Asserts that the constructor argument for was not written at the application + /// site: the parameter is optional and defaulted, or the bound constructor does not have it. + /// + /// The attribute application. + /// The constructor parameter name. + /// The attribute when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveConstructorArgument( + this AttributeData attribute, + string parameterName + ) + { + if (attribute is null) + return AssertionResult.Failed($"expected {nameof(AttributeData)} is null"); + + var arguments = AttributeArguments.From(attribute); + return arguments.TryGetConstructorArgument(parameterName, out var argument) && argument.IsSupplied + ? AssertionResult.Failed($"constructor parameter '{parameterName}' was supplied in {arguments}") + : AssertionResult.Passed(attribute); + } + + /// + /// Asserts that the named argument was supplied with the value. + /// + /// The attribute application. + /// The property or field name. + /// The expected value. + /// The argument when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNamedArgument( + this AttributeData attribute, + string name, + object? expected + ) + { + if (attribute is null) + return AssertionResult.Failed($"expected {nameof(AttributeData)} is null"); + + var arguments = AttributeArguments.From(attribute); + return arguments.TryGetNamedArgument(name, out var argument) + ? Check(arguments, argument, expected, null) + : AssertionResult.Failed($"there is no named argument '{name}' in {arguments}"); + } + + /// + /// Asserts that the named argument was not supplied. + /// + /// The attribute application. + /// The property or field name. + /// The attribute when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveNamedArgument(this AttributeData attribute, string name) + { + if (attribute is null) + return AssertionResult.Failed($"expected {nameof(AttributeData)} is null"); + + var arguments = AttributeArguments.From(attribute); + return arguments.TryGetNamedArgument(name, out _) + ? AssertionResult.Failed($"named argument '{name}' was supplied in {arguments}") + : AssertionResult.Passed(attribute); + } + + static AssertionResult Check( + AttributeArguments arguments, + AttributeArgument argument, + object? expected, + AttributeArgumentForm? form + ) + { + if (!argument.ValueEquals(expected)) + return AssertionResult.Failed($"'{argument.Name}' was {argument} in {arguments}"); + + if (form is not null && argument.Form != form) + return AssertionResult.Failed($"'{argument.Name}' was supplied as {argument.Form} in {arguments}"); + + return AssertionResult.Passed(argument); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md index 8244984..f9bd779 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md @@ -153,6 +153,9 @@ imported). `await Assert.That(...)` is terminal and returns the value: inherits its property's accessibility). - `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` / `HasNoErrorDiagnostics`. - `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- `HasConstructorArgument(ordinal | parameterName, expected[, form])` / `DoesNotHaveConstructorArgument` / + `HasNamedArgument` / `DoesNotHaveNamedArgument` — on an `AttributeData`; see + [Attribute argument assertions](#attribute-argument-assertions). - `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened). The `CodeQuery` assertions operate on a `CodeQuery` directly, so they accept a query from any test result — @@ -175,6 +178,32 @@ CodeQueryResult attributeClass = await Assert.That(query await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); ``` +### Attribute argument assertions + +`AttributeDataAssertions` assert on an `AttributeData`, typically one compiled by `AttributeDataFactory` +from the Testing package. They are the way to test attribute parsing — usually a generated attribute-data +model — against real input: pin the application first, then the model, so a failure says which half is +wrong. + +```csharp +var attribute = AttributeDataFactory.FromUsage("[InfoEvent(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument(0, "custom {Value}"); // by ordinal +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); // by parameter name +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); +await Assert.That(attribute).DoesNotHaveNamedArgument("EventId"); + +var model = InfoEventAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +``` + +Names are matched case-sensitively, as generated models match them. Values compare with +`AttributeArgument.ValueEquals`: numbers across numeric types, enums by value or member name (`"High"`, +`"Level.High"`, `"Sample.Level.High"`), `typeof` arguments by `Type`, `TypeIdentity` or name, arrays +element-wise. Each assertion returns the matched `AttributeArgument`, and a failure lists every argument of +the application, for example +`[InfoEventAttribute(#0 messageTemplate: "custom {Value}", Name = "N")]`. ## Incremental cache tests `GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs b/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs new file mode 100644 index 0000000..b82b30b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs @@ -0,0 +1,174 @@ +using System.Collections; +using System.Globalization; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// A single argument of an attribute application: a constructor argument or a named argument. +/// +/// How the argument was supplied. +/// The constructor parameter name, or the property or field name for a named argument. +/// The constructor parameter position, or for a named argument. +/// The argument value as Roslyn sees it. +public sealed record AttributeArgument(AttributeArgumentForm Form, string Name, int? Ordinal, TypedConstant Value) +{ + /// + /// Gets whether this is a constructor argument, including a one. + /// + public bool IsConstructorArgument => Form != AttributeArgumentForm.NamedArgument; + + /// + /// Gets whether the argument was written at the application site, rather than defaulted. + /// + public bool IsSupplied => Form != AttributeArgumentForm.Defaulted; + + /// + /// Compares the argument value with . + /// + /// The expected value. + /// when the value matches. + /// + /// + /// Numbers compare by value across numeric types, so 42 matches a long argument. + /// An enum argument matches an enum value with the same underlying value, or a member name in any of the + /// forms Member, Enum.Member or Namespace.Enum.Member. + /// A typeof argument matches a , a , or a type name in + /// display, fully-qualified or metadata form. + /// An array argument matches any non-string sequence with matching elements. + /// + /// + public bool ValueEquals(object? expected) => ValueEquals(Value, expected); + + /// + /// Formats the argument as it would be written at the application site, followed by how it was supplied. + /// + public override string ToString() + { + var value = FormatValue(Value); + return Form switch + { + AttributeArgumentForm.NamedArgument => $"{Name} = {value}", + AttributeArgumentForm.Defaulted => $"#{Ordinal} {Name}: {value} (defaulted)", + AttributeArgumentForm.NamedParameter => $"#{Ordinal} {Name}: {value} (by name)", + _ => $"#{Ordinal} {Name}: {value}", + }; + } + + internal static bool ValueEquals(TypedConstant value, object? expected) + { + if (expected is null) + return value.IsNull; + + if (value.IsNull) + return false; + + if (value.Kind == TypedConstantKind.Array) + { + if (expected is string || expected is not IEnumerable sequence) + return false; + + var items = sequence.Cast().ToArray(); + return items.Length == value.Values.Length + && items.Zip(value.Values, static (item, element) => ValueEquals(element, item)).All(static e => e); + } + + if (value.Kind == TypedConstantKind.Type) + return value.Value is ITypeSymbol type && TypeEquals(type, expected); + + if (value.Kind == TypedConstantKind.Enum && expected is string memberName) + { + return EnumMemberName(value) is { } member + && ( + string.Equals(memberName, member, StringComparison.Ordinal) + || string.Equals(memberName, $"{value.Type!.Name}.{member}", StringComparison.Ordinal) + || string.Equals(memberName, $"{value.Type!.ToDisplayString()}.{member}", StringComparison.Ordinal) + ); + } + + // Primitive and enum values, compared by value across numeric types. + return Equals(value.Value, expected) || NumericEquals(value.Value, expected); + } + + internal static string FormatValue(TypedConstant value) + { + if (value.IsNull) + return "null"; + + return value.Kind switch + { + TypedConstantKind.Array => $"[{string.Join(", ", value.Values.Select(FormatValue))}]", + TypedConstantKind.Type => $"typeof({((ITypeSymbol)value.Value!).ToDisplayString()})", + TypedConstantKind.Enum => EnumMemberName(value) is { } member + ? $"{value.Type!.Name}.{member}" + : $"({value.Type!.Name}){ToInvariantString(value.Value)}", + _ => value.Value switch + { + string text => $"\"{text}\"", + char character => $"'{character}'", + bool flag => flag ? "true" : "false", + var other => ToInvariantString(other), + }, + }; + } + + static string? EnumMemberName(TypedConstant value) + { + if (value.Type is not INamedTypeSymbol enumType) + return null; + + foreach (var member in enumType.GetMembers()) + { + if (member is IFieldSymbol { HasConstantValue: true } field && Equals(field.ConstantValue, value.Value)) + return field.Name; + } + + return null; + } + + static bool TypeEquals(ITypeSymbol type, object expected) + { + var metadataName = MetadataName(type); + return expected switch + { + Type runtimeType => string.Equals(metadataName, runtimeType.FullName, StringComparison.Ordinal), + TypeIdentity identity => string.Equals(metadataName, identity.MetadataFullName, StringComparison.Ordinal), + string name => string.Equals(name, metadataName, StringComparison.Ordinal) + || string.Equals(name, type.ToDisplayString(), StringComparison.Ordinal) + || string.Equals( + name, + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + StringComparison.Ordinal + ), + _ => false, + }; + } + + static string MetadataName(ITypeSymbol type) + { + if (type.ContainingType is { } containing) + return MetadataName(containing) + "+" + type.MetadataName; + + return type.ContainingNamespace is { IsGlobalNamespace: false } ns + ? ns.ToDisplayString() + "." + type.MetadataName + : type.MetadataName; + } + + static bool IsNumeric(object? value) => + value is byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal or Enum; + + // Floating-point values compare as double: decimal cannot hold their range. + static bool NumericEquals(object? actual, object expected) + { + if (!IsNumeric(actual) || !IsNumeric(expected)) + return false; + + return actual is float or double || expected is float or double + ? Convert.ToDouble(actual, CultureInfo.InvariantCulture) + == Convert.ToDouble(expected, CultureInfo.InvariantCulture) + : Convert.ToDecimal(actual, CultureInfo.InvariantCulture) + == Convert.ToDecimal(expected, CultureInfo.InvariantCulture); + } + + static string ToInvariantString(object? value) => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null"; +} diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeArgumentForm.cs b/src/src/SourceGeneratorFramework.Testing/AttributeArgumentForm.cs new file mode 100644 index 0000000..f416b76 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AttributeArgumentForm.cs @@ -0,0 +1,28 @@ +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// How an attribute argument was supplied at the application site. +/// +public enum AttributeArgumentForm +{ + /// + /// A constructor argument supplied by position: [Log("text")]. Also used for every constructor argument + /// of an attribute read from metadata, where the application syntax is not available. + /// + Positional, + + /// + /// A constructor argument supplied by parameter name: [Log(message: "text")]. + /// + NamedParameter, + + /// + /// An optional constructor parameter that was not supplied, so it carries the parameter's default value. + /// + Defaulted, + + /// + /// A named argument that sets a property or field: [Log(EventId = 7)]. + /// + NamedArgument, +} diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs b/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs new file mode 100644 index 0000000..bed09e0 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// The arguments of an attribute application, by constructor parameter ordinal, constructor parameter name and +/// named argument, with how each was supplied. +/// +/// +/// exposes constructor arguments by position only and does not say whether an +/// optional parameter was written or defaulted. This view pairs each constructor argument with its parameter name +/// and , which is what a test of an attribute-data model usually needs to +/// state. +/// +public sealed class AttributeArguments +{ + AttributeArguments(AttributeData attribute, ImmutableArray all) + { + Attribute = attribute; + All = all; + Constructor = [.. all.Where(static a => a.IsConstructorArgument)]; + Named = [.. all.Where(static a => !a.IsConstructorArgument)]; + } + + /// + /// Gets the attribute application. + /// + public AttributeData Attribute { get; } + + /// + /// Gets every argument: constructor arguments in parameter order, then named arguments in source order. + /// + public ImmutableArray All { get; } + + /// + /// Gets the constructor arguments in parameter order, including defaulted optional parameters. + /// + public ImmutableArray Constructor { get; } + + /// + /// Gets the named arguments in source order. + /// + public ImmutableArray Named { get; } + + /// + /// Creates the view for an attribute application. + /// + /// The attribute application. + /// Cancels reading the application syntax. + /// The arguments of . + public static AttributeArguments From(AttributeData attribute, CancellationToken cancellationToken = default) + { + if (attribute is null) + throw new ArgumentNullException(nameof(attribute)); + + var builder = ImmutableArray.CreateBuilder(); + var parameters = attribute.AttributeConstructor?.Parameters ?? []; + var syntax = attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken) as AttributeSyntax; + + var positionalCount = 0; + HashSet namedParameters = new(StringComparer.Ordinal); + if (syntax?.ArgumentList is { } argumentList) + { + foreach (var argument in argumentList.Arguments) + { + if (argument.NameColon is { } nameColon) + namedParameters.Add(nameColon.Name.Identifier.ValueText); + else if (argument.NameEquals is null) + positionalCount++; + } + } + + for (var i = 0; i < attribute.ConstructorArguments.Length; i++) + { + var name = i < parameters.Length ? parameters[i].Name : $"arg{i}"; + var form = + syntax is null || i < positionalCount ? AttributeArgumentForm.Positional + : namedParameters.Contains(name) ? AttributeArgumentForm.NamedParameter + : AttributeArgumentForm.Defaulted; + + builder.Add(new(form, name, i, attribute.ConstructorArguments[i])); + } + + foreach (var named in attribute.NamedArguments) + builder.Add(new(AttributeArgumentForm.NamedArgument, named.Key, null, named.Value)); + + return new(attribute, builder.ToImmutable()); + } + + /// + /// Gets the constructor argument at a parameter position. + /// + /// The zero-based constructor parameter position. + /// The argument, when the constructor has that many parameters. + /// when the argument exists. + public bool TryGetConstructorArgument(int ordinal, out AttributeArgument argument) + { + argument = Constructor.FirstOrDefault(a => a.Ordinal == ordinal)!; + return argument is not null; + } + + /// + /// Gets the constructor argument for a parameter name. The name is matched case-sensitively, as the generated + /// attribute-data model code matches it. + /// + /// The constructor parameter name. + /// The argument, when the bound constructor has that parameter. + /// when the argument exists. + public bool TryGetConstructorArgument(string parameterName, out AttributeArgument argument) + { + argument = Constructor.FirstOrDefault(a => string.Equals(a.Name, parameterName, StringComparison.Ordinal))!; + return argument is not null; + } + + /// + /// Gets the named argument for a property or field name, matched case-sensitively. + /// + /// The property or field name. + /// The argument, when it was supplied. + /// when the argument was supplied. + public bool TryGetNamedArgument(string name, out AttributeArgument argument) + { + argument = Named.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.Ordinal))!; + return argument is not null; + } + + /// + /// Formats the application with every argument, for example + /// [InfoEvent(#0 messageTemplate: "text", EventId = 7)]. Used in assertion failure messages. + /// + public override string ToString() => $"[{Attribute.AttributeClass?.Name}({string.Join(", ", All)})]"; +} diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeDataFactory.cs b/src/src/SourceGeneratorFramework.Testing/AttributeDataFactory.cs new file mode 100644 index 0000000..ba075a2 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AttributeDataFactory.cs @@ -0,0 +1,217 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// Compiles attribute applications into , so an attribute-data model's +/// FromAttributeData can be tested against exactly what Roslyn produces, without running a generator. +/// +/// +/// +/// References and usings come from the : add the assembly declaring the +/// attribute with and its namespace with +/// , or declare the attribute in +/// , which are compiled alongside. +/// +/// +/// A compilation error throws listing the errors, so a misspelt +/// parameter or property name fails the test instead of producing an attribute with missing arguments. +/// +/// +public static class AttributeDataFactory +{ + const string TargetName = "AttributeDataTarget"; + const string HostName = "AttributeDataHost"; + + // Tried in order when the attribute does not allow a class; each places the application on one kind of + // declaration and reads it back from the compilation. + static readonly ( + AttributeTargets Target, + Func Source, + Func Symbol + )[] Placements = + [ + (AttributeTargets.Class, a => $"[{a}] public class {TargetName} {{ }}", c => Type(c, TargetName)), + (AttributeTargets.Method, a => Host($"[{a}] public void {TargetName}() {{ }}"), c => Member(c, TargetName)), + ( + AttributeTargets.Property, + a => Host($"[{a}] public int {TargetName} {{ get; set; }}"), + c => Member(c, TargetName) + ), + (AttributeTargets.Field, a => Host($"[{a}] public int {TargetName};"), c => Member(c, TargetName)), + ( + AttributeTargets.Parameter, + a => Host($"public void M([{a}] int {TargetName}) {{ }}"), + c => (Member(c, "M") as IMethodSymbol)?.Parameters.FirstOrDefault() + ), + (AttributeTargets.Struct, a => $"[{a}] public struct {TargetName} {{ }}", c => Type(c, TargetName)), + (AttributeTargets.Interface, a => $"[{a}] public interface {TargetName} {{ }}", c => Type(c, TargetName)), + (AttributeTargets.Enum, a => $"[{a}] public enum {TargetName} {{ }}", c => Type(c, TargetName)), + (AttributeTargets.Delegate, a => $"[{a}] public delegate void {TargetName}();", c => Type(c, TargetName)), + ( + AttributeTargets.Constructor, + a => Host($"[{a}] public {HostName}() {{ }}"), + c => Type(c, HostName)?.InstanceConstructors.FirstOrDefault() + ), + ( + AttributeTargets.Event, + a => Host($"[{a}] public event global::System.Action? {TargetName};"), + c => Member(c, TargetName) + ), + ( + AttributeTargets.GenericParameter, + a => $"public class {TargetName}<[{a}] T> {{ }}", + c => c.GetTypeByMetadataName(TargetName + "`1")?.TypeParameters.FirstOrDefault() + ), + (AttributeTargets.Assembly, a => $"[assembly: {a}]", c => c.Assembly), + (AttributeTargets.Module, a => $"[module: {a}]", c => c.SourceModule), + ]; + + /// + /// Compiles a single attribute application and returns it. + /// + /// The application, with or without brackets: [Log("text", EventId = 7)]. + /// Supplies references, usings and language settings; when omitted. + /// The attribute application. + /// + /// The application is placed on a class when the attribute allows it, and otherwise on the first declaration + /// kind its allows, so method-only and parameter-only attributes work too. + /// + public static AttributeData FromUsage(string attributeUsage, SourceGeneratorTestOptions? options = null) + { + if (string.IsNullOrWhiteSpace(attributeUsage)) + throw new ArgumentException("The attribute usage cannot be null or whitespace.", nameof(attributeUsage)); + + options ??= SourceGeneratorTestOptions.Default; + + var usage = attributeUsage.Trim(); + if (usage.Length >= 2 && usage[0] == '[' && usage[usage.Length - 1] == ']') + usage = usage.Substring(1, usage.Length - 2).Trim(); + + var placement = Placements[0]; + var compilation = Compile(placement.Source(usage), options); + var attributes = placement.Symbol(compilation)?.GetAttributes() ?? []; + + // The class placement binds the attribute type even when the attribute does not allow classes; its + // AttributeUsage then says where it can go. + var validOn = ValidOn(attributes.FirstOrDefault()?.AttributeClass); + if (!validOn.HasFlag(AttributeTargets.Class)) + { + placement = Placements.FirstOrDefault(p => validOn.HasFlag(p.Target)); + if (placement.Source is null) + throw new InvalidOperationException($"No supported declaration accepts [{usage}] ({validOn})."); + + compilation = Compile(placement.Source(usage), options); + attributes = placement.Symbol(compilation)?.GetAttributes() ?? []; + } + + ThrowOnErrors(compilation, usage); + + return attributes.Length == 1 + ? attributes[0] + : throw new InvalidOperationException( + $"Expected exactly one attribute application in [{usage}] but found {attributes.Length}." + ); + } + + /// + /// Compiles and returns the attribute applications on a type or one of its members. + /// + /// The source declaring the attributed symbol. + /// The metadata name of the type, for example Sample.IEvents or Sample.Outer+Inner. + /// A member of the type to read instead of the type itself. It must name exactly one member. + /// Supplies references, usings and language settings; when omitted. + /// The attribute applications, in declaration order. + public static ImmutableArray FromSource( + string source, + string typeMetadataName, + string? memberName = null, + SourceGeneratorTestOptions? options = null + ) + { + if (source is null) + throw new ArgumentNullException(nameof(source)); + if (string.IsNullOrWhiteSpace(typeMetadataName)) + throw new ArgumentException( + "The type metadata name cannot be null or whitespace.", + nameof(typeMetadataName) + ); + + var compilation = Compile(source, options ?? SourceGeneratorTestOptions.Default); + ThrowOnErrors(compilation, null); + + var type = + compilation.GetTypeByMetadataName(typeMetadataName) + ?? throw new InvalidOperationException($"Type '{typeMetadataName}' was not found in the compilation."); + + if (memberName is null) + return type.GetAttributes(); + + var members = type.GetMembers(memberName); + return members.Length == 1 + ? members[0].GetAttributes() + : throw new InvalidOperationException( + $"Expected exactly one member named '{memberName}' on '{typeMetadataName}' but found {members.Length}." + ); + } + + static CSharpCompilation Compile(string source, SourceGeneratorTestOptions options) + { + CSharpParseOptions parseOptions = new(options.LanguageVersion); + SyntaxTree[] trees = + [ + CSharpSyntaxTree.ParseText(SourceGeneratorHelpers.PrepareSource(source, options), parseOptions), + .. options.AdditionalSources.Select(s => CSharpSyntaxTree.ParseText(s, parseOptions)), + ]; + + return SourceGeneratorHelpers.CreateCompilation( + trees, + SourceGeneratorHelpers.ResolveReferences(options, typeof(AttributeDataFactory).Assembly), + options + ); + } + + static void ThrowOnErrors(Compilation compilation, string? usage) + { + var errors = compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).ToList(); + if (errors.Count == 0) + return; + + var subject = usage is null ? "the source" : $"[{usage}]"; + throw new InvalidOperationException( + $"Compiling {subject} failed:\n" + + string.Join("\n", errors.Select(static e => e.ToString())) + + "\n\nSource:\n" + + compilation.SyntaxTrees.First().GetText() + ); + } + + static AttributeTargets ValidOn(INamedTypeSymbol? attributeClass) + { + for (var type = attributeClass; type is not null; type = type.BaseType) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + attribute.AttributeClass is { Name: nameof(AttributeUsageAttribute) } usageClass + && usageClass.ContainingNamespace.ToDisplayString() == "System" + && attribute.ConstructorArguments.FirstOrDefault().Value is int validOn + ) + { + return (AttributeTargets)validOn; + } + } + } + + return AttributeTargets.All; + } + + static string Host(string member) => $"public class {HostName} {{ {member} }}"; + + static INamedTypeSymbol? Type(Compilation compilation, string name) => compilation.GetTypeByMetadataName(name); + + static ISymbol? Member(Compilation compilation, string name) => + Type(compilation, HostName)?.GetMembers(name).FirstOrDefault(); +} diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md index 838c629..9512b13 100644 --- a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md @@ -15,6 +15,9 @@ dotnet add package Purview.SourceGeneratorFramework.Testing - **`SourceGeneratorTestOptions`** — options for configuring references, namespaces, analyzer-config values, output kind, and whether to emit the output compilation to an assembly. - **`DriverRunResult`** — wrapper around `GeneratorDriverRunResult` that exposes generated trees, the output compilation, emitted assembly, and log entries. - **`DriverRunResultExtensions`** — assertion helpers such as `AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, `AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, and more. +- **`AttributeDataFactory`** / **`AttributeArguments`** — compile an attribute application into real + `AttributeData`, and inspect its arguments by ordinal, constructor parameter name and named argument, + with how each was supplied. See [Testing attribute applications](#testing-attribute-applications). - **`ITestOutput`** / **`NullTestOutput`** — abstraction for capturing generator log output during tests. ## Usage @@ -234,6 +237,38 @@ var query = result.Generated(); query.GetClass("Service").HasProperty("Name", query.MakeNullable(TypeReference.Create())); ``` +## Testing attribute applications + +`AttributeDataFactory` compiles attribute applications into the `AttributeData` Roslyn produces, without +running a generator, so attribute parsing — typically a generated attribute-data model — can be tested +against real input: + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application, placed on a declaration its AttributeUsage allows; brackets optional. +var attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +References and usings come from the options (`AdditionalAssemblyTypes`, `AdditionalNamespaces`, and +`AdditionalSources` for an attribute declared in the test). A compilation error throws +`InvalidOperationException` listing the errors, so a misspelt argument name fails the test. + +`AttributeArguments.From(attribute)` describes every argument as an `AttributeArgument`: its name +(constructor parameter or property), ordinal, value, and `AttributeArgumentForm` — `Positional`, +`NamedParameter` (`message: "x"`), `Defaulted` (an optional parameter that was not written) or +`NamedArgument` (`Name = "x"`). Look arguments up with `TryGetConstructorArgument(ordinal)`, +`TryGetConstructorArgument(parameterName)` and `TryGetNamedArgument(name)`; names are case-sensitive, as +generated models are. `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member +name, `typeof` arguments by `Type`, `TypeIdentity` or name, and arrays element-wise. `ToString()` gives +`[LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")]`. + +The TUnit package wraps these in assertions (`HasConstructorArgument`, `HasNamedArgument`, …). ## Refactoring tests `RefactoringTestRunner` runs a `CodeRefactoringProvider` against a test document: diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index 825e59e..4937ce0 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -433,11 +433,13 @@ public readonly partial record struct RequiredAttributeData( Supported mapping attributes: - `[Property]` — reads a named attribute property (the property name is inferred from the parameter name unless overridden with `Name = ...`). -- `[Argument]` — reads a constructor argument by parameter name. -- `[Argument(int index)]` — reads a constructor argument by position. -- `[NestedModel]` — populates a nested `[Generate]`-annotated attribute-data model type. +- `[Argument]` — reads the constructor parameter named after the record parameter, camel-cased; `[Argument("name")]` names it explicitly. +- `[Argument(int index)]` — reads a constructor argument by position. Prefer a name: an index moves when overloads change. +- `[NestedModel]` — populates a nested `[Generate]`-annotated attribute-data model type from the same attribute's arguments, matched by name. - `[GenericTypeArgument]` — reads a generic type argument of the attribute class. +Names are matched case-sensitively at run time and a missing name yields the default value, so the bundled `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`) reports mappings the generated code would never find. Test a model against real applications with `AttributeDataFactory` and the attribute-argument assertions in the testing packages. + You can also target an attribute by fully-qualified name, which is useful when the attribute type is not available in the generator project (e.g., `LengthAttribute` in .NET 8+ or a self-generated attribute): ```csharp diff --git a/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs b/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs index 2dbcfe4..b3a5370 100644 --- a/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs +++ b/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; namespace Purview.SourceGeneratorFramework; @@ -133,4 +134,82 @@ public static class AttributeDataModelDiagnosticRules DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// + /// Diagnostic raised when a mapped constructor parameter or named argument matches a member of the attribute + /// only when case is ignored. Names are looked up case-sensitively, so the value is never read. + /// + public static readonly DiagnosticDescriptor MappedNameCaseMismatch = new( + "ADM0011", + "Mapped name differs from the attribute member only by case", + "'{0}' does not match the {1} '{2}' on '{3}'; names are matched case-sensitively, so the value is never read", + "Naming", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// Diagnostic raised when a property reads a named argument that is not a public settable property or field + /// of the attribute, or a bare [Argument] implies a constructor parameter the attribute does not declare. + /// + public static readonly DiagnosticDescriptor MappedMemberNotFound = new( + "ADM0012", + "Mapped attribute member does not exist", + "Property '{0}' reads the {1} '{2}', which '{3}' does not have, so it always takes its default value", + "Naming", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// Diagnostic raised when a nested model property reads only names the outer attribute does not declare. + /// + public static readonly DiagnosticDescriptor NestedModelMemberNotDeclared = new( + "ADM0013", + "Nested model property is not declared on the outer attribute", + "Nested model '{0}' property '{1}' reads {2}, none of which '{3}' declares, so it always takes its default value", + "Naming", + DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + + /// + /// Diagnostic raised when a model property has the name of a member the generator emits. + /// + public static readonly DiagnosticDescriptor PropertyNameConflictsWithGeneratedMember = new( + "ADM0014", + "Property name conflicts with a generated member", + "Property '{0}' has the same name as a generated member; rename the parameter", + "Naming", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// + /// Diagnostic raised when a nested model reads a constructor argument by index and that index names a + /// different parameter on the outer attribute. + /// + public static readonly DiagnosticDescriptor NestedModelConstructorIndexMismatch = new( + "ADM0015", + "Nested model constructor index names a different parameter on the outer attribute", + "Nested model '{0}' property '{1}' reads constructor index {2}, which is '{3}' on '{4}' but '{5}' on '{6}'; map it by name instead", + "Naming", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// The members every generated attribute-data model declares. A model property with one of these names + /// collides with the generated code (). + /// + public static readonly ImmutableArray GeneratedMemberNames = + [ + "Exists", + "Empty", + "TargetAttribute", + "AllAttributeData", + "FromAttributeData", + "TryFromAttributeData", + "FromAttributeDataUnchecked", + ]; } diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelNamingAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelNamingAnalyzerTests.cs new file mode 100644 index 0000000..513b731 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelNamingAnalyzerTests.cs @@ -0,0 +1,315 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class AttributeDataModelNamingAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + const string Definitions = """ + using System; + using Purview.SourceGeneratorFramework.Generators; + + namespace Purview.SourceGeneratorFramework.Generators + { + [AttributeUsage(AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] + public sealed class GenerateAttribute : Attribute + { + public GenerateAttribute(Type targetAttribute) { } + public GenerateAttribute(string targetAttributeName) { } + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class PropertyAttribute : Attribute + { + public PropertyAttribute(object? defaultValue = null) { } + public string? Name { get; set; } + public object? DefaultValue { get; set; } + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class ArgumentAttribute : Attribute + { + public ArgumentAttribute(string? name = null, object? defaultValue = null) { } + public ArgumentAttribute(int index, object? defaultValue = null) { } + public string? Name { get; set; } + public int Index { get; set; } = -1; + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class NestedModelAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class ExcludeAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class GenericTypeArgumentAttribute : Attribute { } + } + + namespace Sample + { + [AttributeUsage(AttributeTargets.Method)] + public sealed class LogAttribute : Attribute + { + public LogAttribute(int level, string? messageTemplate = null) { } + public int Level { get; set; } + public string? MessageTemplate { get; set; } + public int EventId { get; set; } + public string? Name { get; set; } + public string? Category { get; private set; } + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class InfoAttribute : Attribute + { + public InfoAttribute() { } + public InfoAttribute(string? messageTemplate, int eventId = -1) { } + public string? MessageTemplate { get; set; } + public int EventId { get; set; } + public string? Name { get; set; } + } + } + """; + + static string Source(string models) => Definitions + "\nnamespace Sample\n{\n" + models + "\n}\n"; + + [Test] + public async Task CorrectlyNamedModel_ReportsNothing(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData( + [Argument("level")] [Property] int Level, + [Argument] [Property] string? MessageTemplate, + int EventId, + [Property(Name = "Name")] string? EventName + ); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task ConstructorNameDiffersByCase_ReportsMappedNameCaseMismatch(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Argument("MessageTemplate")] string? Template); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedNameCaseMismatch); + } + + [Test] + public async Task NamedArgumentDiffersByCase_ReportsMappedNameCaseMismatch(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Property(Name = "eventId")] int Id); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedNameCaseMismatch); + } + + [Test] + public async Task NamedArgumentWithoutPublicSetter_ReportsMappedMemberNotFound(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Property] string? Category); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedMemberNotFound); + } + + [Test] + public async Task UnannotatedParameterWithNoMatchingProperty_ReportsMappedMemberNotFound( + CancellationToken cancellationToken + ) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData(string? Message); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedMemberNotFound); + } + + [Test] + public async Task BareArgumentWithNoMatchingParameter_ReportsMappedMemberNotFound( + CancellationToken cancellationToken + ) + { + // A bare [Argument] reads the camel-cased constructor parameter "eventId", which LogAttribute lacks. + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Argument] int EventId); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedMemberNotFound); + } + + [Test] + public async Task ExplicitArgumentWithNoMatchingParameter_LeavesItToConstructorMemberNotFound( + CancellationToken cancellationToken + ) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Argument("missing")] int EventId); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + [Arguments("Exists")] + [Arguments("Empty")] + [Arguments("TargetAttribute")] + [Arguments("FromAttributeDataUnchecked")] + public async Task PropertyNamedLikeGeneratedMember_ReportsConflict( + string propertyName, + CancellationToken cancellationToken + ) + { + var source = Source( + $$""" + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Exclude] int Level, bool {{propertyName}}); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert + .That(result) + .HasDiagnostic(AttributeDataModelNamingAnalyzer.PropertyNameConflictsWithGeneratedMember); + } + + [Test] + public async Task NestedModel_MemberNotOnOuterAttribute_ReportsInfo(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Property] string? Name + ); + + [Generate(typeof(InfoAttribute))] + public readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + // Only Level: InfoAttribute declares messageTemplate and Name. + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.NestedModelMemberNotDeclared); + } + + [Test] + public async Task NestedModel_ConstructorIndexNamesDifferentParameter_ReportsMismatch( + CancellationToken cancellationToken + ) + { + // Index 1 is messageTemplate on LogAttribute but eventId on InfoAttribute. + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Argument(1)] string? MessageTemplate); + + [Generate(typeof(InfoAttribute))] + public readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.NestedModelConstructorIndexMismatch); + } + + [Test] + public async Task NestedModel_NameDiffersByCaseOnOuterAttribute_ReportsMappedNameCaseMismatch( + CancellationToken cancellationToken + ) + { + var source = Source( + """ + [AttributeUsage(AttributeTargets.Method)] + public sealed class WarnAttribute : Attribute + { + public WarnAttribute(string? MessageTemplate) { } + } + + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData([Argument("messageTemplate")] string? MessageTemplate); + + [Generate(typeof(WarnAttribute))] + public readonly partial record struct WarnAttributeData([NestedModel] LogAttributeData Log); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelNamingAnalyzer.MappedNameCaseMismatch); + } + + [Test] + public async Task NestedModel_FullyDeclaredOnOuterAttribute_ReportsNothing(CancellationToken cancellationToken) + { + var source = Source( + """ + [Generate(typeof(LogAttribute))] + public readonly partial record struct LogAttributeData( + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Property] int EventId, + [Property] string? Name + ); + + [Generate(typeof(InfoAttribute))] + public readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); + """ + ); + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs index c598cc2..8061420 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs @@ -47,7 +47,7 @@ public readonly partial record struct DebugAttributeData( await Assert.That(debugGenerated).Contains("global::Sample.LogAttributeData Log"); await Assert .That(debugGenerated) - .Contains("var log = global::Sample.LogAttributeData.FromAttributeData(attributeData);"); + .Contains("var log = global::Sample.LogAttributeData.FromAttributeDataUnchecked(attributeData);"); await Assert .That(debugGenerated) .Contains( @@ -69,6 +69,33 @@ await Assert ); } + [Test] + public async Task LogAttributeData_PositionalMessage_IsRead() + { + // LogAttribute.Message has a private setter, so the message only ever arrives through the constructor. + var attribute = AttributeDataFactory.FromUsage("[Log(\"hello\", EventId = 3)]", ExamplesOptions); + + var model = Examples.LogAttributeData.FromAttributeData(attribute); + + await Assert.That(model.Message).IsEqualTo("hello"); + await Assert.That(model.EventId).IsEqualTo(3); + await Assert.That(model.Level).IsEqualTo(LogLevel.Information); + } + + [Test] + public async Task DebugAttributeData_PositionalMessage_FlowsIntoNestedModel() + { + var attribute = AttributeDataFactory.FromUsage("[Debug(\"hi\")]", ExamplesOptions); + + var model = Examples.DebugAttributeData.FromAttributeData(attribute); + + await Assert.That(model.Log.Message).IsEqualTo("hi"); + await Assert.That(model.Level).IsEqualTo(LogLevel.Debug); + } + + static LogAttributeDataTestOptions ExamplesOptions => + new() { AdditionalNamespaces = ["Purview.SourceGeneratorFramework.Examples"] }; + static async Task GetGeneratedStringAsync( DriverRunResult result, string fileName, diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogEventAttributeDataTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogEventAttributeDataTests.cs new file mode 100644 index 0000000..5dd94e8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogEventAttributeDataTests.cs @@ -0,0 +1,207 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Examples; +using Purview.SourceGeneratorFramework.Generators; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; +using Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +namespace Purview.SourceGeneratorFramework.ExampleGenerator; + +/// +/// Runs the generated and models against +/// real . does not derive from +/// , so the [NestedModel] must be read from the outer attribute without the +/// nested model's own target-attribute check. +/// +public class LogEventAttributeDataTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + using Purview.SourceGeneratorFramework.Examples; + + namespace Sample; + + public interface IEvents + { + [InfoEvent("custom {Value}", 42)] + void Positional(int value); + + [InfoEvent(MessageTemplate = "named {Value}", EventId = 7, Name = "NamedEvent")] + void Named(int value); + + [InfoEvent("mixed {Value}", Name = "MixedEvent")] + void Mixed(int value); + + [InfoEvent] + void Omitted(int value); + + [LogEvent(LogLevel.Warning, "direct {Value}")] + void Direct(int value); + } + """; + + // AttributeDataFactory compiles the source and reads the application, without running a generator. + static AttributeData AttributeOf(string methodName) => + AttributeDataFactory + .FromSource(Source, "Sample.IEvents", methodName, new LogEventAttributeDataTestOptions()) + .Single(); + + [Test] + public async Task Applications_SupplyArgumentsInTheExpectedForms() + { + // Pins the attribute side of the test, so a model failure below is the model's fault. + await Assert.That(AttributeOf("Positional")).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert + .That(AttributeOf("Positional")) + .HasConstructorArgument("eventId", 42, AttributeArgumentForm.Positional); + await Assert.That(AttributeOf("Named")).HasNamedArgument("MessageTemplate", "named {Value}"); + await Assert.That(AttributeOf("Mixed")).DoesNotHaveNamedArgument("EventId"); + await Assert.That(AttributeOf("Direct")).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + } + + [Test] + public async Task NestedModel_PositionalArguments_FlowIntoNestedModel() + { + var model = InfoEventAttributeData.FromAttributeData(AttributeOf("Positional")); + + await Assert.That(model.Exists).IsTrue(); + await Assert.That(model.Log.Exists).IsTrue(); + await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); + await Assert.That(model.Log.EventId).IsEqualTo(42); + await Assert.That(model.Log.Name).IsNull(); + } + + [Test] + public async Task NestedModel_NamedArguments_FlowIntoNestedModel() + { + var model = InfoEventAttributeData.FromAttributeData(AttributeOf("Named")); + + await Assert.That(model.Log.MessageTemplate).IsEqualTo("named {Value}"); + await Assert.That(model.Log.EventId).IsEqualTo(7); + await Assert.That(model.Log.Name).IsEqualTo("NamedEvent"); + } + + [Test] + public async Task NestedModel_MixedArguments_FlowIntoNestedModel() + { + var found = InfoEventAttributeData.TryFromAttributeData(AttributeOf("Mixed"), out var model); + + await Assert.That(found).IsTrue(); + await Assert.That(model.Log.MessageTemplate).IsEqualTo("mixed {Value}"); + await Assert.That(model.Log.Name).IsEqualTo("MixedEvent"); + await Assert.That(model.Log.EventId).IsEqualTo(-1); + } + + [Test] + public async Task NestedModel_OmittedArguments_TakeNestedModelDefaults() + { + var model = InfoEventAttributeData.FromAttributeData(AttributeOf("Omitted")); + + await Assert.That(model.Log.Exists).IsTrue(); + await Assert.That(model.Log.MessageTemplate).IsNull(); + await Assert.That(model.Log.EventId).IsEqualTo(-1); + await Assert.That(model.Log.Name).IsNull(); + } + + [Test] + public async Task NestedModel_ArgumentNotDeclaredOnOuterAttribute_TakesNestedModelDefault() + { + // InfoEventAttribute has no level constructor parameter or Level property. + var model = InfoEventAttributeData.FromAttributeData(AttributeOf("Positional")); + + await Assert.That(model.Log.Level).IsEqualTo(LogLevel.Information); + } + + [Test] + public async Task NestedModelTarget_FromAttributeData_StillRejectsOtherAttributeTypes() + { + var attribute = AttributeOf("Positional"); + + await Assert.That(LogEventAttributeData.FromAttributeData(attribute)).IsEqualTo(LogEventAttributeData.Empty); + await Assert.That(LogEventAttributeData.TryFromAttributeData(attribute, out _)).IsFalse(); + await Assert + .That(InfoEventAttributeData.FromAttributeData(AttributeOf("Direct"))) + .IsEqualTo(InfoEventAttributeData.Empty); + } + + [Test] + public async Task NestedModelTarget_FromAttributeData_ReadsItsOwnAttribute() + { + var model = LogEventAttributeData.FromAttributeData(AttributeOf("Direct")); + + await Assert.That(model.Exists).IsTrue(); + await Assert.That(model.Level).IsEqualTo(LogLevel.Warning); + await Assert.That(model.MessageTemplate).IsEqualTo("direct {Value}"); + await Assert.That(model.EventId).IsEqualTo(-1); + } + + [Test] + public async Task Generate_NestedModel_CallsUncheckedParse(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework.Examples; + using Purview.SourceGeneratorFramework.Generators; + + namespace Sample; + + [Generate(typeof(LogEventAttribute))] + public readonly partial record struct LogEventAttributeData( + [Argument("level")] [Property(DefaultValue = LogLevel.Information)] LogLevel Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name + ); + + [Generate(typeof(InfoEventAttribute))] + public readonly partial record struct InfoEventAttributeData([NestedModel] LogEventAttributeData Log); + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions().AssertNoLogErrors(); + + var infoGenerated = await GetGeneratedStringAsync( + result, + "InfoEventAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert + .That(infoGenerated) + .Contains("var log = global::Sample.LogEventAttributeData.FromAttributeDataUnchecked(attributeData);"); + await Assert.That(infoGenerated).DoesNotContain("LogEventAttributeData.FromAttributeData(attributeData)"); + + var logGenerated = await GetGeneratedStringAsync( + result, + "LogEventAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert.That(logGenerated).Contains("public static LogEventAttributeData FromAttributeDataUnchecked("); + await Assert.That(logGenerated).Contains("return FromAttributeDataUnchecked(attributeData);"); + await Assert.That(logGenerated).Contains("attribute = FromAttributeDataUnchecked(attributeData);"); + } + + static async Task GetGeneratedStringAsync( + DriverRunResult result, + string fileName, + CancellationToken cancellationToken + ) + { + var tree = result.GetGeneratedTree(fileName); + return tree is null ? null : (await tree.GetTextAsync(cancellationToken)).ToString(); + } +} + +public sealed record LogEventAttributeDataTestOptions : SourceGeneratorTestOptions +{ + public LogEventAttributeDataTestOptions() + { + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(TypeIdentity), + typeof(LogLevel), + typeof(LogEventAttribute), + typeof(InfoEventAttribute) + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs index fd38848..4f60674 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs @@ -70,7 +70,7 @@ await Assert await Assert .That(generated) .Contains( - "var validationAttribute = global::Test.ValidationAttributeData.FromAttributeData(attributeData)" + "var validationAttribute = global::Test.ValidationAttributeData.FromAttributeDataUnchecked(attributeData)" ); await Assert .That(generated) @@ -1108,6 +1108,121 @@ await Assert await Assert.That(generated).DoesNotContain("global::global::"); } + [Test] + public async Task Generate_PropertyNamesThatAreKeywordsOrShadowParameter_Compile( + CancellationToken cancellationToken + ) + { + // Each property is read into a camel-cased local: event, class and var are keywords, and attributeData + // is the parse method's own parameter. + var source = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class KeywordAttribute(Severity @default = Severity.Low) : System.Attribute + { + public string? Event { get; set; } + public string? Class { get; set; } + public string? Var { get; set; } + public string? AttributeData { get; set; } + public Severity Default { get; set; } = @default; + } + + public enum Severity { Low, High } + + [Generate(typeof(KeywordAttribute))] + public readonly partial record struct KeywordAttributeData( + [Property] string? Event, + [Property] string? Class, + [Property] string? Var, + [Property] string? AttributeData, + [Argument("default", IsEnum = true)] [Property(IsEnum = true)] string? Default + ); + } + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions().AssertNoCompilationErrors(); + + var generated = await GetGeneratedStringAsync( + result, + "KeywordAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert.That(generated).Contains("out var @event)"); + await Assert.That(generated).Contains("out var @class)"); + await Assert.That(generated).Contains("out var @var)"); + await Assert.That(generated).Contains("out var __attributeData)"); + await Assert.That(generated).Contains("global::Microsoft.CodeAnalysis.TypedConstant __defaultTc = default;"); + await Assert.That(generated).Contains("var @default = __defaultTc.ToEnumString()"); + await Assert.That(generated).Contains("return new(true, @event, @class, @var, __attributeData, @default);"); + } + + [Test] + public async Task Generate_BareArgument_ReadsCamelCasedConstructorParameter(CancellationToken cancellationToken) + { + var source = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TemplateAttribute(string messageTemplate) : System.Attribute + { + public string MessageTemplate { get; } = messageTemplate; + } + + [Generate(typeof(TemplateAttribute))] + public readonly partial record struct TemplateAttributeData([Argument] string? MessageTemplate); + } + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions().AssertNoCompilationErrors(); + + var generated = await GetGeneratedStringAsync( + result, + "TemplateAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert + .That(generated) + .Contains("attributeData.TryGetConstructorArgument(\"messageTemplate\", out var messageTemplate)"); + await Assert.That(generated).DoesNotContain("TryGetNamedArgument"); + } + + [Test] + public async Task Generate_PropertyNamedLikeGeneratedMember_SkipsGeneration(CancellationToken cancellationToken) + { + var source = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class FlagAttribute : System.Attribute + { + public bool Exists { get; set; } + } + + [Generate(typeof(FlagAttribute))] + public readonly partial record struct FlagAttributeData([Property] bool Exists); + } + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions(); + + await Assert.That(result.GetGeneratedTree("FlagAttributeData.AttributeDataModel.g.cs")).IsNull(); + } + static async Task GetGeneratedStringAsync( DriverRunResult result, string fileName, diff --git a/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataTestingTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataTestingTests.cs new file mode 100644 index 0000000..3c86b9d --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataTestingTests.cs @@ -0,0 +1,153 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +namespace Purview.SourceGeneratorFramework; + +public class AttributeDataTestingTests +{ + static readonly int[] ExpectedTags = [1, 2]; + + const string Attributes = """ + namespace Sample + { + public enum Level { Low = 1, High = 2 } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public sealed class EventAttribute : System.Attribute + { + public EventAttribute(string? messageTemplate, Level level = Level.Low, int eventId = -1) { } + public string? Name { get; set; } + public System.Type? Payload { get; set; } + public int[]? Tags { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Parameter)] + public sealed class TagAttribute : System.Attribute + { + public TagAttribute(string name) { } + } + } + """; + + static SourceGeneratorTestOptions Options => + new() { AdditionalSources = [Attributes], AdditionalNamespaces = ["Sample"] }; + + [Test] + public async Task FromUsage_MethodOnlyAttribute_IsPlacedOnAMethod() + { + var attribute = AttributeDataFactory.FromUsage("[Event(\"text\")]", Options); + + await Assert.That(attribute.AttributeClass!.Name).IsEqualTo("EventAttribute"); + await Assert.That(attribute).HasConstructorArgument(0, "text"); + } + + [Test] + public async Task FromUsage_ParameterOnlyAttribute_WithoutBrackets_IsPlacedOnAParameter() + { + var attribute = AttributeDataFactory.FromUsage("Tag(\"t\")", Options); + + await Assert.That(attribute).HasConstructorArgument("name", "t"); + } + + [Test] + public async Task FromUsage_MisspeltParameterName_Throws() + { + await Assert + .That(() => AttributeDataFactory.FromUsage("[Event(messageTempl: \"text\")]", Options)) + .Throws() + .WithMessageContaining("CS1739", StringComparison.Ordinal); + } + + [Test] + public async Task Arguments_DescribeFormOrdinalAndName() + { + var attribute = AttributeDataFactory.FromUsage("[Event(\"text\", eventId: 7, Name = \"N\")]", Options); + var arguments = AttributeArguments.From(attribute); + + await Assert + .That(arguments.Constructor.Select(a => a.Form)) + .IsEquivalentTo([ + AttributeArgumentForm.Positional, + AttributeArgumentForm.Defaulted, + AttributeArgumentForm.NamedParameter, + ]); + await Assert + .That(arguments.Constructor.Select(a => a.Name)) + .IsEquivalentTo(["messageTemplate", "level", "eventId"]); + await Assert.That(arguments.Named.Single().Form).IsEqualTo(AttributeArgumentForm.NamedArgument); + await Assert + .That(arguments.ToString()) + .IsEqualTo( + "[EventAttribute(#0 messageTemplate: \"text\", #1 level: Level.Low (defaulted), #2 eventId: 7 (by name), Name = \"N\")]" + ); + } + + [Test] + public async Task Assertions_ConstructorArgumentsByOrdinalNameAndForm() + { + var attribute = AttributeDataFactory.FromUsage("[Event(\"text\", eventId: 7)]", Options); + + await Assert.That(attribute).HasConstructorArgument(0, "text"); + await Assert.That(attribute).HasConstructorArgument("eventId", 7, AttributeArgumentForm.NamedParameter); + await Assert.That(attribute).HasConstructorArgument("level", "Low", AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveConstructorArgument("level"); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + } + + [Test] + public async Task Assertions_ValuesCompareAcrossEnumTypeAndArrayForms() + { + var attribute = AttributeDataFactory.FromUsage( + "[Event(\"text\", Level.High, Payload = typeof(System.String), Tags = new[] { 1, 2 })]", + Options + ); + + await Assert.That(attribute).HasConstructorArgument("level", 2); + await Assert.That(attribute).HasConstructorArgument("level", "Level.High"); + await Assert.That(attribute).HasConstructorArgument("level", "Sample.Level.High"); + await Assert.That(attribute).HasConstructorArgument(1, 2L); + await Assert.That(attribute).HasNamedArgument("Payload", typeof(string)); + await Assert.That(attribute).HasNamedArgument("Payload", "string"); + await Assert.That(attribute).HasNamedArgument("Tags", ExpectedTags); + } + + [Test] + public async Task Assertions_WrongValue_FailsListingEveryArgument() + { + var attribute = AttributeDataFactory.FromUsage("[Event(\"text\", Name = \"N\")]", Options); + + await Assert + .That(async () => await Assert.That(attribute).HasNamedArgument("Name", "Other")) + .Throws() + .WithMessageContaining("Name = \"N\"", StringComparison.Ordinal); + } + + [Test] + public async Task Assertions_NameDiffersByCase_Fails() + { + var attribute = AttributeDataFactory.FromUsage("[Event(\"text\")]", Options); + + await Assert + .That(async () => await Assert.That(attribute).HasConstructorArgument("MessageTemplate", "text")) + .Throws() + .WithMessageContaining("no constructor parameter 'MessageTemplate'", StringComparison.Ordinal); + } + + [Test] + public async Task FromSource_ReadsTypeAndMemberAttributes() + { + const string source = """ + [System.Obsolete("type")] + public interface IEvents + { + [Event("member")] + void Raised(); + } + """; + + var typeAttributes = AttributeDataFactory.FromSource(source, "IEvents", options: Options); + var memberAttributes = AttributeDataFactory.FromSource(source, "IEvents", "Raised", Options); + + await Assert.That(typeAttributes.Single()).HasConstructorArgument(0, "type"); + await Assert.That(memberAttributes.Single()).HasConstructorArgument("messageTemplate", "member"); + } +}