diff --git a/AGENTS.md b/AGENTS.md index e070cf8..f7f9c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,5 @@ Read ./ai-plans/AGENTS.md for details on how to write plans. ## Here is Your Space If you encounter something worth noting while you are working on this code base, write it down here in this section. Once you are finished, I will discuss it with you, and we can decide where to put your notes. + +- `MetadataValueReconstructor` preserves `OperationCanceledException` through its exception filters, but this is not behaviorally testable through the generator's public surface: a pre-cancelled token is intercepted by Roslyn before reconstruction, and none of the whitelisted framework evaluations can throw cancellation. The contract therefore rests on the filters' structure unless an accepted evaluation gains a reachable cancellation path. diff --git a/README.md b/README.md index d3ca7dd..d960059 100644 --- a/README.md +++ b/README.md @@ -1216,7 +1216,9 @@ app.MapPut("/api/movieRatings", AddMovieRating) ); ``` -The generator analyzes top-level `context.Check(...).Rule(...)` chains and produces response schemas and examples when metadata arguments are compile-time constants (e.g. `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`). +The generator analyzes top-level `context.Check(...).Rule(...)` chains and produces response schemas and examples from compile-time constants (for example, `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`). It also reconstructs deterministic `DateTime`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`, `Guid`, and `Uri` boundaries written with supported constructors, factories, well-known static values, or `static readonly` fields declared in the validator's file. Reconstructed message values and example metadata use the same canonical text. + +Runtime-computed or unsupported boundary expressions still generate valid schemas, but the affected example omits its message and metadata and reports `LPRSG0015` at the argument. `DateTimeKind.Local`, culture-sensitive parsing, arithmetic or chained calls, and invalid constructor or factory arguments follow this degraded path. A `static readonly` field declared in another file or assembly is deliberately not followed and reports `LPRSG0016`; write the value inline or move its declaration into the validator's file when the complete example is required. Use `[PortableValidationOpenApiErrorHint]` to annotate codes the generator cannot infer (for example, from `Must(...)`, `Custom(...)`, or child validators): diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md new file mode 100644 index 0000000..8a2808c --- /dev/null +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -0,0 +1,192 @@ +# OpenAPI Error Examples for Non-Constant Validation Boundaries + +> Correction: two claims in Technical Details are wrong, without consequence for the implemented design. The integral `TimeSpan.From*` overloads arrived in .NET 9, not .NET 8 — the reasoning about `TimeSpan.FromHours(2)` binding differently per target framework holds, only the version is off. And `TimeSpan.FromDays(double)` rounds to the nearest millisecond only on .NET Framework; modern .NET rounds to the tick. The conclusion still stands, because the implementation calls the real `double` overloads on the compiler host and only reimplements `FromMicroseconds(double)`, which `netstandard2.0` lacks. + +## Rationale + +The validation source generator learns a rule's boundary value through `SemanticModel.GetConstantValue`, which only succeeds for C# constant expressions. `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, `DateOnly`, `TimeOnly`, and `Uri` cannot be C# constants at all, so a temporal or identifier boundary can never be folded — not by writing it differently and not by hoisting it into a `static readonly` field. The rule then loses both its message and its metadata, and the generated example carries neither. Two checks written identically in the same validator produce usable documentation for the `int` one and an empty example for the `DateTime` one, with no diagnostic to explain the difference. + +Date and time boundaries are among the cases where a client most needs the boundary value in the example, and the degradation is invisible: nothing in the generated code, the diagnostics, or the published document distinguishes a rule that has no message from one whose message could not be reconstructed. This plan reconstructs the value from syntax where the shape allows it, and makes the remaining gaps visible through a diagnostic instead of silence. + +## Acceptance Criteria + +- [x] A boundary written as an object creation whose arguments are themselves constants or recognized shapes — `new DateTime(...)`, `new DateTimeOffset(...)`, `new TimeSpan(...)`, `new DateOnly(...)`, `new TimeOnly(...)`, `new Guid(...)`, `new Uri("...")` — produces an error example carrying both the message and the metadata entry, both derived from the same reconstructed value. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is covered, since no `DateTimeOffset` boundary worth writing has an offset that folds as a constant. +- [x] The accepted shapes are exactly the ones enumerated in the whitelist tables in Technical Details, and the named factories and well-known statics there reconstruct identically to the equivalent object creation. No overload, factory, or static outside those tables is reconstructed, and every entry in the omissions table reaches the diagnostic rather than being silently unsupported. +- [x] A reference to a `static readonly` field **declared in the validator's own file**, whose declaring type has no other source declaration, and whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. +- [x] A field declared in another file or another assembly is not resolved. It reports a distinct warning naming multi-file resolution as unsupported, and the example degrades rather than being reconstructed from a partial view of the field. +- [x] A `DateTime` boundary whose kind is `DateTimeKind.Local` is rejected with the diagnostic instead of reconstructed, while `Utc` and `Unspecified` reconstruct; the generated file for a given source tree is identical regardless of the build machine's time zone. +- [x] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. +- [x] A rule whose metadata cannot be reconstructed reports a new diagnostic identifying the rule and the argument, and a boundary computed at runtime such as `DateTime.UtcNow.AddDays(30)` reports it rather than failing the build. An expression that exceeds the recursion bound, and a `static readonly` field whose initializer chain forms a cycle, both reach that same diagnostic instead of hanging or crashing the compiler. +- [x] An expression that is valid C# but throws when evaluated — `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)` — produces the diagnostic and a degraded example rather than an unhandled exception, and the compilation that contains it still succeeds. `OperationCanceledException` is the only exception that propagates out of reconstruction. +- [x] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. The unsuffixed `TimeSpan.FromHours(2)` is covered as written, so that overload resolution itself is exercised rather than assumed, and the multi-argument component overloads are asserted to be rejected. +- [x] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. +- [x] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. +- [x] `ValidatorOpenApiAnalysis` equality and hashing account for each diagnostic's source path and span, and an incremental test that reuses one generator driver across two compilations — moving an unresolved argument without otherwise changing it — observes the reported location move with it. +- [x] `README.md` no longer states that examples require compile-time constant metadata arguments, and describes what is reconstructed and what degrades. +- [x] Test code coverage stays above 95%, and no existing generated output changes for boundaries that already fold today. + +## Technical Details + +### Where the value is lost + +`ValidatorOpenApiAnalyzer` reads the boundary argument with `semanticModel.GetConstantValue` and leaves `hasConstantValue` false when folding fails. Everything downstream keys off that flag: message assembly abandons the whole message when a placeholder has no replacement, and `ValidatorOpenApiEmitter.EmitExamples` drops the metadata dictionary when `rule.MetadataValues.All(m => m.HasConstantValue)` is false. + +The fix belongs at the single point where the value is read. When constant folding fails, attempt a syntax-directed reconstruction and set `hasConstantValue` when it succeeds. `ValidatorOpenApiEmitter.ToLiteral` already has arms for `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, and `Uri` plus `TryCreateDateOnlyOrTimeOnlyLiteral`; they are currently unreachable through the analyzer and are the intended landing site, and they emit ticks-based literals, so the round trip is exact and culture-independent. + +What reconstruction produces is not a boxed CLR value, for the reasons in the next section, so `MetadataValueModel.Value` and the two rendering paths do change shape. That is the one structural change in this plan. + +### What may be reconstructed + +Recognition is a closed whitelist of shapes, not an expression evaluator, and it is recursive: an argument of a recognized shape is accepted when it folds through `GetConstantValue` **or** is itself a recognized shape. This is not optional detail — every useful `DateTimeOffset` constructor takes a `TimeSpan` offset or a `DateTime`, neither of which can ever be a C# constant, so a non-recursive rule would promise `DateTimeOffset` support and deliver none of it. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is the shape to work from, and `TimeSpan.FromHours(2)`, `TimeSpan.Zero`, and `new TimeSpan(2, 0, 0)` must all be acceptable in that offset position. The repository already writes these values this way — `new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2))` in `TypedMetadataRoutingTests`. + +Recursion is bounded by a depth limit and a cycle guard rather than by the shape of the grammar. A literal nesting such as `DateTimeOffset` over `TimeSpan` is two levels, and each `static readonly` field hop adds one, so a small fixed bound — four is enough for every shape named here — keeps a pathological or adversarial expression from driving the generator into deep recursion. The cycle guard tracks the field symbols already being resolved on the current path, because field initializers can reference other fields and a cycle must end in the diagnostic rather than a stack overflow inside the compiler. Exceeding either bound is treated exactly like an unrecognized shape. + +### Reconstructed values are structural, not boxed CLR objects + +A reconstructed value is carried as an explicit kind plus a deterministic payload, not as `object?` holding a framework instance: + +| Kind | Payload | +| --- | --- | +| `DateTime` | Ticks and `DateTimeKind` | +| `DateTimeOffset` | Ticks and offset ticks | +| `TimeSpan` | Ticks | +| `DateOnly` | Day number | +| `TimeOnly` | Ticks | +| `Guid` | Canonical `D`-format text | +| `Uri` | `OriginalString` | + +The generator targets `netstandard2.0`, where `DateOnly` and `TimeOnly` do not exist, so it cannot construct or even name them. The existing `TryCreateDateOnlyOrTimeOnlyLiteral` copes by reflecting on `type.FullName` and reading `DayNumber` through `GetProperty`, which works only because the analyzer host happens to run a newer runtime than the generator targets. Requiring real CLR values would spread that reflection into the analyzer and make correctness depend on the host's framework version; a payload of `int` and `long` depends on nothing. + +`DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, and `Uri` do exist in `netstandard2.0`, so evaluation still uses them — that is what gives argument validation for free — but the result is reduced to its payload immediately rather than stored. `DateOnly` and `TimeOnly` are evaluated through `DateTime` and `TimeSpan` arithmetic, which also yields their range checking: `new DateOnly(2026, 13, 1)` fails because the equivalent `DateTime` construction throws. + +Two further reasons this representation is the right one: + +- **Rendering is centralized.** One type renders both the C# literal and the canonical message text from the same payload, so the agreement required below holds by construction instead of by two `switch` statements staying in sync. +- **Equality is ordinal and total.** `Uri.Equals` ignores the fragment, so two boundaries differing only after `#` compare equal while emitting different literals; comparing `OriginalString` ordinally matches what is actually emitted. The same applies to the example de-duplication in `EmitExamples`, which currently keys on `metadata.Value?.ToString()` — `Uri.ToString()` is a canonicalized, unescaped form rather than `OriginalString`, so the dedup key and the emitted literal disagree today. + +Object creation is recognized in both its explicit and target-typed forms: `new DateTime(2026, 1, 1)` and `new (2026, 1, 1)` resolve to the same constructor symbol, and symbol-based recognition sees no difference between them. This is not a nicety — `new (2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)` in `TypedMetadataValueTests` is how this repository already writes such values, so a recognizer that keyed on the explicit form would miss the dominant authoring style. `default` and `default(T)` remain omitted; unlike target-typed `new`, they are a third syntax form rather than the same one spelled shorter. + +Recognition matches **symbols**, not names. A user-defined `DateTime` type, or a static `FromHours` on someone's own struct, must not be reconstructed as if it were the framework's; resolve the symbol and compare against the corresponding `INamedTypeSymbol` from the compilation's core library. + +### The accepted set + +The whitelist is exhaustive, not indicative. Reconstruction evaluates on the runtime hosting the compiler rather than the target framework, so the set is restricted to shapes whose semantics do not vary between the two; that is the reason for enumerating overloads instead of naming method families. + +Constructors, with every argument either a folded constant or a nested accepted shape: + +| Type | Accepted constructors | +| --- | --- | +| `DateTime` | `(int, int, int)`, `(int, int, int, int, int, int)`, `(int, int, int, int, int, int, int)`, `(int, int, int, int, int, int, DateTimeKind)`, `(int, int, int, int, int, int, int, DateTimeKind)`, `(long)`, `(long, DateTimeKind)` — there is no year/month/day overload taking a `DateTimeKind` | +| `DateTimeOffset` | `(int, int, int, int, int, int, TimeSpan)`, `(int, int, int, int, int, int, int, TimeSpan)`, `(long, TimeSpan)`, `(DateTime, TimeSpan)` | +| `TimeSpan` | `(int, int, int)`, `(int, int, int, int)`, `(int, int, int, int, int)`, `(long)` | +| `DateOnly` | `(int, int, int)` | +| `TimeOnly` | `(int, int)`, `(int, int, int)`, `(int, int, int, int)`, `(long)` | +| `Guid` | `(string)` | +| `Uri` | `(string)`, `(string, UriKind)` | + +Static factory methods: + +| Type | Accepted factories | +| --- | --- | +| `TimeSpan` | `FromTicks(long)`, and the **single-argument** overloads of `FromDays`, `FromHours`, `FromMinutes`, `FromSeconds`, `FromMilliseconds`, `FromMicroseconds`, in both their `double` and integral forms — `FromDays(int)`, `FromHours(int)`, `FromMinutes(long)`, `FromSeconds(long)`, `FromMilliseconds(long)`, `FromMicroseconds(long)`. The multi-argument component overloads are excluded | +| `DateOnly` | `FromDayNumber(int)` | +| `DateTimeOffset` | `FromUnixTimeSeconds(long)`, `FromUnixTimeMilliseconds(long)` | +| `Guid` | `Parse(string)`, `ParseExact(string, string)` | + +Well-known statics, recognized by symbol rather than syntax: + +| Type | Accepted statics | +| --- | --- | +| `DateTime` | `MinValue`, `MaxValue`, `UnixEpoch` | +| `DateTimeOffset` | `MinValue`, `MaxValue`, `UnixEpoch` | +| `TimeSpan` | `Zero`, `MinValue`, `MaxValue` | +| `DateOnly` | `MinValue`, `MaxValue` | +| `TimeOnly` | `MinValue`, `MaxValue` | +| `Guid` | `Empty` | + +Both the `double` and integral `TimeSpan.From*` families must be accepted, because which one a boundary binds to is not a property of the source text. .NET 8 added integral overloads, so `TimeSpan.FromHours(2)` binds to `FromHours(double)` when the consumer targets `netstandard2.0` or `net6.0` and to `FromHours(int)` on `net8.0` and later, where the `int` literal is an exact match. Accepting only one family would make reconstruction depend on the consumer's target framework — and would reject `TimeSpan.FromHours(2)`, this plan's own worked example, on any current target. + +Recognition must match the full resolved signature, not the method name. The component overloads have optional parameters, so `FromHours(int, long, long, long, long)` is a candidate for a name-based match and must be rejected by comparing the symbol's parameters. + +Evaluating the integral overloads needs care for the reason given above: they do not exist in `netstandard2.0`, so the generator cannot call them. Compute those from the payload arithmetically — ticks as the quantity multiplied by the corresponding `TimeSpan.TicksPer*` constant, in checked arithmetic so that an overflow throws and lands in the failure contract. Do not silently substitute the `double` overload for an integral one: the two agree on ordinary integral values, but `FromDays(double)` rounds to the nearest millisecond and has a different range, so at the extremes they are not the same function. + +The offset argument of a `DateTimeOffset` accepts any accepted `TimeSpan` shape: `TimeSpan.Zero`, the `From*` factories, `new TimeSpan(...)`, `MinValue`/`MaxValue`, or a `static readonly` `TimeSpan` field declared in source. `MinValue` and `MaxValue` are accepted as shapes and then throw during evaluation, which the failure contract below turns into the diagnostic — the recognizer does not need to know which offsets are legal. + +Everything else is omitted, and an omission is a diagnostic rather than a silent gap: + +| Omitted | Reason | +| --- | --- | +| `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()` | No compile-time value by definition | +| `DateTime.Parse`/`ParseExact`/`TryParse` and the `DateTimeOffset` equivalents | Culture-sensitive; see Excluded shapes | +| `new DateTimeOffset(DateTime)` | Resolves an `Unspecified` or `Local` input against the local zone, so it is nondeterministic for the same reason `DateTimeKind.Local` is | +| Constructors taking a `Calendar` | Culture-dependent interpretation of the same components | +| `new Guid(byte[])` and any array or collection argument | Requires evaluating an array creation, which is not an accepted shape | +| `default` and `default(T)` | Keeps the recognizer to two syntax forms, object creation and member access or invocation | +| Locals, parameters, properties, and instance or non-`readonly` fields | Value depends on flow the generator does not track | +| `static readonly` fields declared outside the validator's syntax tree, including referenced assemblies | Multi-file resolution is deliberately unsupported in this iteration and reports the warning | +| Arithmetic and chaining such as `X.AddDays(1)` or `a + b` | Would require a general expression evaluator rather than a whitelist | +| The multi-argument `TimeSpan.From*` component overloads, such as `FromHours(int, long, long, long, long)` | A boundary is written as one quantity; these exist mainly to spell out components, and their optional parameters make them easy to match by accident | +| `DateOnly.FromDateTime`, `TimeOnly.FromDateTime`/`FromTimeSpan` | Not needed for a boundary; add on demand rather than by default | +| Any reconstructed `DateTime` whose `Kind` is `Local` | Nondeterministic; see Excluded shapes | + +### Reconstruction must not be able to break a build + +Recognizing a shape is not the same as being able to evaluate it. Reconstruction ultimately runs real constructors and factory methods, so it inherits their argument validation, and every one of these compiles cleanly while throwing when evaluated: `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)`. An unhandled exception here does not degrade one example — it fails the generator and breaks compilation of code that was previously building, which is a far worse outcome than the silent degradation this plan exists to fix. + +The contract is therefore that evaluation cannot fail the generator: any exception other than `OperationCanceledException` is caught at the evaluation step and turned into "not reconstructable", which routes into the same diagnostic and the same degraded output as an unrecognized shape. Cancellation must keep propagating, because Roslyn relies on it to abandon superseded generator runs and swallowing it converts a responsive IDE into a hanging one. + +Scope the handler to the evaluation of a single recognized shape rather than wrapping the analyzer or the generator entry point. A blanket catch would also swallow genuine defects in this code and turn them into quietly missing documentation, which is the failure mode that is hardest to notice. + +An expression in this category is usually also a defect in the validator itself, since the same call throws at runtime. Diagnosing that is out of scope: the generator reports only that it could not reconstruct the value, and does not attempt to tell the author their boundary is invalid. + +### Excluded shapes + +`DateTimeKind.Local` is rejected and takes the diagnostic path. `MetadataValue.FromDateTime` converts a local value with `ToUniversalTime()`, which resolves against the time zone of whichever process performs the conversion. The metadata entry is converted at runtime, in the deployment time zone, by the document transformer; the message text is baked into the generated file at build time, in the build machine's time zone. Two harms follow, and either alone is disqualifying: the generated source stops being a function of the source tree, so two machines building the same commit emit different files, and whenever the build and deployment zones differ the message and the `comparativeValue` in the same example state different instants — the precise disagreement the criteria forbid. + +The rejection tests the reconstructed value's `Kind`, not the syntax: a field reference or a nested shape can yield a local value without the enum member appearing at the call site at all. + +`Utc` and `Unspecified` are both deterministic — `FromDateTime` stores them unconverted — and `Unspecified` is what every `new DateTime(...)` literal produces, so rejecting `Local` costs nothing on the common authoring path. That `Unspecified` is published without a zone remains an accepted limitation of the OpenAPI output rather than something this work changes; reconstruction only makes existing behaviour reachable for these types, and no reasoning here depends on resolving it. `DateTimeOffset` is unaffected, since it carries its offset explicitly. + +Culture-sensitive parsing is excluded: `DateTime.Parse`, `DateTimeOffset.Parse`, and their `TryParse` siblings are **not** supported even with a constant string, because the generator would have to pick a culture and any choice can disagree with what the developer meant. `Guid.Parse`/`ParseExact` are the exception — the `Guid` formats are culture-invariant. A rejected shape is a diagnostic, not a silent degradation, which keeps the unsupported set discoverable. + +For a `static readonly` field reference, resolve the symbol to its declaring syntax and apply the same recognition to the initializer. Only the well-known statics in the tables above are recognized by symbol rather than by syntax. + +**Field resolution is confined to the validator's own syntax tree.** A field declared in another file, in another partial declaration of the same type, or in a referenced assembly is not resolved at all; it reports the multi-file warning and degrades. This is a deliberate limit on the first iteration rather than a technical obstacle — resolving across trees needs the `Compilation` and a `GetSemanticModel` call per tree, which is mechanical — and it can be lifted later without changing anything else in this design. + +The restriction also buys correctness that is otherwise expensive. `static readonly` does not mean "initialized once at the declaration": a static constructor may assign the field again, and that assignment wins, because the initializer runs first and the constructor body overwrites it. Reconstructing from the initializer would then document a value the application never uses, so reconstruction is valid only when the initializer is the field's *sole* assignment. Within one syntax tree that is decidable — examine the static constructors declared there and reject the field if any assigns it. Across files it is not, because a `partial` type can hide a static constructor in a file the analyzer never looked at, and a rule that resolves cross-file fields while checking only one file's assignments would be confidently wrong. A field with no initializer degrades regardless, since there is no syntax to recurse into. + +Consequently, if the declaring type has any declaring syntax reference outside the validator's tree, the field is not resolved even when the initializer sits in the validator's own file — the assignments cannot all be seen from here. + +### Message and metadata must agree + +This is a real defect today rather than a hypothetical: `FormatMessageValue` renders through `IFormattable.ToString(null, CultureInfo.InvariantCulture)`, which for a `DateTime` yields `01/01/2026 00:00:00`, while the runtime metadata pipeline renders through `MetadataValue.ToCanonicalString()`, which is round-trip ISO-8601. Reconstruction makes both paths reachable for the same value at once, so the disagreement would ship in a single example — the message saying one thing and `comparativeValue` another. + +`MetadataValue.ToCanonicalString()` is the canonical form and the message must follow it, not the reverse: it is what the published document and the wire format already use. The generator cannot call it directly — `MetadataValue` lives in the runtime library and the generator targets `netstandard2.0` with no reference to it — so the reconstructed value's own canonical rendering has to reproduce it, and `FormatMessageValue` delegates to that instead of growing a parallel set of arms. Delegation is what makes the guarantee structural: the message and the metadata literal are then two renderings of one payload rather than two switch statements that must be kept in agreement. The criteria still require a test comparing the output against `MetadataValue.ToCanonicalString()` rather than against a literal string, because the two libraries can only be pinned to each other by execution. + +### All-or-nothing metadata is deliberate + +The issue asks whether one unreconstructable entry should still drop the entries that folded. It should. `CreateRangeSchema` marks both `lowerBoundary` and `upperBoundary` as required, and `CreateComparisonSchema` marks `comparativeValue` as required, so every metadata key of every built-in rule is required by its own schema. Emitting the foldable subset would publish an example that violates the schema published alongside it, which is worse than publishing no metadata. The `All(...)` check stays as written; the new diagnostic is what removes the silence that made it look accidental. + +### Diagnostic + +Two descriptors are added to `DiagnosticDescriptors`, continuing the `LPRSG####` sequence. + +Cardinality is **one report per distinct unresolved argument**, located at that argument. Reporting once per rule cannot be reconciled with pointing at the argument: `IsInRange` with two unreconstructable boundaries has two offending locations, and collapsing them either invents a location or hides the second problem. Per-argument reporting also means fixing one boundary visibly removes one diagnostic, which is the feedback an author needs while working through a rule. + +The first covers metadata that could not be reconstructed, at `Info` severity: the generated code is valid and the validation itself is unaffected, and a runtime-computed boundary such as `DateTime.UtcNow.AddDays(30)` is a legitimate authoring choice that should stay quiet enough to live with. + +The second covers a field reference the analyzer declines to follow out of the validator's syntax tree, at `Warning` severity. It is louder because it reports a tool limitation rather than an authoring decision: the shape is one the generator could support, the author has no way to tell from the output that the value was dropped for this reason instead of any other, and the fix is mechanical — move the constant into the validator's file or write it inline. It names multi-file resolution explicitly so the message is actionable rather than a generic failure to fold. + +One consequence to accept knowingly: a consumer building with `TreatWarningsAsErrors` fails on this where they would previously have got silently degraded documentation. That is the intended trade for visibility, and the escape hatch is the ordinary one — suppress `LPRSG####` — but it is a behaviour change for that audience rather than a purely additive diagnostic. + +### Incremental generation + +The incremental value is `ValidatorOpenApiAnalysis`, whose `Equals` compares `HintName`, the emitted `Source` string, and the diagnostics. Reconstructed values are therefore never compared directly by the pipeline: they reach the cache only through the text they render into. This makes the structural representation above a rendering concern rather than a cache-correctness one, and it means an equality mistake in a value type cannot silently poison incremental results — but it also means any nondeterminism in rendering shows up as spurious cache misses and churn in generated files, which is a further reason the payloads are fixed integers and ordinal text. + +Confining field resolution to the validator's own syntax tree keeps invalidation where it already is. The analyzer is handed the `Compilation` regardless, so the point is not that a compilation dependency is avoided — it is that a validator's generated output continues to depend only on its own syntax tree, and no edit to an unrelated file can change it. Had cross-file fields been resolved, an example would depend on a tree nothing tracks, and the cached result would survive an edit to the constant it quotes — stale documentation that looks correct, which is a worse failure than the degradation this plan fixes. Whoever lifts the restriction later has to make that dependency explicit rather than rely on the analyzer happening to hold a `Compilation`. + +Diagnostic locations need the same care, and here the current code is already insufficient for what this plan promises. `DiagnosticsEqual` compares only `Id`, `Severity`, and `GetMessage()`, so two analyses whose diagnostics differ only in position compare equal. Since the new diagnostics are located at a specific argument, moving an unresolved argument — adding a line above it, reordering rules — produces an identical comparison and the driver keeps the cached analysis, leaving the squiggle on the old position. `Equals` and `GetHashCode` must therefore incorporate each diagnostic's source path and span. The criteria require an incremental test that reuses one driver across two compilations to catch this, because a single-shot generator test cannot: it never consults the cache and so passes either way. + +### Scope + +The twelve mutation survivors in `BuiltInValidationErrorBuilderExtensions` are **not** part of this work, despite an earlier triage note that grouped them here. They cover the `target`-provided branch of the hand-written typed helpers, which the generator never calls — it emits `builder.WithGreaterThanError()` without a target and writes examples through separate `WithErrorExample` calls. Those survivors are an independent test-coverage gap in the runtime helper API and need their own issue. diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs new file mode 100644 index 0000000..e8f6556 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Text; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Renders C# string literals and escaped characters with deterministic escaping for generated source. +/// +internal static class CSharpLiterals +{ + public static string ToStringLiteral(string value) + { + var builder = new StringBuilder(value.Length + 2).Append('"'); + foreach (var c in value) + { + builder.Append(EscapeChar(c)); + } + + return builder.Append('"').ToString(); + } + + public static string EscapeChar(char value) => + value switch + { + '\\' => @"\\", + '"' => "\\\"", + '\'' => "\\'", + '\0' => "\\0", + '\a' => "\\a", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\v' => "\\v", + _ => char.IsControl(value) ? + "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : + value.ToString() + }; +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs index 6a3f9b5..d522a6a 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -148,4 +148,24 @@ public static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: HelpLinkBase ); + + public static readonly DiagnosticDescriptor MetadataValueCannotBeReconstructed = new ( + "LPRSG0015", + "Validation metadata value cannot be reconstructed", + "Validation rule '{0}' metadata argument '{1}' cannot be reconstructed for an OpenAPI error example", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor MultiFileMetadataFieldUnsupported = new ( + "LPRSG0016", + "Multi-file validation metadata field resolution is unsupported", + "Validation rule '{0}' metadata argument '{1}' references a static readonly field that requires unsupported multi-file resolution; declare it in the validator's file or write it inline", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs new file mode 100644 index 0000000..98db897 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -0,0 +1,1250 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Describes the outcome of reconstructing a validation metadata value. +/// +public enum MetadataReconstructionResult +{ + Success = 0, + Unsupported = 1, + MultiFileFieldUnsupported = 2 +} + +/// +/// Reconstructs supported validation metadata expressions into deterministic values. +/// +public sealed class MetadataValueReconstructor +{ + private const int MaximumDepth = 4; + private const long TicksPerMicrosecond = 10L; + private readonly FrameworkTypes _frameworkTypes; + + /// + /// Creates a reconstructor for expressions belonging to the specified compilation. + /// + public MetadataValueReconstructor(Compilation compilation) + { + _frameworkTypes = new FrameworkTypes(compilation); + } + + /// + /// Attempts to reconstruct the specified expression. + /// + public MetadataReconstructionResult TryReconstruct( + SemanticModel semanticModel, + ExpressionSyntax expression, + CancellationToken cancellationToken, + out object? value + ) + { + var resolvingFields = new HashSet(SymbolEqualityComparer.Default); + return TryReconstruct( + semanticModel, + expression, + _frameworkTypes, + depth: 0, + resolvingFields, + cancellationToken, + out value + ); + } + + private static MetadataReconstructionResult TryReconstruct( + SemanticModel semanticModel, + ExpressionSyntax expression, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object? value + ) + { + cancellationToken.ThrowIfCancellationRequested(); + value = null; + if (depth > MaximumDepth) + { + return MetadataReconstructionResult.Unsupported; + } + + var constant = semanticModel.GetConstantValue(expression, cancellationToken); + if (constant.HasValue) + { + value = constant.Value; + return MetadataReconstructionResult.Success; + } + + var operation = semanticModel.GetOperation(expression, cancellationToken); + switch (operation) + { + case IObjectCreationOperation objectCreation: + return TryReconstructObjectCreation( + semanticModel, + objectCreation, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IInvocationOperation invocation: + return TryReconstructInvocation( + semanticModel, + invocation, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IFieldReferenceOperation fieldReference: + return TryReconstructField( + semanticModel, + fieldReference.Field, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IPropertyReferenceOperation propertyReference + when TryGetWellKnownStatic(propertyReference.Property, frameworkTypes, out value): + return MetadataReconstructionResult.Success; + default: + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructObjectCreation( + SemanticModel semanticModel, + IObjectCreationOperation operation, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object? value + ) + { + value = null; + var constructor = operation.Constructor; + if (constructor is null || + !TryGetSupportedConstructor(constructor, frameworkTypes, out var supportedConstructor)) + { + return MetadataReconstructionResult.Unsupported; + } + + var argumentResult = TryReconstructArguments( + semanticModel, + constructor, + operation.Arguments, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out var arguments + ); + if (argumentResult != MetadataReconstructionResult.Success) + { + return argumentResult; + } + + try + { + value = EvaluateConstructor(supportedConstructor, arguments); + return MetadataReconstructionResult.Success; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + value = null; + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructInvocation( + SemanticModel semanticModel, + IInvocationOperation operation, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object? value + ) + { + value = null; + var method = operation.TargetMethod; + if (!TryGetSupportedFactory(method, frameworkTypes, out var supportedFactory)) + { + return MetadataReconstructionResult.Unsupported; + } + + var argumentResult = TryReconstructArguments( + semanticModel, + method, + operation.Arguments, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out var arguments + ); + if (argumentResult != MetadataReconstructionResult.Success) + { + return argumentResult; + } + + try + { + value = EvaluateFactory(supportedFactory, arguments); + return MetadataReconstructionResult.Success; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + value = null; + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructArguments( + SemanticModel semanticModel, + IMethodSymbol method, + ImmutableArray argumentOperations, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object?[] arguments + ) + { + arguments = new object?[method.Parameters.Length]; + var assigned = new bool[method.Parameters.Length]; + foreach (var argumentOperation in argumentOperations) + { + cancellationToken.ThrowIfCancellationRequested(); + var parameter = argumentOperation.Parameter; + if (parameter is null || argumentOperation.IsImplicit) + { + return MetadataReconstructionResult.Unsupported; + } + + if (argumentOperation.Value.Syntax is not ExpressionSyntax argumentExpression) + { + return MetadataReconstructionResult.Unsupported; + } + + var constant = semanticModel.GetConstantValue(argumentExpression, cancellationToken); + if (constant.HasValue) + { + arguments[parameter.Ordinal] = constant.Value; + assigned[parameter.Ordinal] = true; + continue; + } + + var reconstructionResult = TryReconstruct( + semanticModel, + argumentExpression, + frameworkTypes, + depth + 1, + resolvingFields, + cancellationToken, + out var reconstructedValue + ); + if (reconstructionResult != MetadataReconstructionResult.Success) + { + return reconstructionResult; + } + + arguments[parameter.Ordinal] = reconstructedValue; + assigned[parameter.Ordinal] = true; + } + + return assigned.All(static isAssigned => isAssigned) ? + MetadataReconstructionResult.Success : + MetadataReconstructionResult.Unsupported; + } + + private static MetadataReconstructionResult TryReconstructField( + SemanticModel semanticModel, + IFieldSymbol field, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object? value + ) + { + value = null; + if (TryGetWellKnownStatic(field, frameworkTypes, out value)) + { + return MetadataReconstructionResult.Success; + } + + if (!field.IsStatic || !field.IsReadOnly) + { + return MetadataReconstructionResult.Unsupported; + } + + if (field.DeclaringSyntaxReferences.Length != 1 || + field.ContainingType.DeclaringSyntaxReferences.Length != 1) + { + return MetadataReconstructionResult.MultiFileFieldUnsupported; + } + + var fieldSyntaxReference = field.DeclaringSyntaxReferences[0]; + var typeSyntaxReference = field.ContainingType.DeclaringSyntaxReferences[0]; + if (fieldSyntaxReference.SyntaxTree != semanticModel.SyntaxTree || + typeSyntaxReference.SyntaxTree != semanticModel.SyntaxTree) + { + return MetadataReconstructionResult.MultiFileFieldUnsupported; + } + + if (!resolvingFields.Add(field)) + { + return MetadataReconstructionResult.Unsupported; + } + + try + { + if (fieldSyntaxReference.GetSyntax(cancellationToken) is not VariableDeclaratorSyntax + { + Initializer.Value: ExpressionSyntax initializer + } || + IsAssignedInStaticConstructor( + semanticModel, + field, + typeSyntaxReference, + cancellationToken + )) + { + return MetadataReconstructionResult.Unsupported; + } + + return TryReconstruct( + semanticModel, + initializer, + frameworkTypes, + depth + 1, + resolvingFields, + cancellationToken, + out value + ); + } + finally + { + resolvingFields.Remove(field); + } + } + + private static bool IsAssignedInStaticConstructor( + SemanticModel semanticModel, + IFieldSymbol field, + SyntaxReference typeSyntaxReference, + CancellationToken cancellationToken + ) + { + if (typeSyntaxReference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax typeDeclaration) + { + return true; + } + + foreach (var constructor in typeDeclaration.Members + .OfType() + .Where(static constructor => constructor.Modifiers.Any(SyntaxKind.StaticKeyword))) + { + foreach (var assignment in constructor.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + var assignedSymbol = semanticModel.GetSymbolInfo(assignment.Left, cancellationToken).Symbol; + if (SymbolEqualityComparer.Default.Equals(assignedSymbol, field)) + { + return true; + } + } + } + + return false; + } + + private static bool TryGetWellKnownStatic( + ISymbol field, + FrameworkTypes frameworkTypes, + out object? value + ) + { + value = null; + if (!field.IsStatic) + { + return false; + } + + if (IsType(field.ContainingType, frameworkTypes.DateTime)) + { + var dateTime = field.Name switch + { + "MinValue" => DateTime.MinValue, + "MaxValue" => DateTime.MaxValue, + "UnixEpoch" => new DateTime(621_355_968_000_000_000L, DateTimeKind.Utc), + _ => (DateTime?) null + }; + if (dateTime.HasValue) + { + value = ReconstructedMetadataValue.FromDateTime(dateTime.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.DateTimeOffset)) + { + var dateTimeOffset = field.Name switch + { + "MinValue" => DateTimeOffset.MinValue, + "MaxValue" => DateTimeOffset.MaxValue, + "UnixEpoch" => new DateTimeOffset(621_355_968_000_000_000L, TimeSpan.Zero), + _ => (DateTimeOffset?) null + }; + if (dateTimeOffset.HasValue) + { + value = ReconstructedMetadataValue.FromDateTimeOffset(dateTimeOffset.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.TimeSpan)) + { + var timeSpan = field.Name switch + { + "Zero" => TimeSpan.Zero, + "MinValue" => TimeSpan.MinValue, + "MaxValue" => TimeSpan.MaxValue, + _ => (TimeSpan?) null + }; + if (timeSpan.HasValue) + { + value = ReconstructedMetadataValue.FromTimeSpan(timeSpan.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.DateOnly)) + { + if (field.Name == "MinValue") + { + value = ReconstructedMetadataValue.FromDateOnlyDayNumber(0); + return true; + } + + if (field.Name == "MaxValue") + { + value = ReconstructedMetadataValue.FromDateOnlyDayNumber(3_652_058); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.TimeOnly)) + { + if (field.Name == "MinValue") + { + value = ReconstructedMetadataValue.FromTimeOnlyTicks(0L); + return true; + } + + if (field.Name == "MaxValue") + { + value = ReconstructedMetadataValue.FromTimeOnlyTicks(TimeSpan.TicksPerDay - 1L); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.Guid) && field.Name == "Empty") + { + value = ReconstructedMetadataValue.FromGuid(Guid.Empty); + return true; + } + + return false; + } + + private static bool TryGetSupportedConstructor( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedConstructor constructor + ) + { + constructor = default; + if (Matches(method, frameworkTypes.DateTime, ParameterType.Int32, ParameterType.Int32, ParameterType.Int32)) + { + constructor = SupportedConstructor.DateTimeDate; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateTimeSecond; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateTimeMillisecond; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.DateTimeKind + )) + { + constructor = SupportedConstructor.DateTimeSecondKind; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.DateTimeKind + )) + { + constructor = SupportedConstructor.DateTimeMillisecondKind; + } + else if (Matches(method, frameworkTypes.DateTime, ParameterType.Int64)) + { + constructor = SupportedConstructor.DateTimeTicks; + } + else if (Matches(method, frameworkTypes.DateTime, ParameterType.Int64, ParameterType.DateTimeKind)) + { + constructor = SupportedConstructor.DateTimeTicksKind; + } + else if (Matches( + method, + frameworkTypes.DateTimeOffset, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.TimeSpan + )) + { + constructor = SupportedConstructor.DateTimeOffsetSecond; + } + else if (Matches( + method, + frameworkTypes.DateTimeOffset, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.TimeSpan + )) + { + constructor = SupportedConstructor.DateTimeOffsetMillisecond; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, ParameterType.Int64, ParameterType.TimeSpan)) + { + constructor = SupportedConstructor.DateTimeOffsetTicks; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, ParameterType.DateTime, ParameterType.TimeSpan)) + { + constructor = SupportedConstructor.DateTimeOffsetDateTime; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanThreeArguments; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanFourArguments; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanFiveArguments; + } + else if (Matches(method, frameworkTypes.TimeSpan, ParameterType.Int64)) + { + constructor = SupportedConstructor.TimeSpanTicks; + } + else if (Matches( + method, + frameworkTypes.DateOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateOnly; + } + else if (Matches(method, frameworkTypes.TimeOnly, ParameterType.Int32, ParameterType.Int32)) + { + constructor = SupportedConstructor.TimeOnlyMinute; + } + else if (Matches( + method, + frameworkTypes.TimeOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeOnlySecond; + } + else if (Matches( + method, + frameworkTypes.TimeOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeOnlyMillisecond; + } + else if (Matches(method, frameworkTypes.TimeOnly, ParameterType.Int64)) + { + constructor = SupportedConstructor.TimeOnlyTicks; + } + else if (Matches(method, frameworkTypes.Guid, ParameterType.String)) + { + constructor = SupportedConstructor.Guid; + } + else if (Matches(method, frameworkTypes.Uri, ParameterType.String)) + { + constructor = SupportedConstructor.Uri; + } + else if (Matches(method, frameworkTypes.Uri, ParameterType.String, ParameterType.UriKind)) + { + constructor = SupportedConstructor.UriKind; + } + else + { + return false; + } + + return true; + } + + private static bool TryGetSupportedFactory( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedFactory factory + ) + { + factory = default; + if (Matches(method, frameworkTypes.TimeSpan, "FromTicks", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromTicks; + } + else if (TryGetTimeSpanQuantityFactory(method, frameworkTypes, out factory)) + { + return true; + } + else if (Matches(method, frameworkTypes.DateOnly, "FromDayNumber", ParameterType.Int32)) + { + factory = SupportedFactory.DateOnlyFromDayNumber; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, "FromUnixTimeSeconds", ParameterType.Int64)) + { + factory = SupportedFactory.DateTimeOffsetFromUnixTimeSeconds; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, "FromUnixTimeMilliseconds", ParameterType.Int64)) + { + factory = SupportedFactory.DateTimeOffsetFromUnixTimeMilliseconds; + } + else if (Matches(method, frameworkTypes.Guid, "Parse", ParameterType.String)) + { + factory = SupportedFactory.GuidParse; + } + else if (Matches(method, frameworkTypes.Guid, "ParseExact", ParameterType.String, ParameterType.String)) + { + factory = SupportedFactory.GuidParseExact; + } + else + { + return false; + } + + return true; + } + + private static bool TryGetTimeSpanQuantityFactory( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedFactory factory + ) + { + factory = default; + if (Matches(method, frameworkTypes.TimeSpan, "FromDays", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromDaysDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromDays", ParameterType.Int32)) + { + factory = SupportedFactory.TimeSpanFromDaysIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromHours", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromHoursDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromHours", ParameterType.Int32)) + { + factory = SupportedFactory.TimeSpanFromHoursIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMinutes", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMinutesDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMinutes", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMinutesIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromSeconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromSecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromSeconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromSecondsIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMilliseconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMillisecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMilliseconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMillisecondsIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMicroseconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMicrosecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMicroseconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMicrosecondsIntegral; + } + else + { + return false; + } + + return true; + } + + private static ReconstructedMetadataValue EvaluateConstructor( + SupportedConstructor constructor, + IReadOnlyList arguments + ) => + constructor switch + { + SupportedConstructor.DateTimeDate => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt32(arguments[0]), ToInt32(arguments[1]), ToInt32(arguments[2])) + ), + SupportedConstructor.DateTimeSecond => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]) + ) + ), + SupportedConstructor.DateTimeMillisecond => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]) + ) + ), + SupportedConstructor.DateTimeSecondKind => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToDateTimeKind(arguments[6]) + ) + ), + SupportedConstructor.DateTimeMillisecondKind => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]), + ToDateTimeKind(arguments[7]) + ) + ), + SupportedConstructor.DateTimeTicks => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt64(arguments[0])) + ), + SupportedConstructor.DateTimeTicksKind => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt64(arguments[0]), ToDateTimeKind(arguments[1])) + ), + SupportedConstructor.DateTimeOffsetSecond => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToTimeSpan(arguments[6]) + ) + ), + SupportedConstructor.DateTimeOffsetMillisecond => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]), + ToTimeSpan(arguments[7]) + ) + ), + SupportedConstructor.DateTimeOffsetTicks => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset(ToInt64(arguments[0]), ToTimeSpan(arguments[1])) + ), + SupportedConstructor.DateTimeOffsetDateTime => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset(ToDateTime(arguments[0]), ToTimeSpan(arguments[1])) + ), + SupportedConstructor.TimeSpanThreeArguments => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan(ToInt32(arguments[0]), ToInt32(arguments[1]), ToInt32(arguments[2])) + ), + SupportedConstructor.TimeSpanFourArguments => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]) + ) + ), + SupportedConstructor.TimeSpanFiveArguments => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]) + ) + ), + SupportedConstructor.TimeSpanTicks => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan(ToInt64(arguments[0])) + ), + SupportedConstructor.DateOnly => ReconstructedMetadataValue.FromDateOnlyDayNumber( + checked( + (int) (new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]) + ).Ticks / + TimeSpan.TicksPerDay) + ) + ), + SupportedConstructor.TimeOnlyMinute => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + second: 0, + millisecond: 0 + ), + SupportedConstructor.TimeOnlySecond => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + millisecond: 0 + ), + SupportedConstructor.TimeOnlyMillisecond => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]) + ), + SupportedConstructor.TimeOnlyTicks => CreateTimeOnly(ToInt64(arguments[0])), + SupportedConstructor.Guid => ReconstructedMetadataValue.FromGuid(new Guid((string) arguments[0]!)), + SupportedConstructor.Uri => CreateUri(new Uri((string) arguments[0]!, UriKind.Absolute)), + SupportedConstructor.UriKind => CreateUri( + new Uri((string) arguments[0]!, ToUriKind(arguments[1])) + ), + _ => throw new InvalidOperationException($"Unsupported constructor '{constructor}'.") + }; + + private static ReconstructedMetadataValue EvaluateFactory( + SupportedFactory factory, + IReadOnlyList arguments + ) => + factory switch + { + SupportedFactory.TimeSpanFromTicks => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromTicks(ToInt64(arguments[0])) + ), + SupportedFactory.TimeSpanFromDaysDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromDays(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromDaysIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerDay + ), + SupportedFactory.TimeSpanFromHoursDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromHours(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromHoursIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerHour + ), + SupportedFactory.TimeSpanFromMinutesDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromMinutes(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromMinutesIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerMinute + ), + SupportedFactory.TimeSpanFromSecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromSeconds(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromSecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerSecond + ), + SupportedFactory.TimeSpanFromMillisecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromMilliseconds(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromMillisecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerMillisecond + ), + SupportedFactory.TimeSpanFromMicrosecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + CreateDoubleTimeSpan(ToDouble(arguments[0]), TicksPerMicrosecond) + ), + SupportedFactory.TimeSpanFromMicrosecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TicksPerMicrosecond + ), + SupportedFactory.DateOnlyFromDayNumber => CreateDateOnlyFromDayNumber(ToInt32(arguments[0])), + SupportedFactory.DateTimeOffsetFromUnixTimeSeconds => + ReconstructedMetadataValue.FromDateTimeOffset( + DateTimeOffset.FromUnixTimeSeconds(ToInt64(arguments[0])) + ), + SupportedFactory.DateTimeOffsetFromUnixTimeMilliseconds => + ReconstructedMetadataValue.FromDateTimeOffset( + DateTimeOffset.FromUnixTimeMilliseconds(ToInt64(arguments[0])) + ), + SupportedFactory.GuidParse => ReconstructedMetadataValue.FromGuid(Guid.Parse((string) arguments[0]!)), + SupportedFactory.GuidParseExact => ReconstructedMetadataValue.FromGuid( + Guid.ParseExact((string) arguments[0]!, (string) arguments[1]!) + ), + _ => throw new InvalidOperationException($"Unsupported factory '{factory}'.") + }; + + private static ReconstructedMetadataValue CreateIntegralTimeSpan(long quantity, long ticksPerUnit) => + ReconstructedMetadataValue.FromTimeSpan(TimeSpan.FromTicks(checked(quantity * ticksPerUnit))); + + private static ReconstructedMetadataValue CreateDateOnlyFromDayNumber(int dayNumber) + { + if (dayNumber < 0 || dayNumber > 3_652_058) + { + throw new ArgumentOutOfRangeException(nameof(dayNumber)); + } + + return ReconstructedMetadataValue.FromDateOnlyDayNumber(dayNumber); + } + + private static ReconstructedMetadataValue CreateTimeOnly( + int hour, + int minute, + int second, + int millisecond + ) + { + var value = new DateTime(1, 1, 1, hour, minute, second, millisecond, DateTimeKind.Unspecified); + return ReconstructedMetadataValue.FromTimeOnlyTicks(value.Ticks); + } + + private static ReconstructedMetadataValue CreateTimeOnly(long ticks) + { + if (ticks < 0 || ticks >= TimeSpan.TicksPerDay) + { + throw new ArgumentOutOfRangeException(nameof(ticks)); + } + + return ReconstructedMetadataValue.FromTimeOnlyTicks(ticks); + } + + private static ReconstructedMetadataValue CreateUri(Uri value) => + ReconstructedMetadataValue.FromUriOriginalString(value.OriginalString); + + private static TimeSpan CreateDoubleTimeSpan(double value, double ticksPerUnit) + { + if (double.IsNaN(value)) + { + throw new ArgumentException("TimeSpan quantity cannot be NaN.", nameof(value)); + } + + var ticks = value * ticksPerUnit; + if (ticks > TimeSpan.MaxValue.Ticks || ticks < TimeSpan.MinValue.Ticks || double.IsNaN(ticks)) + { + throw new OverflowException("The TimeSpan quantity is outside the supported range."); + } + + return ticks == TimeSpan.MaxValue.Ticks ? TimeSpan.MaxValue : TimeSpan.FromTicks((long) ticks); + } + + private static DateTime ToDateTime(object? value) + { + var reconstructedValue = GetReconstructedValue(value, ReconstructedMetadataValueKind.DateTime); + return new DateTime( + reconstructedValue.PrimaryValue, + (DateTimeKind) reconstructedValue.SecondaryValue + ); + } + + private static TimeSpan ToTimeSpan(object? value) + { + var reconstructedValue = GetReconstructedValue(value, ReconstructedMetadataValueKind.TimeSpan); + return TimeSpan.FromTicks(reconstructedValue.PrimaryValue); + } + + private static ReconstructedMetadataValue GetReconstructedValue( + object? value, + ReconstructedMetadataValueKind expectedKind + ) + { + if (value is ReconstructedMetadataValue reconstructedValue && reconstructedValue.Kind == expectedKind) + { + return reconstructedValue; + } + + throw new InvalidOperationException($"Expected a reconstructed {expectedKind} value."); + } + + private static int ToInt32(object? value) => Convert.ToInt32(value, CultureInfo.InvariantCulture); + + private static long ToInt64(object? value) => Convert.ToInt64(value, CultureInfo.InvariantCulture); + + private static double ToDouble(object? value) => Convert.ToDouble(value, CultureInfo.InvariantCulture); + + private static DateTimeKind ToDateTimeKind(object? value) => (DateTimeKind) ToInt32(value); + + private static UriKind ToUriKind(object? value) => (UriKind) ToInt32(value); + + private static bool Matches( + IMethodSymbol method, + INamedTypeSymbol? containingType, + params ParameterType[] parameterTypes + ) => + method.MethodKind == MethodKind.Constructor && + Matches(method, containingType, method.Name, parameterTypes); + + private static bool Matches( + IMethodSymbol method, + INamedTypeSymbol? containingType, + string name, + params ParameterType[] parameterTypes + ) + { + if (containingType is null || + (!method.IsStatic && method.MethodKind != MethodKind.Constructor) || + method.Name != name || + !IsType(method.ContainingType, containingType) || + method.Parameters.Length != parameterTypes.Length) + { + return false; + } + + for (var i = 0; i < parameterTypes.Length; i++) + { + if (!MatchesParameter(method.Parameters[i].Type, parameterTypes[i], containingType.ContainingAssembly)) + { + return false; + } + } + + return true; + } + + private static bool MatchesParameter( + ITypeSymbol parameterType, + ParameterType expectedType, + IAssemblySymbol coreAssembly + ) => + expectedType switch + { + ParameterType.Int32 => parameterType.SpecialType == SpecialType.System_Int32, + ParameterType.Int64 => parameterType.SpecialType == SpecialType.System_Int64, + ParameterType.Double => parameterType.SpecialType == SpecialType.System_Double, + ParameterType.String => parameterType.SpecialType == SpecialType.System_String, + ParameterType.DateTime => IsFrameworkType(parameterType, coreAssembly, "System.DateTime"), + ParameterType.DateTimeKind => IsFrameworkType(parameterType, coreAssembly, "System.DateTimeKind"), + ParameterType.TimeSpan => IsFrameworkType(parameterType, coreAssembly, "System.TimeSpan"), + ParameterType.UriKind => IsFrameworkType(parameterType, coreAssembly, "System.UriKind"), + _ => false + }; + + private static bool IsFrameworkType( + ITypeSymbol type, + IAssemblySymbol coreAssembly, + string metadataName + ) => + SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, coreAssembly) && + string.Equals(GetMetadataName(type), metadataName, StringComparison.Ordinal); + + private static bool IsType(ITypeSymbol? left, ITypeSymbol? right) => + left is not null && right is not null && SymbolEqualityComparer.Default.Equals(left, right); + + private static string GetMetadataName(ITypeSymbol type) + { + var namespaceName = type.ContainingNamespace?.IsGlobalNamespace == false ? + type.ContainingNamespace.ToDisplayString() + "." : + string.Empty; + return namespaceName + type.MetadataName; + } + + private sealed class FrameworkTypes + { + public FrameworkTypes(Compilation compilation) + { + DateTime = compilation.GetTypeByMetadataName("System.DateTime"); + DateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset"); + TimeSpan = compilation.GetTypeByMetadataName("System.TimeSpan"); + DateOnly = compilation.GetTypeByMetadataName("System.DateOnly"); + TimeOnly = compilation.GetTypeByMetadataName("System.TimeOnly"); + Guid = compilation.GetTypeByMetadataName("System.Guid"); + Uri = compilation.GetTypeByMetadataName("System.Uri"); + } + + public INamedTypeSymbol? DateTime { get; } + public INamedTypeSymbol? DateTimeOffset { get; } + public INamedTypeSymbol? TimeSpan { get; } + public INamedTypeSymbol? DateOnly { get; } + public INamedTypeSymbol? TimeOnly { get; } + public INamedTypeSymbol? Guid { get; } + public INamedTypeSymbol? Uri { get; } + } + + private enum ParameterType + { + Int32 = 0, + Int64 = 1, + Double = 2, + String = 3, + DateTime = 4, + DateTimeKind = 5, + TimeSpan = 6, + UriKind = 7 + } + + private enum SupportedConstructor + { + DateTimeDate = 0, + DateTimeSecond = 1, + DateTimeMillisecond = 2, + DateTimeSecondKind = 3, + DateTimeMillisecondKind = 4, + DateTimeTicks = 5, + DateTimeTicksKind = 6, + DateTimeOffsetSecond = 7, + DateTimeOffsetMillisecond = 8, + DateTimeOffsetTicks = 9, + DateTimeOffsetDateTime = 10, + TimeSpanThreeArguments = 11, + TimeSpanFourArguments = 12, + TimeSpanFiveArguments = 13, + TimeSpanTicks = 14, + DateOnly = 15, + TimeOnlyMinute = 16, + TimeOnlySecond = 17, + TimeOnlyMillisecond = 18, + TimeOnlyTicks = 19, + Guid = 20, + Uri = 21, + UriKind = 22 + } + + private enum SupportedFactory + { + TimeSpanFromTicks = 0, + TimeSpanFromDaysDouble = 1, + TimeSpanFromDaysIntegral = 2, + TimeSpanFromHoursDouble = 3, + TimeSpanFromHoursIntegral = 4, + TimeSpanFromMinutesDouble = 5, + TimeSpanFromMinutesIntegral = 6, + TimeSpanFromSecondsDouble = 7, + TimeSpanFromSecondsIntegral = 8, + TimeSpanFromMillisecondsDouble = 9, + TimeSpanFromMillisecondsIntegral = 10, + TimeSpanFromMicrosecondsDouble = 11, + TimeSpanFromMicrosecondsIntegral = 12, + DateOnlyFromDayNumber = 13, + DateTimeOffsetFromUnixTimeSeconds = 14, + DateTimeOffsetFromUnixTimeMilliseconds = 15, + GuidParse = 16, + GuidParseExact = 17 + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs new file mode 100644 index 0000000..65940d3 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs @@ -0,0 +1,245 @@ +using System; +using System.Globalization; +using System.Xml; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Identifies the framework value represented by a reconstructed validation metadata boundary. +/// +public enum ReconstructedMetadataValueKind +{ + DateTime = 0, + DateTimeOffset = 1, + TimeSpan = 2, + DateOnly = 3, + TimeOnly = 4, + Guid = 5, + Uri = 6 +} + +/// +/// Carries a reconstructed validation metadata boundary as a deterministic structural payload. +/// +public sealed class ReconstructedMetadataValue +{ + private const string DateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; + private const string DateTimeOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"; + private const string DateOnlyFormat = "yyyy-MM-dd"; + private const string TimeOnlyFormat = "HH:mm:ss.FFFFFFF"; + + private ReconstructedMetadataValue( + ReconstructedMetadataValueKind kind, + long primaryValue, + long secondaryValue, + string? text + ) + { + Kind = kind; + PrimaryValue = primaryValue; + SecondaryValue = secondaryValue; + Text = text; + } + + /// + /// Gets the represented framework value kind. + /// + public ReconstructedMetadataValueKind Kind { get; } + + /// + /// Gets the primary integer payload. + /// + public long PrimaryValue { get; } + + /// + /// Gets the secondary integer payload. + /// + public long SecondaryValue { get; } + + /// + /// Gets the ordinal text payload, when the represented kind uses one. + /// + public string? Text { get; } + + /// + /// Creates a structural value from a deterministic . + /// + public static ReconstructedMetadataValue FromDateTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + throw new ArgumentException( + "Local DateTime values cannot be reconstructed deterministically.", + nameof(value) + ); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.DateTime, + value.Ticks, + (long) value.Kind, + text: null + ); + } + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromDateTimeOffset(DateTimeOffset value) => + new ( + ReconstructedMetadataValueKind.DateTimeOffset, + value.Ticks, + value.Offset.Ticks, + text: null + ); + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromTimeSpan(TimeSpan value) => + new (ReconstructedMetadataValueKind.TimeSpan, value.Ticks, secondaryValue: 0L, text: null); + + /// + /// Creates a structural date-only value from its day number. + /// + public static ReconstructedMetadataValue FromDateOnlyDayNumber(int dayNumber) + { + if (dayNumber < 0 || dayNumber > 3_652_058) + { + throw new ArgumentOutOfRangeException(nameof(dayNumber)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.DateOnly, + dayNumber, + secondaryValue: 0L, + text: null + ); + } + + /// + /// Creates a structural time-only value from its tick count. + /// + public static ReconstructedMetadataValue FromTimeOnlyTicks(long ticks) + { + if (ticks < 0L || ticks >= TimeSpan.TicksPerDay) + { + throw new ArgumentOutOfRangeException(nameof(ticks)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.TimeOnly, + ticks, + secondaryValue: 0L, + text: null + ); + } + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromGuid(Guid value) => + new ( + ReconstructedMetadataValueKind.Guid, + primaryValue: 0L, + secondaryValue: 0L, + value.ToString("D", CultureInfo.InvariantCulture) + ); + + /// + /// Creates a structural URI value from its original text. + /// + public static ReconstructedMetadataValue FromUriOriginalString(string originalString) + { + if (originalString is null) + { + throw new ArgumentNullException(nameof(originalString)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.Uri, + primaryValue: 0L, + secondaryValue: 0L, + originalString + ); + } + + /// + /// Renders the canonical metadata text used in generated example messages. + /// + public string ToCanonicalString() => + Kind switch + { + ReconstructedMetadataValueKind.DateTime => + new DateTime(PrimaryValue, (DateTimeKind) SecondaryValue).ToString( + DateTimeFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.DateTimeOffset => + new DateTimeOffset(PrimaryValue, TimeSpan.FromTicks(SecondaryValue)).ToString( + DateTimeOffsetFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.TimeSpan => XmlConvert.ToString(TimeSpan.FromTicks(PrimaryValue)), + ReconstructedMetadataValueKind.DateOnly => + new DateTime(checked(PrimaryValue * TimeSpan.TicksPerDay), DateTimeKind.Unspecified).ToString( + DateOnlyFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.TimeOnly => + new DateTime(PrimaryValue, DateTimeKind.Unspecified).ToString( + TimeOnlyFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.Guid or ReconstructedMetadataValueKind.Uri => Text!, + _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") + }; + + /// + /// Renders the exact C# expression emitted into a generated OpenAPI contract. + /// + public string ToCSharpLiteral() => + Kind switch + { + ReconstructedMetadataValueKind.DateTime => + "new global::System.DateTime(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + GetDateTimeKindName((DateTimeKind) SecondaryValue) + + ")", + ReconstructedMetadataValueKind.DateTimeOffset => + "new global::System.DateTimeOffset(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + SecondaryValue.ToString(CultureInfo.InvariantCulture) + + "L))", + ReconstructedMetadataValueKind.TimeSpan => + "global::System.TimeSpan.FromTicks(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L)", + ReconstructedMetadataValueKind.DateOnly => + "global::System.DateOnly.FromDayNumber(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + ")", + ReconstructedMetadataValueKind.TimeOnly => + "new global::System.TimeOnly(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L)", + ReconstructedMetadataValueKind.Guid => + "global::System.Guid.ParseExact(" + CSharpLiterals.ToStringLiteral(Text!) + ", \"D\")", + ReconstructedMetadataValueKind.Uri => + "new global::System.Uri(" + + CSharpLiterals.ToStringLiteral(Text!) + + ", global::System.UriKind.RelativeOrAbsolute)", + _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") + }; + + private static string GetDateTimeKindName(DateTimeKind kind) => + kind switch + { + DateTimeKind.Unspecified => nameof(DateTimeKind.Unspecified), + DateTimeKind.Utc => nameof(DateTimeKind.Utc), + DateTimeKind.Local => nameof(DateTimeKind.Local), + _ => throw new InvalidOperationException($"Unsupported DateTime kind '{kind}'.") + }; +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 8bac423..4a2d582 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -72,11 +72,13 @@ out var methodDeclaration } var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree); + var metadataValueReconstructor = new MetadataValueReconstructor(compilation); var sourceParameterName = performValidation.Parameters.Length >= 3 ? performValidation.Parameters[2].Name : null; var rules = ImmutableArray.CreateBuilder(); AnalyzePerformValidationBody( semanticModel, + metadataValueReconstructor, methodDeclaration, sourceParameterName, rules, @@ -204,6 +206,7 @@ CancellationToken cancellationToken private static void AnalyzePerformValidationBody( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, MethodDeclarationSyntax methodDeclaration, string? sourceParameterName, ICollection rules, @@ -223,6 +226,7 @@ CancellationToken cancellationToken { AnalyzeCheckExpression( semanticModel, + metadataValueReconstructor, expression, sourceParameterName, rules, @@ -238,6 +242,7 @@ CancellationToken cancellationToken private static void AnalyzeCheckExpression( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, ExpressionSyntax expression, string? sourceParameterName, ICollection rules, @@ -294,6 +299,7 @@ CancellationToken cancellationToken var rule = CreateRuleCall( semanticModel, + metadataValueReconstructor, invocation, symbol, ruleAttribute, @@ -311,6 +317,7 @@ CancellationToken cancellationToken private static RuleCallModel? CreateRuleCall( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, InvocationExpressionSyntax invocation, IMethodSymbol symbol, AttributeData ruleAttribute, @@ -337,6 +344,7 @@ CancellationToken cancellationToken } var metadataValues = ImmutableArray.CreateBuilder(); + var diagnosedArguments = new HashSet(StringComparer.Ordinal); foreach (var metadataAttribute in definitionSymbol.GetAttributes() .Where(static attribute => IsAttribute(attribute, KnownTypeNames.ValidationRuleMetadataAttribute))) { @@ -355,13 +363,16 @@ CancellationToken cancellationToken { if (!TryResolveArgumentConstant( semanticModel, + metadataValueReconstructor, invocation, symbol, sourceArgument!, cancellationToken, out var value, out var valueTypeName, - out var hasConstantValue + out var hasConstantValue, + out var argument, + out var reconstructionResult )) { diagnostics.Add( @@ -375,6 +386,24 @@ out var hasConstantValue return null; } + if (!hasConstantValue && + argument is not null && + diagnosedArguments.Add(sourceArgument!)) + { + var descriptor = + reconstructionResult == MetadataReconstructionResult.MultiFileFieldUnsupported ? + DiagnosticDescriptors.MultiFileMetadataFieldUnsupported : + DiagnosticDescriptors.MetadataValueCannotBeReconstructed; + diagnostics.Add( + Diagnostic.Create( + descriptor, + argument.GetLocation(), + symbol.Name, + sourceArgument + ) + ); + } + metadataValues.Add(new MetadataValueModel(metadataKey!, value, hasConstantValue, valueTypeName)); continue; } @@ -616,6 +645,7 @@ private static string FormatMessageValue(object? value) return value switch { null => string.Empty, + ReconstructedMetadataValue reconstructedValue => reconstructedValue.ToCanonicalString(), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty }; @@ -762,18 +792,23 @@ out string typeName private static bool TryResolveArgumentConstant( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, InvocationExpressionSyntax invocation, IMethodSymbol symbol, string sourceArgument, CancellationToken cancellationToken, out object? value, out string typeName, - out bool hasConstantValue + out bool hasConstantValue, + out ArgumentSyntax? resolvedArgument, + out MetadataReconstructionResult reconstructionResult ) { value = null; typeName = "object"; hasConstantValue = false; + resolvedArgument = null; + reconstructionResult = MetadataReconstructionResult.Unsupported; var parameterIndex = -1; for (var i = 0; i < symbol.Parameters.Length; i++) @@ -806,10 +841,26 @@ out bool hasConstantValue } var constant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + resolvedArgument = argument; if (constant.HasValue) { value = constant.Value; hasConstantValue = true; + reconstructionResult = MetadataReconstructionResult.Success; + } + else + { + reconstructionResult = metadataValueReconstructor.TryReconstruct( + semanticModel, + argument.Expression, + cancellationToken, + out var reconstructedValue + ); + if (reconstructionResult == MetadataReconstructionResult.Success) + { + value = reconstructedValue; + hasConstantValue = true; + } } return true; @@ -820,6 +871,7 @@ out bool hasConstantValue { value = parameter.ExplicitDefaultValue; hasConstantValue = true; + reconstructionResult = MetadataReconstructionResult.Success; } return true; @@ -1020,22 +1072,6 @@ private static string ToCamelCase(string name) return char.ToLowerInvariant(name[0]) + name.Substring(1); } - private readonly struct MessageTemplatePart - { - private MessageTemplatePart(string text, string? placeholder) - { - Text = text; - Placeholder = placeholder; - } - - public string Text { get; } - public string? Placeholder { get; } - - public static MessageTemplatePart Literal(string text) => new (text, null); - - public static MessageTemplatePart PlaceholderValue(string placeholder) => new (string.Empty, placeholder); - } - private static string? ResolveTypedValueTypeName(IMethodSymbol symbol, RuleMetadataShape shape) { if (shape == RuleMetadataShape.Registered) @@ -1559,4 +1595,20 @@ private static string GetMetadataName(ITypeSymbol typeSymbol) string.Empty; return containingNamespace + typeSymbol.MetadataName; } + + private readonly struct MessageTemplatePart + { + private MessageTemplatePart(string text, string? placeholder) + { + Text = text; + Placeholder = placeholder; + } + + public string Text { get; } + public string? Placeholder { get; } + + public static MessageTemplatePart Literal(string text) => new (text, null); + + public static MessageTemplatePart PlaceholderValue(string placeholder) => new (string.Empty, placeholder); + } } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 8aa3ef2..d4feda7 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text; namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; @@ -95,7 +94,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo { writer .Write("builder.WithErrorCodes(") - .Write(string.Join(", ", registeredCodes.Select(ToStringLiteral))) + .Write(string.Join(", ", registeredCodes.Select(CSharpLiterals.ToStringLiteral))) .WriteLine(");"); } @@ -110,7 +109,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo .Write("builder.WithErrorMetadata<") .Write(hint.MetadataTypeName!) .Write(">(") - .Write(ToStringLiteral(hint.Code)) + .Write(CSharpLiterals.ToStringLiteral(hint.Code)) .WriteLine(");"); } @@ -194,11 +193,11 @@ string schemaId var properties = metadataSchemaProperties .OrderBy(static property => property.Key, StringComparer.Ordinal) .ToArray(); - var requiredKeys = string.Join(", ", properties.Select(static property => ToStringLiteral(property.Key))); + var requiredKeys = string.Join(", ", properties.Select(static property => CSharpLiterals.ToStringLiteral(property.Key))); writer .Write("builder.WithErrorMetadata(") - .Write(ToStringLiteral(code)) + .Write(CSharpLiterals.ToStringLiteral(code)) .WriteLine(", _ => new OpenApiSchema") .WriteLine("{") .IncreaseIndent() @@ -211,7 +210,7 @@ string schemaId { writer .Write("[") - .Write(ToStringLiteral(property.Key)) + .Write(CSharpLiterals.ToStringLiteral(property.Key)) .Write("] = PortableOpenApiSchemaTypeMapper.Map<") .Write(property.TypeName) .WriteLine(">(),"); @@ -225,7 +224,7 @@ string schemaId .WriteLine(" }") .DecreaseIndent() .Write("}, ") - .Write(ToStringLiteral(schemaId)) + .Write(CSharpLiterals.ToStringLiteral(schemaId)) .WriteLine(");"); } @@ -237,11 +236,11 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) writer .Write("builder.WithErrorExample(") - .Write(ToStringLiteral(rule.Code)) + .Write(CSharpLiterals.ToStringLiteral(rule.Code)) .Write(", ") - .Write(rule.Target is null ? "null" : ToStringLiteral(rule.Target)) + .Write(rule.Target is null ? "null" : CSharpLiterals.ToStringLiteral(rule.Target)) .Write(", ") - .Write(rule.Message is null ? "null" : ToStringLiteral(rule.Message)); + .Write(rule.Message is null ? "null" : CSharpLiterals.ToStringLiteral(rule.Message)); if (canEmitMetadata && rule.MetadataValues.Length > 0) { @@ -262,7 +261,7 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) "\u001F", example.MetadataValues.Select( static metadata => - metadata.Key + "=" + (metadata.Value?.ToString() ?? string.Empty) + metadata.Key + "=" + GetDeduplicationValue(metadata.Value) ) ) } @@ -273,11 +272,11 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) { writer .Write("builder.WithErrorExample(") - .Write(ToStringLiteral(example.Code)) + .Write(CSharpLiterals.ToStringLiteral(example.Code)) .Write(", ") - .Write(example.Target is null ? "null" : ToStringLiteral(example.Target)) + .Write(example.Target is null ? "null" : CSharpLiterals.ToStringLiteral(example.Target)) .Write(", ") - .Write(example.Message is null ? "null" : ToStringLiteral(example.Message)); + .Write(example.Message is null ? "null" : CSharpLiterals.ToStringLiteral(example.Message)); if (example.MetadataValues.Length > 0) { @@ -304,7 +303,7 @@ IEnumerable metadataValues ", ", metadataValues.Select( static metadataValue => - $"[{ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}" + $"[{CSharpLiterals.ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}" ) ); @@ -330,105 +329,30 @@ IEnumerable metadataValues }; private static string ToLiteral(object? value) => - TryCreateDateOnlyOrTimeOnlyLiteral(value) ?? - value switch - { - null => "null", - string stringValue => ToStringLiteral(stringValue), - char charValue => "'" + EscapeChar(charValue) + "'", - bool boolValue => boolValue ? "true" : "false", - byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), - sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), - short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), - ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture), - int intValue => intValue.ToString(CultureInfo.InvariantCulture), - uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture) + "U", - long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", - ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture) + "UL", - float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", - double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", - decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", - DateTime dateTimeValue => - "new global::System.DateTime(" + - dateTimeValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.DateTimeKind." + - dateTimeValue.Kind + - ")", - DateTimeOffset dateTimeOffsetValue => - "new global::System.DateTimeOffset(" + - dateTimeOffsetValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.TimeSpan.FromTicks(" + - dateTimeOffsetValue.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + - "L))", - TimeSpan timeSpanValue => - "global::System.TimeSpan.FromTicks(" + - timeSpanValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L)", - Guid guidValue => - "global::System.Guid.ParseExact(" + - ToStringLiteral(guidValue.ToString("D")) + - ", \"D\")", - Uri uriValue => - "new global::System.Uri(" + - ToStringLiteral(uriValue.OriginalString) + - ", global::System.UriKind.RelativeOrAbsolute)", - _ => ToStringLiteral(value.ToString() ?? string.Empty) - }; - - private static string? TryCreateDateOnlyOrTimeOnlyLiteral(object? value) - { - if (value is null) - { - return null; - } - - var type = value.GetType(); - if (string.Equals(type.FullName, "System.DateOnly", StringComparison.Ordinal)) - { - var dayNumber = (int) type.GetProperty("DayNumber")!.GetValue(value, index: null)!; - return "global::System.DateOnly.FromDayNumber(" + - dayNumber.ToString(CultureInfo.InvariantCulture) + - ")"; - } - - if (string.Equals(type.FullName, "System.TimeOnly", StringComparison.Ordinal)) - { - var ticks = (long) type.GetProperty("Ticks")!.GetValue(value, index: null)!; - return "new global::System.TimeOnly(" + - ticks.ToString(CultureInfo.InvariantCulture) + - "L)"; - } - - return null; - } - - private static string ToStringLiteral(string value) - { - var builder = new StringBuilder(value.Length + 2).Append('"'); - foreach (var c in value) - { - builder.Append(EscapeChar(c)); - } - - return builder.Append('"').ToString(); - } - - private static string EscapeChar(char value) => - value switch - { - '\\' => @"\\", - '"' => "\\\"", - '\'' => "\\'", - '\0' => "\\0", - '\a' => "\\a", - '\b' => "\\b", - '\f' => "\\f", - '\n' => "\\n", - '\r' => "\\r", - '\t' => "\\t", - '\v' => "\\v", - _ => char.IsControl(value) ? - "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : - value.ToString() - }; + value is ReconstructedMetadataValue reconstructedValue ? + reconstructedValue.ToCSharpLiteral() : + value switch + { + null => "null", + string stringValue => CSharpLiterals.ToStringLiteral(stringValue), + char charValue => "'" + CSharpLiterals.EscapeChar(charValue) + "'", + bool boolValue => boolValue ? "true" : "false", + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture) + "U", + long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", + ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture) + "UL", + float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", + double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", + _ => CSharpLiterals.ToStringLiteral(value.ToString() ?? string.Empty) + }; + + private static string GetDeduplicationValue(object? value) => + value is ReconstructedMetadataValue reconstructedValue ? + reconstructedValue.Kind + ":" + reconstructedValue.ToCSharpLiteral() : + value?.ToString() ?? string.Empty; } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs index 10d9f6b..79eab89 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -67,6 +67,9 @@ public override int GetHashCode() hashCode = hashCode * 31 + diagnostic.Id.GetHashCode(); hashCode = hashCode * 31 + diagnostic.Severity.GetHashCode(); hashCode = hashCode * 31 + diagnostic.GetMessage().GetHashCode(); + hashCode = hashCode * 31 + GetDiagnosticSourcePath(diagnostic).GetHashCode(); + hashCode = hashCode * 31 + diagnostic.Location.SourceSpan.Start.GetHashCode(); + hashCode = hashCode * 31 + diagnostic.Location.SourceSpan.Length.GetHashCode(); } return hashCode; @@ -87,7 +90,9 @@ ImmutableArray right { if (left[i].Id != right[i].Id || left[i].Severity != right[i].Severity || - left[i].GetMessage() != right[i].GetMessage()) + left[i].GetMessage() != right[i].GetMessage() || + GetDiagnosticSourcePath(left[i]) != GetDiagnosticSourcePath(right[i]) || + left[i].Location.SourceSpan != right[i].Location.SourceSpan) { return false; } @@ -95,6 +100,9 @@ ImmutableArray right return true; } + + private static string GetDiagnosticSourcePath(Diagnostic diagnostic) => + diagnostic.Location.SourceTree?.FilePath ?? string.Empty; } public sealed class ValidatorModel @@ -282,7 +290,8 @@ public bool Equals(RuleCallModel? x, RuleCallModel? y) return true; } - if (x is null || y is null || + if (x is null || + y is null || x.Code != y.Code || x.MetadataSchemaProperties.Length != y.MetadataSchemaProperties.Length) { diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs new file mode 100644 index 0000000..3415541 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataIncrementalTests +{ + [Fact] + public void GeneratorMovesUnresolvedArgumentDiagnosticWhenDriverIsReused() + { + var firstCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateSource(extraBlankLine: false)) + ); + var driver = GeneratorTestHarness.CreateDriver().RunGenerators( + firstCompilation, + TestContext.Current.CancellationToken + ); + var firstDiagnostic = GetReconstructionDiagnostic(driver); + + var secondCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateSource(extraBlankLine: true)) + ); + driver = driver.RunGenerators(secondCompilation, TestContext.Current.CancellationToken); + var secondDiagnostic = GetReconstructionDiagnostic(driver); + + secondDiagnostic.Location.SourceSpan.Start.Should().BeGreaterThan(firstDiagnostic.Location.SourceSpan.Start); + secondDiagnostic.Location.GetLineSpan().StartLinePosition.Line.Should() + .Be(firstDiagnostic.Location.GetLineSpan().StartLinePosition.Line + 1); + secondDiagnostic.Location.SourceTree!.GetText(TestContext.Current.CancellationToken) + .GetSubText(secondDiagnostic.Location.SourceSpan) + .ToString() + .Should() + .Be("DateTime.Now"); + } + + private static Diagnostic GetReconstructionDiagnostic(GeneratorDriver driver) => + driver.GetRunResult().Results + .SelectMany(static result => result.Diagnostics) + .Single(static diagnostic => diagnostic.Id == "LPRSG0015"); + + private static string CreateSource(bool extraBlankLine) + { + var blankLine = extraBlankLine ? "\n" : string.Empty; + return $$""" + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + {{blankLine}}context.Check(value).IsEqualTo(DateTime.Now); + return checkpoint.ToValidatedValue(value); + } + } + """; + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs new file mode 100644 index 0000000..b7ecba9 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FluentAssertions; +using Light.PortableResults.Metadata; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataReconstructionTests +{ + private const string GuidText = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateTimeConstructorAndStatic() + { + var cases = new[] + { + DateTimeCase("new (2026, 1, 2)", new DateTime(2026, 1, 2)), + DateTimeCase("new DateTime(2026, 1, 2, 3, 4, 5)", new DateTime(2026, 1, 2, 3, 4, 5)), + DateTimeCase("new DateTime(2026, 1, 2, 3, 4, 5, 6)", new DateTime(2026, 1, 2, 3, 4, 5, 6)), + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)", + new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) + ), + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)", + new DateTime(2026, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc) + ), + DateTimeCase("new DateTime(638713838450000000L)", new DateTime(638713838450000000L)), + DateTimeCase( + "new DateTime(638713838450000000L, DateTimeKind.Utc)", + new DateTime(638713838450000000L, DateTimeKind.Utc) + ), + DateTimeCase("DateTime.MinValue", DateTime.MinValue), + DateTimeCase("DateTime.MaxValue", DateTime.MaxValue), + DateTimeCase("DateTime.UnixEpoch", DateTime.UnixEpoch) + }; + + AssertAcceptedCases("DateTime", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateTimeOffsetConstructorFactoryAndStatic() + { + var offset = TimeSpan.FromHours(2); + var dateTime = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified); + var cases = new[] + { + DateTimeOffsetCase( + "new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.FromHours(2))", + new DateTimeOffset(2026, 1, 2, 3, 4, 5, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(2026, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(2))", + new DateTimeOffset(2026, 1, 2, 3, 4, 5, 6, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(638713838450000000L, TimeSpan.FromHours(2))", + new DateTimeOffset(638713838450000000L, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(new DateTime(2026, 1, 2, 3, 4, 5), TimeSpan.FromHours(2))", + new DateTimeOffset(dateTime, offset) + ), + DateTimeOffsetCase( + "DateTimeOffset.FromUnixTimeSeconds(1L)", + DateTimeOffset.FromUnixTimeSeconds(1L) + ), + DateTimeOffsetCase( + "DateTimeOffset.FromUnixTimeMilliseconds(1L)", + DateTimeOffset.FromUnixTimeMilliseconds(1L) + ), + DateTimeOffsetCase("DateTimeOffset.MinValue", DateTimeOffset.MinValue), + DateTimeOffsetCase("DateTimeOffset.MaxValue", DateTimeOffset.MaxValue), + DateTimeOffsetCase("DateTimeOffset.UnixEpoch", DateTimeOffset.UnixEpoch) + }; + + AssertAcceptedCases("DateTimeOffset", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedTimeSpanConstructorFactoryAndStatic() + { + var cases = new[] + { + TimeSpanCase("new TimeSpan(2, 3, 4)", new TimeSpan(2, 3, 4)), + TimeSpanCase("new TimeSpan(1, 2, 3, 4)", new TimeSpan(1, 2, 3, 4)), + TimeSpanCase("new TimeSpan(1, 2, 3, 4, 5)", new TimeSpan(1, 2, 3, 4, 5)), + TimeSpanCase("new TimeSpan(123456789L)", new TimeSpan(123456789L)), + TimeSpanCase("TimeSpan.FromTicks(123456789L)", TimeSpan.FromTicks(123456789L)), + TimeSpanCase("TimeSpan.FromDays(0.25D)", TimeSpan.FromDays(0.25D)), + TimeSpanCase("TimeSpan.FromDays(1)", TimeSpan.FromDays(1)), + TimeSpanCase("TimeSpan.FromHours(2D)", TimeSpan.FromHours(2D)), + TimeSpanCase("TimeSpan.FromHours(2)", TimeSpan.FromHours(2)), + TimeSpanCase("TimeSpan.FromMinutes(3D)", TimeSpan.FromMinutes(3D)), + TimeSpanCase("TimeSpan.FromMinutes(3L)", TimeSpan.FromMinutes(3L)), + TimeSpanCase("TimeSpan.FromSeconds(4D)", TimeSpan.FromSeconds(4D)), + TimeSpanCase("TimeSpan.FromSeconds(4L)", TimeSpan.FromSeconds(4L)), + TimeSpanCase("TimeSpan.FromMilliseconds(5D)", TimeSpan.FromMilliseconds(5D)), + TimeSpanCase("TimeSpan.FromMilliseconds(5L)", TimeSpan.FromMilliseconds(5L)), + TimeSpanCase("TimeSpan.FromMicroseconds(6D)", TimeSpan.FromMicroseconds(6D)), + TimeSpanCase("TimeSpan.FromMicroseconds(6L)", TimeSpan.FromMicroseconds(6L)), + TimeSpanCase("TimeSpan.Zero", TimeSpan.Zero), + TimeSpanCase("TimeSpan.MinValue", TimeSpan.MinValue), + TimeSpanCase("TimeSpan.MaxValue", TimeSpan.MaxValue) + }; + + AssertAcceptedCases("TimeSpan", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateOnlyConstructorFactoryAndStatic() + { + var cases = new[] + { + DateOnlyCase("new DateOnly(2026, 1, 2)", new DateOnly(2026, 1, 2)), + DateOnlyCase("DateOnly.FromDayNumber(1234)", DateOnly.FromDayNumber(1234)), + DateOnlyCase("DateOnly.MinValue", DateOnly.MinValue), + DateOnlyCase("DateOnly.MaxValue", DateOnly.MaxValue) + }; + + AssertAcceptedCases("DateOnly", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedTimeOnlyConstructorAndStatic() + { + var cases = new[] + { + TimeOnlyCase("new TimeOnly(3, 4)", new TimeOnly(3, 4)), + TimeOnlyCase("new TimeOnly(3, 4, 5)", new TimeOnly(3, 4, 5)), + TimeOnlyCase("new TimeOnly(3, 4, 5, 6)", new TimeOnly(3, 4, 5, 6)), + TimeOnlyCase("new TimeOnly(123456789L)", new TimeOnly(123456789L)), + TimeOnlyCase("TimeOnly.MinValue", TimeOnly.MinValue), + TimeOnlyCase("TimeOnly.MaxValue", TimeOnly.MaxValue) + }; + + AssertAcceptedCases("TimeOnly", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedGuidConstructorFactoryAndStatic() + { + var guid = new Guid(GuidText); + var cases = new[] + { + GuidCase($"new Guid(\"{GuidText}\")", guid), + GuidCase($"Guid.Parse(\"{GuidText}\")", guid), + GuidCase("Guid.ParseExact(\"a1b2c3d4e5f67890abcdef1234567890\", \"N\")", guid), + GuidCase("Guid.Empty", Guid.Empty) + }; + + AssertAcceptedCases("Guid", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedUriConstructor() + { + var absoluteText = "https://example.com/items/42?view=full#summary"; + var relativeText = "items/42#summary"; + var cases = new[] + { + UriCase($"new Uri(\"{absoluteText}\")", new Uri(absoluteText)), + UriCase( + $"new Uri(\"{relativeText}\", UriKind.Relative)", + new Uri(relativeText, UriKind.Relative) + ) + }; + + AssertAcceptedCases("Uri", cases); + } + + [Fact] + public void GeneratorReconstructsDateTimeOffsetWithEveryAcceptedTimeSpanShape() + { + var offset = TimeSpan.FromHours(2); + var expressions = new[] + { + "new TimeSpan(2, 0, 0)", + "new TimeSpan(0, 2, 0, 0)", + "new TimeSpan(0, 2, 0, 0, 0)", + "new TimeSpan(72000000000L)", + "TimeSpan.FromTicks(72000000000L)", + "TimeSpan.FromDays(0.08333333333333333D)", + "TimeSpan.FromDays(0)", + "TimeSpan.FromHours(2D)", + "TimeSpan.FromHours(2)", + "TimeSpan.FromMinutes(120D)", + "TimeSpan.FromMinutes(120L)", + "TimeSpan.FromSeconds(7200D)", + "TimeSpan.FromSeconds(7200L)", + "TimeSpan.FromMilliseconds(7200000D)", + "TimeSpan.FromMilliseconds(7200000L)", + "TimeSpan.FromMicroseconds(7200000000D)", + "TimeSpan.FromMicroseconds(7200000000L)", + "TimeSpan.Zero", + "Offset" + }; + var cases = expressions.Select( + (expression, index) => DateTimeOffsetCase( + $"new DateTimeOffset(2026, 1, {index + 1}, 0, 0, 0, {expression})", + new DateTimeOffset( + 2026, + 1, + index + 1, + 0, + 0, + 0, + expression is "TimeSpan.Zero" or "TimeSpan.FromDays(0)" ? TimeSpan.Zero : offset + ) + ) + ) + .ToArray(); + + AssertAcceptedCases( + "DateTimeOffset", + cases, + "private static readonly TimeSpan Offset = TimeSpan.FromHours(2);" + ); + } + + [Fact] + public void GeneratorReconstructsStaticReadonlyFieldDeclaredInValidatorFile() + { + var value = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var cases = new[] { DateTimeCase("Boundary", value) }; + + AssertAcceptedCases( + "DateTime", + cases, + "private static readonly DateTime Boundary = new (2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);" + ); + } + + [Fact] + public void GeneratorReconstructsStaticReadonlyFieldsWithConstantInitializers() + { + AssertAcceptedCases( + "TimeSpan", + new[] { TimeSpanCase("new TimeSpan(Hours, 0, 0)", new TimeSpan(2, 0, 0)) }, + "private static readonly int Hours = 2;" + ); + AssertAcceptedCases( + "int", + new[] { new BoundaryCase("Boundary", MetadataValue.FromInt64(2).ToCanonicalString(), "2") }, + "private static readonly int Boundary = 2;" + ); + AssertAcceptedCases( + "Guid", + new[] { GuidCase("new Guid(Text)", new Guid(GuidText)) }, + $"private static readonly string Text = \"{GuidText}\";" + ); + AssertAcceptedCases( + "DateTime", + new[] + { + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, Kind)", + new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) + ) + }, + "private static readonly DateTimeKind Kind = DateTimeKind.Utc;" + ); + } + + private static void AssertAcceptedCases( + string valueType, + IReadOnlyList cases, + string additionalMember = "" + ) + { + var source = CreateValidatorSource(valueType, cases, additionalMember); + var result = GeneratorTestHarness.Run(source); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.ResultDiagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .NotContain(["LPRSG0015", "LPRSG0016"]); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + for (var i = 0; i < cases.Count; i++) + { + generatedSource.Should().Contain($"case{i} must be equal to {cases[i].CanonicalText}"); + generatedSource.Should().Contain($"[\"comparativeValue\"] = {cases[i].CSharpLiteral}"); + } + } + + private static string CreateValidatorSource( + string valueType, + IReadOnlyList cases, + string additionalMember + ) + { + var calls = string.Join( + Environment.NewLine, + cases.Select( + static (item, index) => + $"context.Check(value, displayName: \"case{index}\").IsEqualTo({item.Expression});" + ) + ); + return $$""" + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator<{{valueType}}> + { + {{additionalMember}} + + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue<{{valueType}}> PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + {{valueType}} value + ) + { + {{calls}} + return checkpoint.ToValidatedValue(value); + } + } + """; + } + + private static BoundaryCase DateTimeCase(string expression, DateTime value) => + new ( + expression, + MetadataValue.FromDateTime(value).ToCanonicalString(), + "new global::System.DateTime(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + value.Kind + + ")" + ); + + private static BoundaryCase DateTimeOffsetCase(string expression, DateTimeOffset value) => + new ( + expression, + MetadataValue.FromDateTimeOffset(value).ToCanonicalString(), + "new global::System.DateTimeOffset(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + value.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + + "L))" + ); + + private static BoundaryCase TimeSpanCase(string expression, TimeSpan value) => + new ( + expression, + MetadataValue.FromTimeSpan(value).ToCanonicalString(), + "global::System.TimeSpan.FromTicks(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)" + ); + + private static BoundaryCase DateOnlyCase(string expression, DateOnly value) => + new ( + expression, + MetadataValue.FromDateOnly(value).ToCanonicalString(), + "global::System.DateOnly.FromDayNumber(" + + value.DayNumber.ToString(CultureInfo.InvariantCulture) + + ")" + ); + + private static BoundaryCase TimeOnlyCase(string expression, TimeOnly value) => + new ( + expression, + MetadataValue.FromTimeOnly(value).ToCanonicalString(), + "new global::System.TimeOnly(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)" + ); + + private static BoundaryCase GuidCase(string expression, Guid value) => + new ( + expression, + MetadataValue.FromGuid(value).ToCanonicalString(), + "global::System.Guid.ParseExact(\"" + value.ToString("D") + "\", \"D\")" + ); + + private static BoundaryCase UriCase(string expression, Uri value) => + new ( + expression, + MetadataValue.FromUri(value).ToCanonicalString(), + "new global::System.Uri(\"" + + value.OriginalString + + "\", global::System.UriKind.RelativeOrAbsolute)" + ); + + private sealed class BoundaryCase + { + public BoundaryCase(string expression, string canonicalText, string cSharpLiteral) + { + Expression = expression; + CanonicalText = canonicalText; + CSharpLiteral = cSharpLiteral; + } + + public string Expression { get; } + public string CanonicalText { get; } + public string CSharpLiteral { get; } + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs new file mode 100644 index 0000000..2616243 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -0,0 +1,505 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataRejectionTests +{ + public static IEnumerable OmittedBoundaryCases() + { + yield return ["DateTime", "DateTime.Now", "", ""]; + yield return ["DateTime", "DateTime.UtcNow", "", ""]; + yield return ["DateTime", "DateTime.Today", "", ""]; + yield return ["DateTimeOffset", "DateTimeOffset.Now", "", ""]; + yield return ["DateTimeOffset", "DateTimeOffset.UtcNow", "", ""]; + yield return ["Guid", "Guid.NewGuid()", "", ""]; + yield return ["DateTime", "DateTime.Parse(\"2026-01-02\")", "", ""]; + yield return + [ + "DateTime", + "DateTime.ParseExact(\"2026-01-02\", \"yyyy-MM-dd\", CultureInfo.InvariantCulture)", + "", + "" + ]; + yield return ["DateTimeOffset", "DateTimeOffset.Parse(\"2026-01-02+02:00\")", "", ""]; + yield return + [ + "DateTimeOffset", + "DateTimeOffset.ParseExact(\"2026-01-02+02:00\", \"yyyy-MM-ddzzz\", CultureInfo.InvariantCulture)", + "", + "" + ]; + yield return + [ + "DateTime", + "ParseWithTryParse()", + "private static DateTime ParseWithTryParse() { DateTime.TryParse(\"2026-01-02\", out var value); return value; }", + "" + ]; + yield return + [ + "DateTimeOffset", + "ParseOffsetWithTryParse()", + "private static DateTimeOffset ParseOffsetWithTryParse() { DateTimeOffset.TryParse(\"2026-01-02+02:00\", out var value); return value; }", + "" + ]; + yield return + [ + "DateTimeOffset", + "new DateTimeOffset(new DateTime(2026, 1, 2))", + "", + "" + ]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, new GregorianCalendar())", + "", + "" + ]; + yield return ["Guid", "new Guid(new byte[16])", "", ""]; + yield return ["DateTime", "default", "", ""]; + yield return ["DateTime", "default(DateTime)", "", ""]; + yield return ["DateTime", "value", "", ""]; + yield return + [ + "DateTime", + "BoundaryProperty", + "private static DateTime BoundaryProperty => new (2026, 1, 2);", + "" + ]; + yield return + [ + "DateTime", + "_instanceBoundary", + "private readonly DateTime _instanceBoundary = new (2026, 1, 2);", + "" + ]; + yield return + [ + "DateTime", + "MutableBoundary", + "private static DateTime MutableBoundary = new (2026, 1, 2);", + "" + ]; + yield return ["DateTime", "DateTime.UnixEpoch.AddDays(1)", "", ""]; + yield return + [ + "TimeSpan", + "TimeSpan.FromHours(1, 2, 3, 4, 5)", + "", + "" + ]; + yield return ["DateOnly", "DateOnly.FromDateTime(DateTime.UnixEpoch)", "", ""]; + yield return ["TimeOnly", "TimeOnly.FromDateTime(DateTime.UnixEpoch)", "", ""]; + yield return ["TimeOnly", "TimeOnly.FromTimeSpan(TimeSpan.Zero)", "", ""]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Local)", + "", + "" + ]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, 3, 4, 5, Kind)", + "private static readonly DateTimeKind Kind = DateTimeKind.Local;", + "" + ]; + } + + [Theory] + [MemberData(nameof(OmittedBoundaryCases))] + public void GeneratorReportsInfoAtEveryOmittedBoundary( + string valueType, + string expression, + string additionalMember, + string additionalType + ) + { + AssertRejectedBoundary(valueType, expression, additionalMember, additionalType); + } + + [Theory] + [InlineData("DateTime", "new DateTime(2026, 13, 1)")] + [InlineData("Guid", "Guid.Parse(\"invalid\")")] + [InlineData("Uri", "new Uri(\"http://[\")")] + [InlineData("Uri", "new Uri(\"items/42\")")] + [InlineData("TimeSpan", "TimeSpan.FromDays(double.MaxValue)")] + [InlineData("DateOnly", "DateOnly.FromDayNumber(-1)")] + [InlineData("TimeOnly", "new TimeOnly(-1L)")] + [InlineData("TimeSpan", "TimeSpan.FromMicroseconds(double.NaN)")] + [InlineData("TimeSpan", "TimeSpan.FromMicroseconds(double.MaxValue)")] + public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, string expression) + { + var result = AssertRejectedBoundary(valueType, expression); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + } + + [Theory] + [InlineData("TimeSpan.MinValue")] + [InlineData("TimeSpan.MaxValue")] + public void GeneratorDegradesWhenAcceptedTimeSpanCannotBeDateTimeOffset(string offsetExpression) + { + AssertRejectedBoundary( + "DateTimeOffset", + $"new DateTimeOffset(2026, 1, 2, 0, 0, 0, {offsetExpression})" + ); + } + + [Fact] + public void GeneratorRejectsStaticReadonlyFieldAssignedByStaticConstructor() + { + AssertRejectedBoundary( + "DateTime", + "Boundary", + """ + private static readonly DateTime Boundary = new (2026, 1, 2); + + static BoundaryValidator() + { + Boundary = new DateTime(2027, 1, 2); + } + """ + ); + } + + [Fact] + public void GeneratorRejectsLocalVariableBoundary() + { + var result = GeneratorTestHarness.Run( + """ + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + var boundary = new DateTime(2026, 1, 2); + context.Check(value).IsEqualTo(boundary); + return checkpoint.ToValidatedValue(value); + } + } + """ + ); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0015") + .Should() + .ContainSingle() + .Subject; + + diagnostic.Severity.Should().Be(DiagnosticSeverity.Info); + GetLocatedText(diagnostic).Should().Be("boundary"); + result.GeneratedSources.Should().ContainSingle().Subject + .Should() + .Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + } + + [Fact] + public void GeneratorRejectsStaticReadonlyFieldCycle() + { + AssertRejectedBoundary( + "DateTime", + "First", + """ + private static readonly DateTime First = Second; + private static readonly DateTime Second = First; + """ + ); + } + + [Fact] + public void GeneratorRejectsExpressionBeyondRecursionBound() + { + AssertRejectedBoundary( + "DateTime", + "First", + """ + private static readonly DateTime First = Second; + private static readonly DateTime Second = Third; + private static readonly DateTime Third = Fourth; + private static readonly DateTime Fourth = Fifth; + private static readonly DateTime Fifth = Sixth; + private static readonly DateTime Sixth = new (2026, 1, 2); + """ + ); + } + + [Fact] + public void GeneratorRejectsUserDefinedTypeWithFrameworkName() + { + AssertRejectedBoundary( + "Fake.DateTime", + "new Fake.DateTime(2026, 1, 2)", + additionalType: + """ + namespace Fake + { + public readonly struct DateTime + { + public DateTime(int year, int month, int day) { } + } + } + """ + ); + } + + [Fact] + public void GeneratorRejectsUserDefinedFactoryWithFrameworkMemberName() + { + AssertRejectedBoundary( + "TimeSpan", + "FakeTimeSpan.FromHours(2)", + additionalType: + """ + public static class FakeTimeSpan + { + public static TimeSpan FromHours(int value) => TimeSpan.FromHours(value); + } + """ + ); + } + + [Fact] + public void GeneratorReportsWarningForFieldInAnotherSourceFile() + { + var validatorSource = CreateValidatorSource("DateTime", "Boundaries.Value"); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ( + "Boundaries.cs", + """ + using System; + + public static class Boundaries + { + public static readonly DateTime Value = new (2026, 1, 2); + } + """ + ) + ); + + AssertCrossFileWarning(result, "Boundaries.Value"); + } + + [Fact] + public void GeneratorPropagatesCrossFileWarningFromNestedConstructorArgument() + { + const string expression = + "new DateTimeOffset(2026, 1, 2, 0, 0, 0, Boundaries.Offset)"; + var validatorSource = CreateValidatorSource("DateTimeOffset", expression); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ( + "Boundaries.cs", + """ + using System; + + public static class Boundaries + { + public static readonly TimeSpan Offset = TimeSpan.FromHours(2); + } + """ + ) + ); + + AssertCrossFileWarning(result, expression); + } + + [Fact] + public void GeneratorReportsWarningWhenFieldTypeHasAnotherSourceDeclaration() + { + var validatorSource = CreateValidatorSource( + "DateTime", + "Boundary", + "private static readonly DateTime Boundary = new (2026, 1, 2);" + ); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ("Validator.Partial.cs", "public sealed partial class BoundaryValidator { }") + ); + + AssertCrossFileWarning(result, "Boundary"); + } + + [Fact] + public void GeneratorReportsWarningForFieldInReferencedAssembly() + { + var boundaryCompilation = GeneratorTestHarness.CreateCompilation( + ( + "ExternalBoundaries.cs", + """ + using System; + + public static class ExternalBoundaries + { + public static readonly DateTime Value = new (2026, 1, 2); + } + """ + ) + ) + .WithAssemblyName("BoundaryLibrary"); + using var stream = new MemoryStream(); + var emitResult = boundaryCompilation.Emit( + stream, + cancellationToken: TestContext.Current.CancellationToken + ); + emitResult.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var reference = MetadataReference.CreateFromImage(stream.ToArray()); + var validatorCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateValidatorSource("DateTime", "ExternalBoundaries.Value")) + ) + .AddReferences(reference); + + var result = GeneratorTestHarness.Run(validatorCompilation); + + AssertCrossFileWarning(result, "ExternalBoundaries.Value"); + } + + [Fact] + public void GeneratorReportsOneInfoPerDistinctUnresolvedRangeArgument() + { + var source = CreateRangeValidatorSource(); + var result = GeneratorTestHarness.Run(source); + var diagnostics = result.ResultDiagnostics + .Where(static diagnostic => diagnostic.Id == "LPRSG0015") + .ToArray(); + + diagnostics.Should().HaveCount(2); + diagnostics.Select(GetLocatedText).Should().BeEquivalentTo("DateTime.Now", "DateTime.UtcNow"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"InRange\", null, null);"); + generatedSource.Should().NotContain("[\"lowerBoundary\"]"); + generatedSource.Should().NotContain("[\"upperBoundary\"]"); + } + + private static GeneratorTestResult AssertRejectedBoundary( + string valueType, + string expression, + string additionalMember = "", + string additionalType = "" + ) + { + var result = GeneratorTestHarness.Run( + CreateValidatorSource(valueType, expression, additionalMember, additionalType) + ); + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0015") + .Should() + .ContainSingle() + .Subject; + + diagnostic.Severity.Should().Be(DiagnosticSeverity.Info); + diagnostic.Location.SourceTree!.FilePath.Should().Be("Validator.cs"); + GetLocatedText(diagnostic).Should().Be(expression); + diagnostic.GetMessage().Should().Contain("IsEqualTo").And.Contain("comparativeValue"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + generatedSource.Should().NotContain("[\"comparativeValue\"]"); + return result; + } + + private static void AssertCrossFileWarning(GeneratorTestResult result, string expression) + { + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0016") + .Should() + .ContainSingle() + .Subject; + diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); + diagnostic.Location.SourceTree!.FilePath.Should().Be("Validator.cs"); + GetLocatedText(diagnostic).Should().Be(expression); + diagnostic.GetMessage().Should().Contain("multi-file resolution"); + result.ResultDiagnostics.Select(static candidate => candidate.Id).Should().NotContain("LPRSG0015"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + generatedSource.Should().NotContain("[\"comparativeValue\"]"); + } + + private static string GetLocatedText(Diagnostic diagnostic) => + diagnostic.Location.SourceTree!.GetText().GetSubText(diagnostic.Location.SourceSpan).ToString(); + + private static string CreateValidatorSource( + string valueType, + string expression, + string additionalMember = "", + string additionalType = "" + ) => + $$""" + using System; + using System.Globalization; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator<{{valueType}}> + { + {{additionalMember}} + + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue<{{valueType}}> PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + {{valueType}} value + ) + { + context.Check(value).IsEqualTo({{expression}}); + return checkpoint.ToValidatedValue(value); + } + } + + {{additionalType}} + """; + + private static string CreateRangeValidatorSource() => + """ + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + context.Check(value).IsInRange(DateTime.Now, DateTime.UtcNow); + return checkpoint.ToValidatedValue(value); + } + } + """; +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs new file mode 100644 index 0000000..983cff7 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using Light.PortableResults.AspNetCore.OpenApi; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.OpenApi; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +internal static class GeneratorTestHarness +{ + public static readonly CSharpParseOptions ParseOptions = + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + + public static GeneratorTestResult Run(string source, string path = "Validator.cs") => + Run((path, source)); + + public static GeneratorTestResult Run(params (string Path, string Source)[] sources) + { + return Run(CreateCompilation(sources)); + } + + public static GeneratorTestResult Run(CSharpCompilation compilation) + { + var driver = CreateDriver().RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics + ); + var runResult = driver.GetRunResult(); + var diagnostics = outputCompilation.GetDiagnostics() + .Concat(generatorDiagnostics) + .Concat(runResult.Diagnostics) + .ToImmutableArray(); + var generatedSources = runResult.Results + .SelectMany(static result => result.GeneratedSources) + .Select(static generatedSource => generatedSource.SourceText.ToString()) + .ToImmutableArray(); + var resultDiagnostics = runResult.Results + .SelectMany(static result => result.Diagnostics) + .ToImmutableArray(); + return new GeneratorTestResult(diagnostics, resultDiagnostics, generatedSources); + } + + public static CSharpCompilation CreateCompilation(params (string Path, string Source)[] sources) + { + var syntaxTrees = sources.Select( + static source => CSharpSyntaxTree.ParseText(source.Source, ParseOptions, source.Path) + ); + return CSharpCompilation.Create( + "GeneratorTests", + syntaxTrees, + CreateMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + public static GeneratorDriver CreateDriver() => + CSharpGeneratorDriver.Create(new PortableValidationOpenApiGenerator()) + .WithUpdatedParseOptions(ParseOptions); + + private static MetadataReference[] CreateMetadataReferences() + { + var trustedPlatformAssemblies = ((string?) AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))! + .Split(Path.PathSeparator); + var references = new HashSet(trustedPlatformAssemblies, StringComparer.OrdinalIgnoreCase); + AddAssembly(references, typeof(ValidationContext).Assembly); + AddAssembly(references, typeof(GeneratePortableValidationOpenApiAttribute).Assembly); + AddAssembly(references, typeof(PortableOpenApiSchemaTypeMapper).Assembly); + AddAssembly(references, typeof(OpenApiSchema).Assembly); + AddAssembly(references, typeof(PortableValidationOpenApiGenerator).Assembly); + return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + } + + private static void AddAssembly(ISet references, Assembly assembly) + { + if (!string.IsNullOrWhiteSpace(assembly.Location)) + { + references.Add(assembly.Location); + } + } +} + +internal sealed class GeneratorTestResult +{ + public GeneratorTestResult( + ImmutableArray diagnostics, + ImmutableArray resultDiagnostics, + ImmutableArray generatedSources + ) + { + Diagnostics = diagnostics; + ResultDiagnostics = resultDiagnostics; + GeneratedSources = generatedSources; + } + + public ImmutableArray Diagnostics { get; } + public ImmutableArray ResultDiagnostics { get; } + public ImmutableArray GeneratedSources { get; } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs index bc4b66f..18a7949 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs @@ -189,16 +189,6 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() MetadataValue("d", 2.5D), MetadataValue("m", 3.5M), MetadataValue("nothing", null), - MetadataValue("fallback", Guid.Empty), - MetadataValue("dateTime", new DateTime(638891343300000000L, DateTimeKind.Utc)), - MetadataValue( - "dateTimeOffset", - new DateTimeOffset(638891343300000000L, TimeSpan.FromHours(2)) - ), - MetadataValue("dateOnly", DateOnly.FromDayNumber(739822)), - MetadataValue("timeOnly", new TimeOnly(495300000000L)), - MetadataValue("timeSpan", TimeSpan.FromSeconds(5)), - MetadataValue("uri", new Uri("https://example.com/items/42")), MetadataValue("escapes", "a\\b\"c\nd\te\rfg\0h\ai\bj\fk\vl'm") ); var model = ModelWithRules( @@ -223,21 +213,6 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() source.Should().Contain("[\"d\"] = 2.5D"); source.Should().Contain("[\"m\"] = 3.5M"); source.Should().Contain("[\"nothing\"] = null"); - source.Should().Contain( - "[\"fallback\"] = global::System.Guid.ParseExact(\"00000000-0000-0000-0000-000000000000\", \"D\")" - ); - source.Should().Contain( - "[\"dateTime\"] = new global::System.DateTime(638891343300000000L, global::System.DateTimeKind.Utc)" - ); - source.Should().Contain( - "[\"dateTimeOffset\"] = new global::System.DateTimeOffset(638891343300000000L, global::System.TimeSpan.FromTicks(72000000000L))" - ); - source.Should().Contain("[\"dateOnly\"] = global::System.DateOnly.FromDayNumber(739822)"); - source.Should().Contain("[\"timeOnly\"] = new global::System.TimeOnly(495300000000L)"); - source.Should().Contain("[\"timeSpan\"] = global::System.TimeSpan.FromTicks(50000000L)"); - source.Should().Contain( - "[\"uri\"] = new global::System.Uri(\"https://example.com/items/42\", global::System.UriKind.RelativeOrAbsolute)" - ); source.Should() .Contain("[\"escapes\"] = \"a\\\\b\\\"c\\nd\\te\\rf\\u0001g\\0h\\ai\\bj\\fk\\vl\\'m\""); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs index 39d2e70..b24486e 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using FluentAssertions; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using Xunit; namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; @@ -75,8 +77,53 @@ public void Equals_ShouldReturnFalseWhenDiagnosticContentDiffers() first.Equals(second).Should().BeFalse(); } + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticSourcePathDiffers() + { + var first = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "First.cs", 1) + ); + var second = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Second.cs", 1) + ); + + first.Equals(second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticSourceSpanDiffers() + { + var first = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Validator.cs", 1) + ); + var second = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Validator.cs", 2) + ); + + first.Equals(second).Should().BeFalse(); + } + private static ImmutableArray CreateDiagnostics(string argument) => [Diagnostic.Create(Descriptor, Location.None, argument)]; + + private static ImmutableArray CreateDiagnostics( + string argument, + string path, + int spanStart + ) + { + var syntaxTree = CSharpSyntaxTree.ParseText("abcd", path: path); + var location = Location.Create(syntaxTree, new TextSpan(spanStart, 1)); + return [Diagnostic.Create(Descriptor, location, argument)]; + } } public sealed class RuleSchemaKeyComparerTests diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs index 98203b7..5e4f505 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using FluentAssertions; using Light.PortableResults.AspNetCore.MinimalApis; @@ -51,6 +52,23 @@ public async Task DateTimeValidationProblemBodyShouldConformToGeneratedOpenApiDo ); ValidationOpenApiDocumentTestUtilities.SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); schema.Format.Should().Be("date-time"); + + var responseDefinition = (OpenApiResponse) document.Paths["/temporal-validation"] + .Operations![HttpMethod.Post] + .Responses![StatusCodes.Status400BadRequest.ToString()]; + var example = (OpenApiExample) responseDefinition.Content!["application/problem+json"] + .Examples!["ValidationProblem"]; + var exampleBody = example.Value.Should().BeOfType().Subject; + var exampleError = exampleBody["errors"]!.AsArray() + .Single(item => item!["code"]!.GetValue() == ValidationErrorCodes.InRange)!; + exampleError["message"]!.GetValue().Should() + .Be("timestamp must be between 2026-07-26T13:45:30Z and 2026-07-27T13:45:30Z"); + exampleError["metadata"]!["lowerBoundary"]!.GetValue() + .Should() + .Be("2026-07-26T13:45:30Z"); + exampleError["metadata"]!["upperBoundary"]!.GetValue() + .Should() + .Be("2026-07-27T13:45:30Z"); } private static WebApplication CreateApp()