From 7791793f83c70f17ff94d07b8da74da51436f5fb Mon Sep 17 00:00:00 2001 From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:30:51 +0000 Subject: [PATCH] Release 1.7.0: declarative semantic-type registry (#166) + partial generated types (#167) Closes #166: schemas can now annotate fields with semanticType (e.g. UTCTimestampNanos, LocalMktDate, MonthYear) and the generator emits a sibling typed accessor {Field}Value alongside the raw wire field. Eight FIX/SBE built-in converters ship out of the box; users can register or override via [assembly: SbeSemanticType("Name", typeof(MyConverter))] where MyConverter implements ISbeSemanticConverter. Optional fields produce nullable accessors. Field-level semanticType wins, otherwise the field inherits its referenced type's semanticType (common FIX/B3 pattern). Types already producing a typed helper struct (e.g. LocalMktDate -> DateOnly) are left untouched to avoid double conversion. Raw wire accessor is never replaced. Closes #167: SbeDispatcher, ISbeMessageHandler, {Msg}VersionMap, {Msg}DataReader, and {X}Validation are now emitted as partial so consumers can extend them in user code without forking the generator. Layout-bearing blittable structs were already partial. Adds diagnostics SBE016 (wire-type mismatch), SBE017 (does-not-implement ISbeSemanticConverter), SBE018 (semantic accessor name collision). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 13 ++ README.md | 7 +- .../AnalyzerReleases.Shipped.md | 10 ++ .../AnalyzerReleases.Unshipped.md | 4 - src/SbeCodeGenerator/Diagnostics/README.md | 15 ++ .../Diagnostics/SbeDiagnostics.cs | 30 ++++ .../Generators/DispatcherGenerator.cs | 4 +- .../Fields/SemanticAccessorDefinition.cs | 55 +++++++ .../Generators/MessagesCodeGenerator.cs | 89 +++++++++++- .../Generators/Types/MessageDefinition.cs | 2 +- .../Generators/TypesCodeGenerator.cs | 8 ++ .../Generators/ValidationGenerator.cs | 4 +- src/SbeCodeGenerator/SBESourceGenerator.cs | 41 +++++- .../SbeSourceGenerator.csproj | 2 +- src/SbeCodeGenerator/Schema/SchemaFieldDto.cs | 3 +- src/SbeCodeGenerator/Schema/SchemaReader.cs | 3 +- src/SbeCodeGenerator/SchemaContext.cs | 24 ++++ .../BuiltInSemanticConverters.cs | 31 ++++ .../SemanticTypes/PrimitiveSpecialTypeMap.cs | 66 +++++++++ .../SemanticConverterRegistration.cs | 12 ++ .../SemanticConverterRegistry.cs | 44 ++++++ .../SemanticTypesAttributeScanner.cs | 105 ++++++++++++++ .../SemanticTypesRuntimeSource.cs | 119 +++++++++++++++ .../PartialExtensionTests.cs | 118 +++++++++++++++ .../SemanticTypeRegistryTests.cs | 135 ++++++++++++++++++ .../semantic-types-test-schema.xml | 33 +++++ ...esCodeGenerator.Message.Quote.verified.txt | 2 +- ...esCodeGenerator.Message.Trade.verified.txt | 2 +- 28 files changed, 961 insertions(+), 20 deletions(-) create mode 100644 src/SbeCodeGenerator/Generators/Fields/SemanticAccessorDefinition.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/BuiltInSemanticConverters.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/PrimitiveSpecialTypeMap.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistration.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistry.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/SemanticTypesAttributeScanner.cs create mode 100644 src/SbeCodeGenerator/SemanticTypes/SemanticTypesRuntimeSource.cs create mode 100644 tests/SbeCodeGenerator.IntegrationTests/PartialExtensionTests.cs create mode 100644 tests/SbeCodeGenerator.IntegrationTests/SemanticTypeRegistryTests.cs create mode 100644 tests/SbeCodeGenerator.IntegrationTests/TestSchemas/semantic-types-test-schema.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb5ef2..37c3557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.7.0] - 2026-04-30 + +### Added + +- **Declarative semantic-type registry (#166)**: Schemas can now annotate fields with `semanticType` (e.g. `UTCTimestampNanos`, `LocalMktDate`, `MonthYear`) and the generator emits a sibling typed accessor `{Field}Value` next to the raw wire field — without changing the wire layout. Eight FIX/SBE built-in converters ship out of the box (`UTCTimestamp`, `UTCTimestampNanos`, `UTCTimestampMicros`, `UTCTimestampMillis`, `UTCDateOnly`, `LocalMktDate`, `MonthYear`, `Boolean`) producing strongly typed `DateTime` / `DateOnly` / `(int Year, int Month)` / `bool` results from the underlying primitive. Optional fields produce a nullable accessor that returns `null` on the SBE null sentinel. Field-level `semanticType` wins; otherwise the field inherits its referenced type's `semanticType` (the common FIX/B3 pattern of declaring ``). Fields whose type already produces a typed helper struct (e.g. `LocalMktDate` → `DateOnly` via `DateHelper`) are left untouched to avoid double conversion. The raw wire accessor is **never** replaced — the typed accessor is always additive. +- **User-extensible converters via `[assembly: SbeSemanticType("Name", typeof(MyConverter))]`**: Any user type implementing `ISbeSemanticConverter` (a static-abstract interface emitted into every consuming compilation as `SbeSourceGenerator.Runtime.ISbeSemanticConverter`) can be registered against any `semanticType` string and overrides the built-in. The generator scans assembly attributes via a syntax-first incremental pipeline, validates that the converter's `TWire` matches the schema field's wire `SpecialType`, and reports diagnostics on misregistration. Built-ins are seeded automatically; users only declare what they want to override or add. +- **`partial` on non-blittable generated types (#167)**: The dispatcher (`SbeDispatcher`), handler interface (`ISbeMessageHandler`), per-message version maps (`{Msg}VersionMap`), zero-copy readers (`{Msg}DataReader`), and validation extension classes (`{X}Validation`) are now emitted as `partial`. Consumers can extend them in user code without forking the generator — for example, adding instrumentation hooks to the dispatcher, default methods to the handler interface, custom lookups to a version map, or domain-specific helpers to a `DataReader`. Layout-bearing blittable structs were already `partial`; this fills in the remaining surface intentionally, while the semantic-type registry (#166) provides the safe path for adding typed accessors without touching wire layout. +- **`SBE016` diagnostic** — *Semantic converter wire-type mismatch*: emitted when a user-registered converter declares a `TWire` that does not match the schema field's wire primitive. The accessor is suppressed for that field; raw access is unaffected. +- **`SBE017` diagnostic** — *Semantic converter does not implement `ISbeSemanticConverter<,>`*: emitted when a `[SbeSemanticType]` registration points at a type that does not implement the runtime interface (or implements it with non-static members). The registration is ignored. +- **`SBE018` diagnostic** — *Semantic accessor name collision*: emitted (Warning) when the generated `{Field}Value` name would collide with an existing member; the semantic accessor is dropped to keep the surface compiling. + ## [1.6.1] - 2026-04-30 ### Fixed diff --git a/README.md b/README.md index b39b999..0f16e08 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ A Roslyn-based source generator that converts FIX Simple Binary Encoding (SBE) X - Explicit `blockLength` on messages - Validation constraints (min/max ranges) - Zero-cost `SbeDispatcher` + `ISbeMessageHandler` for devirtualized message routing -- Comprehensive build-time diagnostics (SBE001–SBE015) +- Declarative semantic-type registry mapping `semanticType="…"` → typed `{Field}Value` accessors (built-in FIX converters: `UTCTimestamp{Nanos,Micros,Millis}`, `UTCDateOnly`, `LocalMktDate`, `MonthYear`, `Boolean`; user-extensible via `[assembly: SbeSemanticType(...)]`) +- Generated dispatcher, handler interface, version maps, data readers, and validation classes are emitted as `partial` for safe consumer extension (typed accessors should prefer the semantic registry) +- Comprehensive build-time diagnostics (SBE001–SBE018) ## What's New in v1.5.0 @@ -370,6 +372,9 @@ The generator provides comprehensive diagnostics: | SBE013 | Warning | Duplicate type name | | SBE014 | Warning | sinceVersion exceeds schema version | | SBE015 | Warning | Duplicate generated source hintName suppressed | +| SBE016 | Error | Semantic converter wire-type mismatch | +| SBE017 | Error | Semantic converter does not implement `ISbeSemanticConverter<,>` | +| SBE018 | Warning | Semantic accessor name collision | See [Diagnostics README](./src/SbeCodeGenerator/Diagnostics/README.md) for details. diff --git a/src/SbeCodeGenerator/AnalyzerReleases.Shipped.md b/src/SbeCodeGenerator/AnalyzerReleases.Shipped.md index b062b71..6a2fec4 100644 --- a/src/SbeCodeGenerator/AnalyzerReleases.Shipped.md +++ b/src/SbeCodeGenerator/AnalyzerReleases.Shipped.md @@ -1,3 +1,13 @@ +## Release 1.7.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +SBE016 | SbeSourceGenerator | Error | Semantic converter wire-type mismatch (#166) +SBE017 | SbeSourceGenerator | Error | Semantic converter does not implement ISbeSemanticConverter<,> (#166) +SBE018 | SbeSourceGenerator | Warning | Semantic accessor name collides with an existing field (#166) + ## Release 1.6.1 ### New Rules diff --git a/src/SbeCodeGenerator/AnalyzerReleases.Unshipped.md b/src/SbeCodeGenerator/AnalyzerReleases.Unshipped.md index e9b1a80..e69de29 100644 --- a/src/SbeCodeGenerator/AnalyzerReleases.Unshipped.md +++ b/src/SbeCodeGenerator/AnalyzerReleases.Unshipped.md @@ -1,4 +0,0 @@ -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------ diff --git a/src/SbeCodeGenerator/Diagnostics/README.md b/src/SbeCodeGenerator/Diagnostics/README.md index 2bfcec2..5b7a05d 100644 --- a/src/SbeCodeGenerator/Diagnostics/README.md +++ b/src/SbeCodeGenerator/Diagnostics/README.md @@ -107,6 +107,21 @@ Provides compile-time diagnostics for: **Example**: A schema declares two `` blocks; the second pass attempts `AddSource("…/Enums/Side.cs", …)` again. Without the suppression, Roslyn would throw `ArgumentException`, abort the generator phase, and produce a cascade of `CS0246` errors against partially-emitted files. **Resolution**: Resolve the underlying duplication in the schema (commonly a duplicate type name — see also `SBE013`) or fix the upstream code path that emitted the second source. +### SBE016: Semantic Converter Wire-Type Mismatch +**Severity**: Error +**Triggered when**: A user-registered semantic converter (`[assembly: SbeSemanticType("Name", typeof(MyConv))]`) declares a `TWire` (the first type argument of `ISbeSemanticConverter`) that does not match the schema field's underlying primitive (e.g., a `ulong`-wired converter applied to a `uint16` field). +**Resolution**: Either change the converter's `TWire` to match the schema's primitive, or register a different converter for that `semanticType`. The accessor is suppressed for that field; raw access remains available. + +### SBE017: Semantic Converter Does Not Implement ISbeSemanticConverter +**Severity**: Error +**Triggered when**: A type referenced in `[assembly: SbeSemanticType(..., typeof(X))]` does not implement `SbeSourceGenerator.Runtime.ISbeSemanticConverter` (the static-abstract interface emitted by the generator). +**Resolution**: Make the converter type implement `ISbeSemanticConverter` with `static abstract FromWire`/`ToWire` members. The registration is ignored. + +### SBE018: Semantic Accessor Name Collision +**Severity**: Warning +**Triggered when**: The generated `{Field}Value` semantic accessor name collides with an existing member on the same struct (e.g. a sibling field literally named `XxxValue`). +**Resolution**: Rename either the conflicting field in the schema or refactor your field naming to avoid the suffix collision. The semantic accessor is dropped for that field; raw access is unaffected. + ## Usage Diagnostics are automatically reported during source generation. When you build a project that includes an invalid SBE schema as an additional file, you'll see these diagnostics in: diff --git a/src/SbeCodeGenerator/Diagnostics/SbeDiagnostics.cs b/src/SbeCodeGenerator/Diagnostics/SbeDiagnostics.cs index 9322bb5..15fa5d0 100644 --- a/src/SbeCodeGenerator/Diagnostics/SbeDiagnostics.cs +++ b/src/SbeCodeGenerator/Diagnostics/SbeDiagnostics.cs @@ -157,5 +157,35 @@ internal static class SbeDiagnostics defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Roslyn requires every generated source hintName to be unique. The generator now suppresses duplicates and continues, instead of aborting the entire generation phase. Resolve the underlying schema duplication or fix the upstream generator path that produced the second source."); + + // SBE016: Semantic converter wire-type mismatch + public static readonly DiagnosticDescriptor SemanticConverterWireMismatch = new DiagnosticDescriptor( + id: "SBE016", + title: "Semantic converter wire-type mismatch", + messageFormat: "Semantic converter '{0}' for semanticType '{1}' expects wire type '{2}', but field '{3}.{4}' has wire type '{5}'. The {6}Value accessor will not be emitted.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A converter registered via [assembly: SbeSemanticType(...)] (or a built-in) declares a TWire generic argument that does not match the field's actual wire primitive. Fix the registration so TWire matches the field's primitive type, or pick a different converter."); + + // SBE017: Semantic converter does not implement ISbeSemanticConverter<,> + public static readonly DiagnosticDescriptor SemanticConverterMissingInterface = new DiagnosticDescriptor( + id: "SBE017", + title: "Semantic converter must implement ISbeSemanticConverter", + messageFormat: "Type '{0}' is registered for semanticType '{1}' but does not implement 'SbeSourceGenerator.Runtime.ISbeSemanticConverter'. The registration is ignored.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Converters registered via [assembly: SbeSemanticType(...)] must implement ISbeSemanticConverter with static abstract members so the generator can validate the wire/semantic types and emit the typed accessor."); + + // SBE018: Semantic accessor name collision + public static readonly DiagnosticDescriptor SemanticAccessorNameCollision = new DiagnosticDescriptor( + id: "SBE018", + title: "Semantic accessor name collides with an existing field", + messageFormat: "Cannot emit semantic accessor '{0}Value' on message '{1}' because another field with the same name already exists. The semantic accessor for semanticType '{2}' is skipped.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The convention is to emit a typed accessor named '{Field}Value'. If a field literally named '{Field}Value' already exists in the same message, the semantic accessor would collide with it and is skipped to keep the generated code compilable."); } } diff --git a/src/SbeCodeGenerator/Generators/DispatcherGenerator.cs b/src/SbeCodeGenerator/Generators/DispatcherGenerator.cs index f9ff834..195e444 100644 --- a/src/SbeCodeGenerator/Generators/DispatcherGenerator.cs +++ b/src/SbeCodeGenerator/Generators/DispatcherGenerator.cs @@ -45,7 +45,7 @@ internal class DispatcherGenerator : ICodeGenerator sb.Append("namespace ").Append(baseNs).AppendLine(";"); sb.AppendLine(); sb.AppendLine("/// Handler interface dispatched to by . Implement as a struct for zero-cost devirtualized dispatch."); - sb.AppendLine("public interface ISbeMessageHandler"); + sb.AppendLine("public partial interface ISbeMessageHandler"); sb.AppendLine("{"); foreach (var (name, _) in messages) { @@ -70,7 +70,7 @@ internal class DispatcherGenerator : ICodeGenerator sb.AppendLine("/// Because is constrained to struct, ISbeMessageHandler, the JIT"); sb.AppendLine("/// generates a specialized version per handler type and devirtualizes every dispatch call."); sb.AppendLine("/// "); - sb.AppendLine("public static class SbeDispatcher"); + sb.AppendLine("public static partial class SbeDispatcher"); sb.AppendLine("{"); sb.AppendLine("\t/// Decodes the header at the start of and dispatches to the matching handler method."); sb.AppendLine("\t/// true if a known message was dispatched; false if the header could not be read or the templateId is unknown (in which case OnUnknownMessage is called)."); diff --git a/src/SbeCodeGenerator/Generators/Fields/SemanticAccessorDefinition.cs b/src/SbeCodeGenerator/Generators/Fields/SemanticAccessorDefinition.cs new file mode 100644 index 0000000..dd7e436 --- /dev/null +++ b/src/SbeCodeGenerator/Generators/Fields/SemanticAccessorDefinition.cs @@ -0,0 +1,55 @@ +using SbeSourceGenerator.SemanticTypes; +using System.Text; + +namespace SbeSourceGenerator.Generators.Fields +{ + /// + /// Issue #166: emits an additional {Field}Value readonly property next to + /// a raw wire field, delegating to a registered semantic converter. Carries no + /// layout information (does not implement or + /// ), so it never affects offset computation + /// in SumFieldLength() nor appears in the generated ToString(). + /// + public class SemanticAccessorDefinition : IFileContentGenerator + { + private readonly string _fieldName; + private readonly string _converterFqn; + private readonly string _semanticTypeDisplay; + private readonly bool _isOptional; + private readonly string _semanticTypeKey; + private readonly bool _isBuiltIn; + + public SemanticAccessorDefinition(string fieldName, string converterFullyQualifiedName, + string semanticTypeDisplay, bool isOptional, string semanticTypeKey, bool isBuiltIn) + { + _fieldName = fieldName; + _converterFqn = converterFullyQualifiedName; + _semanticTypeDisplay = semanticTypeDisplay; + _isOptional = isOptional; + _semanticTypeKey = semanticTypeKey; + _isBuiltIn = isBuiltIn; + } + + public void AppendFileContent(StringBuilder sb, int tabs = 0) + { + sb.AppendLine("/// ", tabs); + sb.AppendTabs(tabs).Append("/// Typed accessor for ").Append(_fieldName) + .Append(" derived via the ").Append(_semanticTypeKey).Append(" semantic converter") + .Append(_isBuiltIn ? " (built-in)." : ".").AppendLine(); + sb.AppendLine("/// ", tabs); + + if (_isOptional) + { + // Optional fields expose Field/HasField/Set patterns; we read via the public Field property + // (which already handles endian conversion and null-sentinel comparison). + sb.AppendTabs(tabs).Append("public readonly ").Append(_semanticTypeDisplay).Append("? ").Append(_fieldName).Append("Value => ") + .Append(_fieldName).Append(".HasValue ? ").Append(_converterFqn).Append(".FromWire(").Append(_fieldName).Append(".Value) : null;").AppendLine(); + } + else + { + sb.AppendTabs(tabs).Append("public readonly ").Append(_semanticTypeDisplay).Append(" ").Append(_fieldName).Append("Value => ") + .Append(_converterFqn).Append(".FromWire(").Append(_fieldName).Append(");").AppendLine(); + } + } + } +} diff --git a/src/SbeCodeGenerator/Generators/MessagesCodeGenerator.cs b/src/SbeCodeGenerator/Generators/MessagesCodeGenerator.cs index ae38c62..411b31d 100644 --- a/src/SbeCodeGenerator/Generators/MessagesCodeGenerator.cs +++ b/src/SbeCodeGenerator/Generators/MessagesCodeGenerator.cs @@ -2,6 +2,7 @@ using SbeSourceGenerator.Diagnostics; using SbeSourceGenerator.Generators.Fields; using SbeSourceGenerator.Schema; +using SbeSourceGenerator.SemanticTypes; using System.Collections.Generic; using System.Text; @@ -118,7 +119,7 @@ private static (string fileName, string content) BuildVersionMap( .Append(messageName).AppendLine("\"/>."); sb.Append("/// Issue #146: zero-allocation lookup. The array is small (one entry per version) so a linear scan is faster than a dictionary."); sb.AppendLine(); - sb.Append("public static class ").Append(messageName).AppendLine("VersionMap"); + sb.Append("public static partial class ").Append(messageName).AppendLine("VersionMap"); sb.AppendLine("{"); sb.AppendLine("\t/// (BlockLength, Version) tuples in declaration order."); sb.AppendLine("\tpublic static readonly (int BlockLength, int Version)[] Entries = new (int, int)[]"); @@ -380,10 +381,96 @@ private static List GetFieldsForVersion( context.StructTypeNames.Contains(field.Type) )); } + + // Issue #166: emit a typed {Field}Value accessor when the field's semanticType + // is registered. Done after raw-field emission so SumFieldLength sees only the + // wire layout (SemanticAccessorDefinition is not IBlittable). + TryAppendSemanticAccessor(result, field, generatedFieldName, isOptional, context, sourceContext); } return result; } + private static void TryAppendSemanticAccessor( + List result, + SchemaFieldDto field, + string generatedFieldName, + bool isOptional, + SchemaContext context, + SourceProductionContext sourceContext) + { + // Resolve effective semanticType: field-level wins, otherwise inherit from the + // referenced named type (FIX/B3 commonly puts semanticType on declarations). + string effectiveSemantic = !string.IsNullOrEmpty(field.SemanticType) + ? field.SemanticType + : (context.TypeSemanticTypes.TryGetValue(field.Type, out var inherited) ? inherited : ""); + if (string.IsNullOrEmpty(effectiveSemantic)) return; + if (context.TypesWithCustomHelper.Contains(field.Type)) return; // helper already provides typed conversion + if (!context.SemanticConverters.TryGet(effectiveSemantic, out var registration)) return; + + // Determine the field's wire SpecialType. Try (in order): inline SBE primitive on field.Type, + // explicit primitiveType, or the resolved C# underlying primitive of a named type. + SpecialType wire = PrimitiveSpecialTypeMap.FromSbePrimitive(field.Type); + if (wire == SpecialType.None && !string.IsNullOrEmpty(field.PrimitiveType)) + wire = PrimitiveSpecialTypeMap.FromSbePrimitive(field.PrimitiveType); + if (wire == SpecialType.None) + { + var underlying = GetUnderlyingType(field.Type, context); + if (!string.IsNullOrEmpty(underlying)) + wire = PrimitiveSpecialTypeMap.FromCSharpPrimitive(underlying!); + } + + if (wire == SpecialType.None) + { + // Field doesn't resolve to a scalar primitive (composite, char[], etc.) — v1 scope is scalar only. + return; + } + + if (wire != registration.WireSpecialType) + { + sourceContext.ReportDiagnostic(Diagnostic.Create( + SbeDiagnostics.SemanticConverterWireMismatch, + registration.Location ?? Location.None, + registration.ConverterFullyQualifiedName, + registration.SemanticType, + PrimitiveSpecialTypeMap.ToCSharpKeyword(registration.WireSpecialType), + /* message context: */ "", + field.Name, + PrimitiveSpecialTypeMap.ToCSharpKeyword(wire), + generatedFieldName)); + return; + } + + // Name-collision guard: another field literally named "{Field}Value" already in result. + string accessorName = generatedFieldName + "Value"; + foreach (var existing in result) + { + string? existingName = existing switch + { + MessageFieldDefinition mfd => mfd.Name, + OptionalMessageFieldDefinition omfd => omfd.Name, + _ => null, + }; + if (existingName == accessorName) + { + sourceContext.ReportDiagnostic(Diagnostic.Create( + SbeDiagnostics.SemanticAccessorNameCollision, + registration.Location ?? Location.None, + generatedFieldName, + "", + registration.SemanticType)); + return; + } + } + + result.Add(new SemanticAccessorDefinition( + fieldName: generatedFieldName, + converterFullyQualifiedName: registration.ConverterFullyQualifiedName, + semanticTypeDisplay: registration.SemanticTypeDisplay, + isOptional: isOptional, + semanticTypeKey: registration.SemanticType, + isBuiltIn: registration.IsBuiltIn)); + } + private static List BuildConstants(List constants, SchemaContext context) { var result = new List(constants.Count); diff --git a/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs b/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs index 80c6435..eaf36d3 100644 --- a/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs +++ b/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs @@ -607,7 +607,7 @@ private void AppendReaderStruct(StringBuilder sb, int tabs) sb.AppendTabs(tabs).Append("/// Zero-copy reader for ").Append(Name).AppendLine("Data messages."); sb.AppendLine("/// Provides direct access to the message data in the underlying buffer without copying.", tabs); sb.AppendLine("/// ", tabs); - sb.AppendTabs(tabs).Append("public ref struct ").Append(Name).AppendLine("DataReader"); + sb.AppendTabs(tabs).Append("public ref partial struct ").Append(Name).AppendLine("DataReader"); sb.AppendLine("{", tabs++); sb.AppendLine("private readonly ReadOnlySpan _buffer;", tabs); diff --git a/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs b/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs index cc27adf..a63700f 100644 --- a/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs +++ b/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs @@ -113,6 +113,13 @@ internal class TypesCodeGenerator : ICodeGenerator context.EncodingTypeAliases[typeDto.Name] = typeDto.PrimitiveType; } + // Issue #166: track type-level semanticType so message fields referencing this type + // (without their own semanticType attribute) inherit the registration. + if (!string.IsNullOrEmpty(typeDto.Name) && !string.IsNullOrEmpty(typeDto.SemanticType)) + { + context.TypeSemanticTypes[typeDto.Name] = typeDto.SemanticType; + } + if (!TypeTranslator.IsPrimitive(typeDto.Name)) { var generatedName = TypeResolverHelper.RegisterGeneratedTypeName(context, typeDto.Name, sourceContext); @@ -209,6 +216,7 @@ internal class TypesCodeGenerator : ICodeGenerator sb.Clear(); dateHelper.AppendFileContent(sb); yield return (context.CreateHintName(ns, "Types", generatedName + ".ToDateOnly"), sb.ToString()); + context.TypesWithCustomHelper.Add(typeDto.Name); } } } diff --git a/src/SbeCodeGenerator/Generators/ValidationGenerator.cs b/src/SbeCodeGenerator/Generators/ValidationGenerator.cs index 074bfa3..79b46e9 100644 --- a/src/SbeCodeGenerator/Generators/ValidationGenerator.cs +++ b/src/SbeCodeGenerator/Generators/ValidationGenerator.cs @@ -88,7 +88,7 @@ internal class ValidationGenerator : ICodeGenerator sb.AppendLine($"/// "); sb.Append("/// Validation extension methods for ").Append(messageDto.Name).AppendLine("."); sb.AppendLine($"/// "); - sb.Append("public static class ").Append(messageDto.Name.FirstCharToUpper()).AppendLine("Validation"); + sb.Append("public static partial class ").Append(messageDto.Name.FirstCharToUpper()).AppendLine("Validation"); sb.AppendLine("{"); // Generate TryValidate method first (contains the core logic) @@ -181,7 +181,7 @@ internal class ValidationGenerator : ICodeGenerator sb.AppendLine($"/// "); sb.Append("/// Validation extension methods for ").Append(typeDto.Name).AppendLine("."); sb.AppendLine($"/// "); - sb.Append("public static class ").Append(typeDto.Name).AppendLine("Validation"); + sb.Append("public static partial class ").Append(typeDto.Name).AppendLine("Validation"); sb.AppendLine("{"); // Generate TryValidate method first (contains the core logic) diff --git a/src/SbeCodeGenerator/SBESourceGenerator.cs b/src/SbeCodeGenerator/SBESourceGenerator.cs index efedd92..ba360d9 100644 --- a/src/SbeCodeGenerator/SBESourceGenerator.cs +++ b/src/SbeCodeGenerator/SBESourceGenerator.cs @@ -1,8 +1,10 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using SbeSourceGenerator.Diagnostics; using SbeSourceGenerator.Generators; using SbeSourceGenerator.Schema; +using SbeSourceGenerator.SemanticTypes; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -21,11 +23,35 @@ public class SBESourceGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext initContext) { + // Issue #166: emit the runtime types (SbeSemanticTypeAttribute, ISbeSemanticConverter, + // built-in converters) into every consumer compilation BEFORE any user attribute can + // reference them. RegisterPostInitializationOutput is the supported channel for this. + initContext.RegisterPostInitializationOutput(ctx => + ctx.AddSource(SemanticTypesRuntimeSource.HintName, SemanticTypesRuntimeSource.Source)); + // Stage 1: Collect XML schema files from additional files IncrementalValuesProvider xmlSchemaFiles = CollectXmlSchemaFiles(initContext); - // Stage 2: Combine with analyzer config options (for SbeAssumeHostEndianness hint) - var combined = xmlSchemaFiles.Collect().Combine(initContext.AnalyzerConfigOptionsProvider); + // Issue #166: collect user [assembly: SbeSemanticType(...)] declarations syntax-first. + var userAttributeResults = initContext.SyntaxProvider + .CreateSyntaxProvider(SemanticTypesAttributeScanner.IsCandidate, SemanticTypesAttributeScanner.Transform) + .SelectMany((arr, _) => arr) + .Collect(); + + // Surface SBE017 diagnostics from attribute parsing. + initContext.RegisterSourceOutput(userAttributeResults, (ctx, results) => + { + foreach (var r in results) + { + if (r.Diagnostic != null) + ctx.ReportDiagnostic(r.Diagnostic); + } + }); + + // Stage 2: Combine with analyzer config options (for SbeAssumeHostEndianness hint) and the registry. + var combined = xmlSchemaFiles.Collect() + .Combine(initContext.AnalyzerConfigOptionsProvider) + .Combine(userAttributeResults); // Stage 3: Register source generation with diagnostic support RegisterSourceGeneration(initContext, combined); @@ -43,15 +69,21 @@ private static IncrementalValuesProvider CollectXmlSchemaFiles(I /// Registers source output for each XML schema with diagnostic reporting. /// private static void RegisterSourceGeneration(IncrementalGeneratorInitializationContext initContext, - IncrementalValueProvider<(ImmutableArray Left, AnalyzerConfigOptionsProvider Right)> combined) + IncrementalValueProvider<((ImmutableArray Schemas, AnalyzerConfigOptionsProvider Options) Left, ImmutableArray UserRegs)> combined) { initContext.RegisterSourceOutput(combined, (sourceContext, input) => { - var (text, configOptions) = input; + var ((text, configOptions), userRegs) = input; if (text.IsDefaultOrEmpty) return; + // Build the semantic registry once per generation pass. + var userRegistrations = userRegs.IsDefaultOrEmpty + ? ImmutableArray.Empty + : userRegs.Where(r => r.Registration != null).Select(r => r.Registration!).ToImmutableArray(); + var semanticRegistry = SemanticConverterRegistry.Build(userRegistrations); + // Read the optional SbeAssumeHostEndianness MSBuild property string? hostHint = null; if (configOptions.GlobalOptions.TryGetValue("build_property.SbeAssumeHostEndianness", out var hintValue) @@ -79,6 +111,7 @@ private static void RegisterSourceGeneration(IncrementalGeneratorInitializationC // Create a per-schema context to hold mutable state (sharing runtime tracking) var context = new SchemaContext(schemaKey, emittedRuntimeNamespaces); + context.SemanticConverters = semanticRegistry; if (!string.IsNullOrEmpty(schema.ByteOrder)) { diff --git a/src/SbeCodeGenerator/SbeSourceGenerator.csproj b/src/SbeCodeGenerator/SbeSourceGenerator.csproj index ae5415c..de5efc1 100644 --- a/src/SbeCodeGenerator/SbeSourceGenerator.csproj +++ b/src/SbeCodeGenerator/SbeSourceGenerator.csproj @@ -11,7 +11,7 @@ false SbeSourceGenerator SBE Source Generator - 1.6.1 + 1.7.0 Pedro Sakuma Pedro Sakuma SBE Source Generator diff --git a/src/SbeCodeGenerator/Schema/SchemaFieldDto.cs b/src/SbeCodeGenerator/Schema/SchemaFieldDto.cs index de0afde..0fd58a0 100644 --- a/src/SbeCodeGenerator/Schema/SchemaFieldDto.cs +++ b/src/SbeCodeGenerator/Schema/SchemaFieldDto.cs @@ -20,6 +20,7 @@ internal record SchemaFieldDto( string MinValue, string MaxValue, string Deprecated, - string CharacterEncoding = "" + string CharacterEncoding = "", + string SemanticType = "" ); } diff --git a/src/SbeCodeGenerator/Schema/SchemaReader.cs b/src/SbeCodeGenerator/Schema/SchemaReader.cs index 8649d0e..4821621 100644 --- a/src/SbeCodeGenerator/Schema/SchemaReader.cs +++ b/src/SbeCodeGenerator/Schema/SchemaReader.cs @@ -353,6 +353,7 @@ private static SchemaFieldDto ReadField(XmlReader reader, SourceProductionContex string maxValue = reader.GetAttribute("maxValue") ?? ""; string deprecated = reader.GetAttribute("deprecated") ?? ""; string characterEncoding = reader.GetAttribute("characterEncoding") ?? ""; + string semanticType = reader.GetAttribute("semanticType") ?? ""; // Read inner text manually to leave reader on the end element, // so the parent loop's reader.Read() correctly advances to the next sibling. @@ -370,7 +371,7 @@ private static SchemaFieldDto ReadField(XmlReader reader, SourceProductionContex } return new SchemaFieldDto(name, desc, primitiveType, presence, length, nullValue, valueRef, - innerText, fieldId, offset, type, sinceVersion, minValue, maxValue, deprecated, characterEncoding); + innerText, fieldId, offset, type, sinceVersion, minValue, maxValue, deprecated, characterEncoding, semanticType); } private static string GetRequiredAttribute(XmlReader reader, string attributeName, string elementName, SourceProductionContext sourceContext) diff --git a/src/SbeCodeGenerator/SchemaContext.cs b/src/SbeCodeGenerator/SchemaContext.cs index aa8bbb9..2eeb780 100644 --- a/src/SbeCodeGenerator/SchemaContext.cs +++ b/src/SbeCodeGenerator/SchemaContext.cs @@ -55,6 +55,21 @@ public SchemaContext(string schemaKey, HashSet? sharedRuntimeNamespaces /// public Dictionary OptionalTypes { get; } = new Dictionary(8); + /// + /// Issue #166: maps a named type's semanticType attribute to its key, so that + /// fields referencing the type via type="..." (rather than carrying their own + /// semanticType attribute) inherit the type's semantic registration. + /// + public Dictionary TypeSemanticTypes { get; } = new Dictionary(16); + + /// + /// Issue #166: types whose generated C# representation already provides a typed + /// conversion (e.g. LocalMktDateDateOnly via DateHelper). The + /// semantic-type registry skips inherited registrations for these so it does not + /// double-emit a converter call against an already-typed field. + /// + public HashSet TypesWithCustomHelper { get; } = new HashSet(System.StringComparer.Ordinal); + /// /// Maps user-declared simple type names (from <type> elements) to their /// underlying SBE primitive type name (e.g., "uint8EnumEncoding" -> "uint8"). @@ -95,6 +110,15 @@ public SchemaContext(string schemaKey, HashSet? sharedRuntimeNamespaces /// public EndianConversion EndianConversion { get; set; } = EndianConversion.None; + /// + /// Issue #166: registry of semanticType → converter bindings used to emit + /// typed {Field}Value accessors alongside raw wire fields. Built-in registrations + /// are seeded by ; user + /// registrations from [assembly: SbeSemanticType(...)] override built-ins. + /// + public SemanticTypes.SemanticConverterRegistry SemanticConverters { get; set; } = + SemanticTypes.SemanticConverterRegistry.Empty; + public string CreateHintName(params string[] segments) { var builder = new StringBuilder(SchemaKey); diff --git a/src/SbeCodeGenerator/SemanticTypes/BuiltInSemanticConverters.cs b/src/SbeCodeGenerator/SemanticTypes/BuiltInSemanticConverters.cs new file mode 100644 index 0000000..8b3d195 --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/BuiltInSemanticConverters.cs @@ -0,0 +1,31 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace SbeSourceGenerator.SemanticTypes +{ + internal static class BuiltInSemanticConverters + { + public const string RuntimeNamespace = "SbeSourceGenerator.Runtime"; + + public static readonly IReadOnlyList All = new[] + { + New("UTCTimestampNanos", "UtcTimestampNanosConverter", SpecialType.System_UInt64, "global::System.DateTime"), + New("UTCTimestampMicros", "UtcTimestampMicrosConverter", SpecialType.System_UInt64, "global::System.DateTime"), + New("UTCTimestampMillis", "UtcTimestampMillisConverter", SpecialType.System_UInt64, "global::System.DateTime"), + New("UTCTimestamp", "UtcTimestampSecondsConverter",SpecialType.System_UInt64, "global::System.DateTime"), + New("UTCDateOnly", "UtcDateOnlyConverter", SpecialType.System_UInt16, "global::System.DateOnly"), + New("LocalMktDate", "LocalMktDateConverter", SpecialType.System_UInt16, "global::System.DateOnly"), + New("UTCTimeOnly", "UtcTimeOnlyNanosConverter", SpecialType.System_UInt64, "global::System.TimeOnly"), + New("MonthYear", "MonthYearConverter", SpecialType.System_UInt32, "(int Year, int Month)"), + }; + + private static SemanticConverterRegistration New(string semanticType, string converterTypeName, SpecialType wire, string semanticDisplay) => + new SemanticConverterRegistration( + SemanticType: semanticType, + ConverterFullyQualifiedName: "global::" + RuntimeNamespace + "." + converterTypeName, + WireSpecialType: wire, + SemanticTypeDisplay: semanticDisplay, + IsBuiltIn: true, + Location: Location.None); + } +} diff --git a/src/SbeCodeGenerator/SemanticTypes/PrimitiveSpecialTypeMap.cs b/src/SbeCodeGenerator/SemanticTypes/PrimitiveSpecialTypeMap.cs new file mode 100644 index 0000000..5d58096 --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/PrimitiveSpecialTypeMap.cs @@ -0,0 +1,66 @@ +using Microsoft.CodeAnalysis; + +namespace SbeSourceGenerator.SemanticTypes +{ + /// + /// Maps SBE primitive type names (and their C# translated counterparts) to Roslyn + /// values, used to validate that a semantic converter's + /// declared TWire matches the field's actual primitive on the wire. + /// + internal static class PrimitiveSpecialTypeMap + { + public static SpecialType FromSbePrimitive(string primitive) + { + if (string.IsNullOrEmpty(primitive)) return SpecialType.None; + switch (primitive) + { + case "int8": return SpecialType.System_SByte; + case "uint8": return SpecialType.System_Byte; + case "char": return SpecialType.System_Byte; + case "int16": return SpecialType.System_Int16; + case "uint16": return SpecialType.System_UInt16; + case "int32": return SpecialType.System_Int32; + case "uint32": return SpecialType.System_UInt32; + case "int64": return SpecialType.System_Int64; + case "uint64": return SpecialType.System_UInt64; + case "float": return SpecialType.System_Single; + case "double": return SpecialType.System_Double; + default: return SpecialType.None; + } + } + + public static SpecialType FromCSharpPrimitive(string csharpType) + { + if (string.IsNullOrEmpty(csharpType)) return SpecialType.None; + switch (csharpType) + { + case "sbyte": return SpecialType.System_SByte; + case "byte": return SpecialType.System_Byte; + case "short": return SpecialType.System_Int16; + case "ushort": return SpecialType.System_UInt16; + case "int": return SpecialType.System_Int32; + case "uint": return SpecialType.System_UInt32; + case "long": return SpecialType.System_Int64; + case "ulong": return SpecialType.System_UInt64; + case "float": return SpecialType.System_Single; + case "double": return SpecialType.System_Double; + default: return SpecialType.None; + } + } + + public static string ToCSharpKeyword(SpecialType st) => st switch + { + SpecialType.System_SByte => "sbyte", + SpecialType.System_Byte => "byte", + SpecialType.System_Int16 => "short", + SpecialType.System_UInt16 => "ushort", + SpecialType.System_Int32 => "int", + SpecialType.System_UInt32 => "uint", + SpecialType.System_Int64 => "long", + SpecialType.System_UInt64 => "ulong", + SpecialType.System_Single => "float", + SpecialType.System_Double => "double", + _ => "", + }; + } +} diff --git a/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistration.cs b/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistration.cs new file mode 100644 index 0000000..65034de --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistration.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace SbeSourceGenerator.SemanticTypes +{ + public sealed record SemanticConverterRegistration( + string SemanticType, + string ConverterFullyQualifiedName, + SpecialType WireSpecialType, + string SemanticTypeDisplay, + bool IsBuiltIn, + Location Location); +} diff --git a/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistry.cs b/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistry.cs new file mode 100644 index 0000000..49dafc0 --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/SemanticConverterRegistry.cs @@ -0,0 +1,44 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace SbeSourceGenerator.SemanticTypes +{ + public sealed class SemanticConverterRegistry + { + public static readonly SemanticConverterRegistry Empty = + new SemanticConverterRegistry(ImmutableDictionary.Empty); + + private readonly ImmutableDictionary _byName; + + private SemanticConverterRegistry(ImmutableDictionary byName) + { + _byName = byName; + } + + public bool TryGet(string semanticType, out SemanticConverterRegistration registration) + { + if (string.IsNullOrEmpty(semanticType)) + { + registration = null!; + return false; + } + return _byName.TryGetValue(semanticType, out registration!); + } + + public static SemanticConverterRegistry Build(ImmutableArray userRegistrations) + { + var builder = ImmutableDictionary.CreateBuilder(System.StringComparer.Ordinal); + foreach (var b in BuiltInSemanticConverters.All) + builder[b.SemanticType] = b; + if (!userRegistrations.IsDefaultOrEmpty) + { + foreach (var u in userRegistrations) + builder[u.SemanticType] = u; + } + return new SemanticConverterRegistry(builder.ToImmutable()); + } + + public IEnumerable All => _byName.Values; + } +} diff --git a/src/SbeCodeGenerator/SemanticTypes/SemanticTypesAttributeScanner.cs b/src/SbeCodeGenerator/SemanticTypes/SemanticTypesAttributeScanner.cs new file mode 100644 index 0000000..eeb63c4 --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/SemanticTypesAttributeScanner.cs @@ -0,0 +1,105 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SbeSourceGenerator.Diagnostics; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; + +namespace SbeSourceGenerator.SemanticTypes +{ + /// + /// Issue #166: incremental, syntax-first scanner that picks up + /// [assembly: SbeSemanticType("Name", typeof(MyConverter))] declarations + /// in the consumer compilation, validates that the converter implements + /// ISbeSemanticConverter<TWire, TSemantic>, and produces stable + /// DTOs (no ISymbols leak + /// past this point so the incremental cache stays sound). + /// + internal static class SemanticTypesAttributeScanner + { + private const string AttributeFullName = "SbeSourceGenerator.Runtime.SbeSemanticTypeAttribute"; + private const string InterfaceFullName = "SbeSourceGenerator.Runtime.ISbeSemanticConverter`2"; + + /// Syntactically pre-filters assembly attribute lists named like the registration attribute. + public static bool IsCandidate(SyntaxNode node, CancellationToken _) + { + if (node is not AttributeListSyntax list) return false; + if (list.Target?.Identifier.Text != "assembly") return false; + foreach (var attr in list.Attributes) + { + var name = attr.Name.ToString(); + if (name == "SbeSemanticType" || name == "SbeSemanticTypeAttribute" + || name.EndsWith(".SbeSemanticType") || name.EndsWith(".SbeSemanticTypeAttribute")) + return true; + } + return false; + } + + /// Resolves candidate attribute lists to immutable registrations + diagnostics. + public static ImmutableArray Transform(GeneratorSyntaxContext context, CancellationToken ct) + { + var list = (AttributeListSyntax)context.Node; + var results = ImmutableArray.CreateBuilder(); + foreach (var attr in list.Attributes) + { + ct.ThrowIfCancellationRequested(); + var symbolInfo = context.SemanticModel.GetSymbolInfo(attr, ct); + if (symbolInfo.Symbol is not IMethodSymbol ctor) continue; + if (ctor.ContainingType?.ToDisplayString() != AttributeFullName) continue; + + var args = attr.ArgumentList?.Arguments; + if (args is null || args.Value.Count != 2) continue; + + // arg0: string semanticType + var semanticTypeConstant = context.SemanticModel.GetConstantValue(args.Value[0].Expression, ct); + if (!semanticTypeConstant.HasValue || semanticTypeConstant.Value is not string semanticTypeName || string.IsNullOrEmpty(semanticTypeName)) + continue; + + // arg1: typeof(T) + if (args.Value[1].Expression is not TypeOfExpressionSyntax typeOf) continue; + var typeInfo = context.SemanticModel.GetTypeInfo(typeOf.Type, ct); + if (typeInfo.Type is not INamedTypeSymbol converterSymbol) continue; + + var location = attr.GetLocation(); + + // Find ISbeSemanticConverter implementation. + var iface = converterSymbol.AllInterfaces.FirstOrDefault(i => + i.IsGenericType && i.ConstructedFrom?.ToDisplayString() == "SbeSourceGenerator.Runtime.ISbeSemanticConverter"); + if (iface is null || iface.TypeArguments.Length != 2) + { + results.Add(UserAttributeResult.Diag(Diagnostic.Create( + SbeDiagnostics.SemanticConverterMissingInterface, + location, + converterSymbol.ToDisplayString(), + semanticTypeName))); + continue; + } + + var wireSpecial = iface.TypeArguments[0].SpecialType; + var semantic = iface.TypeArguments[1]; + var converterFqn = "global::" + converterSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)); + var semanticDisplay = semantic.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + results.Add(UserAttributeResult.Reg(new SemanticConverterRegistration( + SemanticType: semanticTypeName, + ConverterFullyQualifiedName: converterFqn, + WireSpecialType: wireSpecial, + SemanticTypeDisplay: semanticDisplay, + IsBuiltIn: false, + Location: location))); + } + return results.ToImmutable(); + } + } + + internal readonly struct UserAttributeResult + { + public SemanticConverterRegistration? Registration { get; } + public Diagnostic? Diagnostic { get; } + private UserAttributeResult(SemanticConverterRegistration? r, Diagnostic? d) { Registration = r; Diagnostic = d; } + public static UserAttributeResult Reg(SemanticConverterRegistration r) => new(r, null); + public static UserAttributeResult Diag(Diagnostic d) => new(null, d); + } +} diff --git a/src/SbeCodeGenerator/SemanticTypes/SemanticTypesRuntimeSource.cs b/src/SbeCodeGenerator/SemanticTypes/SemanticTypesRuntimeSource.cs new file mode 100644 index 0000000..94c5062 --- /dev/null +++ b/src/SbeCodeGenerator/SemanticTypes/SemanticTypesRuntimeSource.cs @@ -0,0 +1,119 @@ +namespace SbeSourceGenerator.SemanticTypes +{ + /// + /// Issue #166: source emitted into every consumer assembly via + /// RegisterPostInitializationOutput. Defines the assembly-attribute, + /// the converter interface, and the built-in FIX-style converters in a + /// stable namespace so that user [assembly: SbeSemanticType(...)] + /// declarations always have something to point at, regardless of schema + /// processing order. + /// + internal static class SemanticTypesRuntimeSource + { + public const string HintName = "SbeSourceGenerator.Runtime.SemanticTypes.g.cs"; + + public const string Source = @"// +// Issue #166: declarative semantic-type registry runtime types. +// This file is emitted by SbeSourceGenerator into every consumer compilation. +// Requires C# 11 / .NET 7+ (uses static abstract interface members). +#nullable enable +namespace SbeSourceGenerator.Runtime +{ + using System; + + /// + /// Registers a converter that produces a typed accessor (e.g. , + /// ) alongside the raw wire accessor for any field whose + /// schema semanticType attribute equals . The generator + /// auto-registers a built-in set (UTCTimestamp variants, UTCDateOnly, LocalMktDate, + /// UTCTimeOnly, MonthYear); user registrations override built-ins for the same key. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + internal sealed class SbeSemanticTypeAttribute : Attribute + { + public string SemanticType { get; } + public Type Converter { get; } + public SbeSemanticTypeAttribute(string semanticType, Type converter) + { + SemanticType = semanticType; + Converter = converter; + } + } + + /// + /// Implement on a static class to register a typed accessor for an SBE wire primitive. + /// The generator inspects the TWire generic argument to validate it matches the field's + /// primitive type (mismatch → SBE016 error). + /// + internal interface ISbeSemanticConverter where TWire : unmanaged + { + static abstract TSemantic FromWire(TWire wire); + static abstract TWire ToWire(TSemantic semantic); + } + + // -------- Built-in converters -------- + // Naming mirrors common FIX semanticType values. Generated code calls these as + // concrete static methods (no virtual dispatch, AOT-friendly). + + internal static class UtcTimestampNanosConverter + { + // DateTime ticks are 100ns. Nanosecond inputs are truncated to the nearest 100ns boundary. + public static DateTime FromWire(ulong wire) => + new DateTime(DateTime.UnixEpoch.Ticks + (long)(wire / 100UL), DateTimeKind.Utc); + public static ulong ToWire(DateTime semantic) => + (ulong)((semantic.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks) * 100L); + } + + internal static class UtcTimestampMicrosConverter + { + public static DateTime FromWire(ulong wire) => + new DateTime(DateTime.UnixEpoch.Ticks + (long)(wire * 10UL), DateTimeKind.Utc); + public static ulong ToWire(DateTime semantic) => + (ulong)((semantic.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks) / 10L); + } + + internal static class UtcTimestampMillisConverter + { + public static DateTime FromWire(ulong wire) => + DateTime.UnixEpoch.AddMilliseconds(wire); + public static ulong ToWire(DateTime semantic) => + (ulong)(semantic.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds; + } + + internal static class UtcTimestampSecondsConverter + { + public static DateTime FromWire(ulong wire) => + DateTime.UnixEpoch.AddSeconds(wire); + public static ulong ToWire(DateTime semantic) => + (ulong)(semantic.ToUniversalTime() - DateTime.UnixEpoch).TotalSeconds; + } + + internal static class UtcDateOnlyConverter + { + private static readonly DateOnly Epoch = new DateOnly(1970, 1, 1); + public static DateOnly FromWire(ushort wire) => Epoch.AddDays(wire); + public static ushort ToWire(DateOnly semantic) => checked((ushort)(semantic.DayNumber - Epoch.DayNumber)); + } + + internal static class LocalMktDateConverter + { + private static readonly DateOnly Epoch = new DateOnly(1970, 1, 1); + public static DateOnly FromWire(ushort wire) => Epoch.AddDays(wire); + public static ushort ToWire(DateOnly semantic) => checked((ushort)(semantic.DayNumber - Epoch.DayNumber)); + } + + internal static class UtcTimeOnlyNanosConverter + { + public static TimeOnly FromWire(ulong wire) => new TimeOnly((long)(wire / 100UL)); + public static ulong ToWire(TimeOnly semantic) => (ulong)(semantic.Ticks * 100L); + } + + internal static class MonthYearConverter + { + public static (int Year, int Month) FromWire(uint wire) => ((int)(wire / 100u), (int)(wire % 100u)); + public static uint ToWire((int Year, int Month) semantic) => checked((uint)(semantic.Year * 100 + semantic.Month)); + } +} +"; + } +} diff --git a/tests/SbeCodeGenerator.IntegrationTests/PartialExtensionTests.cs b/tests/SbeCodeGenerator.IntegrationTests/PartialExtensionTests.cs new file mode 100644 index 0000000..9d6ea21 --- /dev/null +++ b/tests/SbeCodeGenerator.IntegrationTests/PartialExtensionTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Runtime.InteropServices; +using Edge.Cases.Test.V0; +using V0Versioning = Versioning.Test.V2; +using Xunit; + +namespace SbeCodeGenerator.IntegrationTests +{ + /// + /// Issue #167: verifies that non-blittable generated types are emitted as partial, + /// so consumers can extend them safely without forking the generated code. + /// + public class PartialExtensionTests + { + // --- Extend the generated SbeDispatcher with a custom helper method --- + + [Fact] + public void SbeDispatcher_PartialExtension_IsCompiledAndCallable() + { + Span buffer = stackalloc byte[MessageHeader.MESSAGE_SIZE + TradeData.MESSAGE_SIZE]; + ref var header = ref MemoryMarshal.AsRef(buffer); + header.BlockLength = (ushort)TradeData.BLOCK_LENGTH; + header.TemplateId = (ushort)TradeData.MESSAGE_ID; + header.SchemaId = 1; + header.Version = 0; + + var handler = new SilentHandler(); + + // Calls a method defined in our partial extension below. + bool dispatched = SbeDispatcher.DispatchAndCount(buffer, ref handler, out int dispatchCount); + + Assert.True(dispatched); + Assert.Equal(1, dispatchCount); + } + + // --- Extend the generated ISbeMessageHandler interface with a default method --- + + [Fact] + public void ISbeMessageHandler_PartialExtension_DefaultMethodIsAvailable() + { + ISbeMessageHandler handler = new SilentHandler(); + // DescribeSelf is defined on our partial interface below. + Assert.Equal("ISbeMessageHandler", handler.DescribeSelf()); + } + + // --- Extend a generated VersionMap with a custom lookup --- + + [Fact] + public void VersionMap_PartialExtension_AddsCustomLookup() + { + // GetVersionOrDefault is defined in our partial extension below. + int v = V0Versioning.EvolvingOrderVersionMap.GetVersionOrDefault(blockLength: 16, fallback: -42); + Assert.Equal(0, v); + + int unknown = V0Versioning.EvolvingOrderVersionMap.GetVersionOrDefault(blockLength: 999, fallback: -42); + Assert.Equal(-42, unknown); + } + + // --- Extend a generated DataReader ref struct with a custom helper --- + + [Fact] + public void DataReader_PartialExtension_AddsCustomHelper() + { + Span buffer = stackalloc byte[TradeData.MESSAGE_SIZE]; + ref var trade = ref MemoryMarshal.AsRef(buffer); + trade.Quantity = 42; + + Assert.True(TradeData.TryParse(buffer, out var reader)); + // QuantityDoubled is defined in the partial extension below. + Assert.Equal(84, reader.QuantityDoubled()); + } + + private struct SilentHandler : ISbeMessageHandler + { + public void OnTrade(in TradeDataReader reader, int blockLength, int version) { } + public void OnTextMessage(in TextMessageDataReader reader, int blockLength, int version) { } + public void OnMarketData(in MarketDataDataReader reader, int blockLength, int version) { } + public void OnUnknownMessage(int templateId, int blockLength, int version, ReadOnlySpan payload) { } + } + } +} + +namespace Edge.Cases.Test.V0 +{ + // Partial extension of the generated dispatcher static class. + public static partial class SbeDispatcher + { + public static bool DispatchAndCount(ReadOnlySpan buffer, ref T handler, out int count) + where T : struct, ISbeMessageHandler + { + bool ok = Dispatch(buffer, ref handler); + count = ok ? 1 : 0; + return ok; + } + } + + // Partial extension of the generated handler interface (default interface method). + public partial interface ISbeMessageHandler + { + string DescribeSelf() => nameof(ISbeMessageHandler); + } + + // Partial extension of the generated DataReader ref struct. + public ref partial struct TradeDataReader + { + public long QuantityDoubled() => Data.Quantity * 2; + } +} + +namespace Versioning.Test.V2 +{ + // Partial extension of the generated VersionMap static class. + public static partial class EvolvingOrderVersionMap + { + public static int GetVersionOrDefault(int blockLength, int fallback) + => TryGetVersion(blockLength, out var v) ? v : fallback; + } +} diff --git a/tests/SbeCodeGenerator.IntegrationTests/SemanticTypeRegistryTests.cs b/tests/SbeCodeGenerator.IntegrationTests/SemanticTypeRegistryTests.cs new file mode 100644 index 0000000..4d6db6b --- /dev/null +++ b/tests/SbeCodeGenerator.IntegrationTests/SemanticTypeRegistryTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Runtime.InteropServices; +using SbeSourceGenerator.Runtime; +using Semantic.Types.Test.V0; +using Xunit; + +// Issue #166: register a user-defined converter for a semanticType not in the built-in set. +[assembly: SbeSemanticType("MyCustomStatus", typeof(SbeCodeGenerator.IntegrationTests.MyCustomStatusConverter))] + +namespace SbeCodeGenerator.IntegrationTests +{ + public enum MyStatus : byte + { + Unknown = 0, + Active = 1, + Suspended = 2, + Closed = 3, + } + + public sealed class MyCustomStatusConverter : ISbeSemanticConverter + { + public static MyStatus FromWire(byte wire) => (MyStatus)wire; + public static byte ToWire(MyStatus semantic) => (byte)semantic; + } + + /// + /// Issue #166: end-to-end coverage for the semantic-type registry. Verifies built-in + /// converters generate {Field}Value accessors with correct conversions, optional + /// fields produce nullable accessors, fields without a registered semanticType get no + /// extra accessor, and user-registered converters are honoured. + /// + public class SemanticTypeRegistryTests + { + [Fact] + public void UTCTimestampNanos_BuiltIn_ProducesDateTimeAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + // 2024-01-15T12:34:56.789Z, in nanoseconds since UNIX epoch. + var expected = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Utc); + ulong wireNanos = (ulong)((expected - DateTime.UnixEpoch).Ticks * 100L); + msg.TransactTime = wireNanos; + + // Built-in UtcTimestampNanosConverter truncates to 100ns ticks (DateTime resolution). + Assert.Equal(expected, msg.TransactTimeValue); + } + + [Fact] + public void UTCTimestampMicros_BuiltIn_ProducesDateTimeAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + var expected = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + msg.ExchangeTime = (ulong)(expected - DateTime.UnixEpoch).TotalMicroseconds; + + Assert.Equal(expected, msg.ExchangeTimeValue); + } + + [Fact] + public void UTCDateOnly_BuiltIn_ProducesDateOnlyAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + var expected = new DateOnly(2024, 1, 15); + msg.BookingDate = (ushort)(expected.DayNumber - new DateOnly(1970, 1, 1).DayNumber); + + Assert.Equal(expected, msg.BookingDateValue); + } + + [Fact] + public void LocalMktDate_BuiltIn_ProducesDateOnlyAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + var expected = new DateOnly(2030, 12, 31); + msg.SettlementDate = (ushort)(expected.DayNumber - new DateOnly(1970, 1, 1).DayNumber); + + Assert.Equal(expected, msg.SettlementDateValue); + } + + [Fact] + public void MonthYear_BuiltIn_ProducesTupleAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + msg.ContractMonth = 202407u; + var (year, month) = msg.ContractMonthValue; + Assert.Equal(2024, year); + Assert.Equal(7, month); + } + + [Fact] + public void OptionalSemantic_NullSentinel_ReturnsNull() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + msg.SetOptionalNanos(null); + Assert.Null(msg.OptionalNanosValue); + + var when = new DateTime(2024, 6, 15, 10, 11, 12, DateTimeKind.Utc); + msg.SetOptionalNanos((ulong)((when - DateTime.UnixEpoch).Ticks * 100L)); + Assert.NotNull(msg.OptionalNanosValue); + Assert.Equal(when, msg.OptionalNanosValue!.Value); + } + + [Fact] + public void UserRegisteredConverter_ProducesTypedAccessor() + { + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + msg.CustomStatus = (byte)MyStatus.Suspended; + Assert.Equal(MyStatus.Suspended, msg.CustomStatusValue); + } + + [Fact] + public void RawAccessor_StillAvailable_ForAllSemanticFields() + { + // The registry NEVER replaces the raw wire accessor; it only adds a sibling. + Span buffer = stackalloc byte[TimedEventData.MESSAGE_SIZE]; + ref var msg = ref MemoryMarshal.AsRef(buffer); + + msg.TransactTime = 12345UL; + Assert.Equal(12345UL, msg.TransactTime); + msg.ContractMonth = 202401u; + Assert.Equal(202401u, msg.ContractMonth); + } + } +} diff --git a/tests/SbeCodeGenerator.IntegrationTests/TestSchemas/semantic-types-test-schema.xml b/tests/SbeCodeGenerator.IntegrationTests/TestSchemas/semantic-types-test-schema.xml new file mode 100644 index 0000000..a74d0b2 --- /dev/null +++ b/tests/SbeCodeGenerator.IntegrationTests/TestSchemas/semantic-types-test-schema.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Quote.verified.txt b/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Quote.verified.txt index 2a0f6fa..f8d160f 100644 --- a/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Quote.verified.txt +++ b/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Quote.verified.txt @@ -145,7 +145,7 @@ public partial struct QuoteData /// Zero-copy reader for QuoteData messages. /// Provides direct access to the message data in the underlying buffer without copying. /// -public ref struct QuoteDataReader +public ref partial struct QuoteDataReader { private readonly ReadOnlySpan _buffer; private readonly int _blockLength; diff --git a/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Trade.verified.txt b/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Trade.verified.txt index d69f834..097522a 100644 --- a/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Trade.verified.txt +++ b/tests/SbeCodeGenerator.Tests/Snapshots/MessagesCodeGenerator.Message.Trade.verified.txt @@ -145,7 +145,7 @@ public partial struct TradeData /// Zero-copy reader for TradeData messages. /// Provides direct access to the message data in the underlying buffer without copying. /// -public ref struct TradeDataReader +public ref partial struct TradeDataReader { private readonly ReadOnlySpan _buffer; private readonly int _blockLength;