From 3dcf2d5b685f17fbabe92d6a1a2e6ed986bb1d6f Mon Sep 17 00:00:00 2001 From: Pedro Travi Date: Sat, 25 Apr 2026 17:32:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20DevEx=20P0=20quick=20wins=20=E2=80=94?= =?UTF-8?q?=20TryFormat,=20AsTrimmedSpan,=20header=20helpers=20(#153=20#15?= =?UTF-8?q?5=20#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive features identified in the post-1.3.0 DevEx review against the real-world SbeB3UmdfConsumer. All three eliminate allocations or hardcoded constants on consumer hot paths — emit-time changes only, no codegen size or runtime cost change for existing call sites. #155 - AsTrimmedSpan() on InlineArray char types Consumers of FIX-style space-padded fields (Symbol, Currency, ISIN) were forced into msg.Field.ToString().Trim() — two allocations per read on hot path. AsTrimmedSpan() additionally strips trailing spaces beyond what AsSpan() already trimmed (\0). #153 - ISpanFormattable on char types and decimal composites - Char types implement TryFormat using the schema's characterEncoding (Latin1/UTF-8) over AsTrimmedSpan(). - Decimal composites use the existing Decimals constant for a default format string (e.g. F4) so consumers get correctly-scaled output without allocating in string.Create / Utf8.TryWrite paths. - Both also implement ToString(format, provider) for IFormattable. #156 - Static header read helpers on the messageHeader composite Consumers customizing dispatch were hardcoding 'SbeHeaderSize = 8' and reading the templateId via MemoryMarshal.Read at offset 2 — magic numbers that break if the schema customizes headerType. Now the generated header composite (detected via context.HeaderType, case-insensitive) exposes: public static bool TryReadTemplateId(ReadOnlySpan, out ushort) public static bool TryReadHeader(ReadOnlySpan, out blockLength, out templateId, out schemaId, out version) Both delegate to the field accessors, so endian conversion and any custom headerType field set are handled correctly. Detection: emit gate is the ordinal-case-insensitive name match between the composite name and SchemaContext.HeaderType, plus presence of all four standard fields (BlockLength/TemplateId/SchemaId/Version). Tests: 10 new integration tests in DevExFeatureTests.cs covering all three features. Snapshot updated for TypesCodeGenerator MessageHeader composite. 187 + 138 tests green. Bumps to 1.4.0. Closes #153, #155, #156. #154 (foreach enumerator) remains in P0 backlog — separate PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 11 ++ README.md | 1 + .../Generators/Types/CompositeDefinition.cs | 51 ++++++- .../Types/DecimalHelperDefinition.cs | 27 +++- .../Types/FixedSizeCharTypeDefinition.cs | 29 +++- .../Generators/TypesCodeGenerator.cs | 3 +- .../SbeSourceGenerator.csproj | 2 +- .../DevExFeatureTests.cs | 142 ++++++++++++++++++ ...rator.Composite.MessageHeader.verified.txt | 31 ++++ 9 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 tests/SbeCodeGenerator.IntegrationTests/DevExFeatureTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b3dae..1e850e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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). +## [1.4.0] - 2026-04-25 + +### Added — DevEx P0 features + +- **`AsTrimmedSpan()` on InlineArray char types** (#155): Like `AsSpan()` but additionally strips trailing space (0x20) padding — matches the behavior of `ToString().Trim()` without the two allocations. Lets hot-path consumers compare/log FIX-style space-padded fields (`Symbol`, `Currency`, `IsinNumber`, …) zero-alloc. +- **`ISpanFormattable` on InlineArray char types and decimal composites** (#153): Generated char and decimal-composite types now implement `ISpanFormattable` — `TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?)` plus `ToString(string?, IFormatProvider?)`. Char types delegate to the schema's `characterEncoding` and `AsTrimmedSpan()`. Decimal composites use the schema's `Decimals` constant for the default format (e.g. `F4` / `F8`), so `string.Create(...)` and `Utf8.TryWrite(...)` emit correctly-scaled strings without allocating. +- **Static header read helpers on the message header composite** (#156): The composite designated by `headerType` (default `messageHeader`) now exposes `TryReadTemplateId(ReadOnlySpan, out ushort)` and `TryReadHeader(ReadOnlySpan, out blockLength, out templateId, out schemaId, out version)`. Single source of truth for header layout — consumers no longer hardcode `SbeHeaderSize = 8` or compute offsets manually. + +### Performance +- All three additions are emit-time only with no allocation on the new code paths. `AsTrimmedSpan()` and `TryReadTemplateId` are aggressively inlined. + ## [1.3.0] - 2026-04-25 ### Added diff --git a/README.md b/README.md index 77fe3b1..83728dc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A Roslyn-based source generator that converts FIX Simple Binary Encoding (SBE) X - Variable-length data (varData) with configurable length prefix (uint8/uint16/uint32) - Constant fields in messages, composites, and groups - Derived numeric constants (`MinValue`/`MaxValue` on type wrappers; `Decimals`/`Multiplier`/`MultiplierDecimal`/`Divisor` on decimal composites) +- Allocation-free formatting via `ISpanFormattable` on char types and decimal composites; `AsTrimmedSpan()` on InlineArray char types - Automatic and explicit field offset calculation - Byte order handling (little-endian native, big-endian with `readonly` property-based conversion) - Schema versioning (`sinceVersion`, `deprecated` on fields, enums, sets, data) with per-message `{Msg}VersionMap` for `blockLength → version` lookup diff --git a/src/SbeCodeGenerator/Generators/Types/CompositeDefinition.cs b/src/SbeCodeGenerator/Generators/Types/CompositeDefinition.cs index e514a6a..ea9045f 100644 --- a/src/SbeCodeGenerator/Generators/Types/CompositeDefinition.cs +++ b/src/SbeCodeGenerator/Generators/Types/CompositeDefinition.cs @@ -5,7 +5,8 @@ namespace SbeSourceGenerator { public record CompositeDefinition(string Namespace, string Name, string Description, string SemanticType, - List Fields, EndianConversion EndianConversion = EndianConversion.None) : IFileContentGenerator + List Fields, EndianConversion EndianConversion = EndianConversion.None, + bool IsMessageHeader = false) : IFileContentGenerator { public void AppendFileContent(StringBuilder sb, int tabs = 0) { @@ -105,8 +106,56 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0) else { FileContentGeneratorExtensions.AppendToString(sb, tabs, Name, Fields); + if (IsMessageHeader) + { + AppendHeaderHelpers(sb, tabs); + } } sb.AppendLine("}", --tabs); } + + private void AppendHeaderHelpers(StringBuilder sb, int tabs) + { + // Issue #156: static convenience helpers so consumers don't hardcode + // header offsets when they need to peek at the templateId before dispatching. + // Field accessors handle endian conversion, so we delegate to them. + var valueFields = Fields.OfType().ToList(); + bool hasField(string n) => valueFields.Any(f => string.Equals(f.Name, n, System.StringComparison.OrdinalIgnoreCase)); + if (!(hasField("BlockLength") && hasField("TemplateId") && hasField("SchemaId") && hasField("Version"))) + return; + + sb.AppendLine("", tabs); + sb.AppendLine("/// ", tabs); + sb.AppendLine("/// Reads the templateId from a buffer that starts with this header. Returns false if the buffer is too small.", tabs); + sb.AppendLine("/// ", tabs); + sb.AppendLine("[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]", tabs); + sb.AppendLine("public static bool TryReadTemplateId(ReadOnlySpan buffer, out ushort templateId)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("if (buffer.Length < MESSAGE_SIZE) { templateId = 0; return false; }", tabs); + sb.AppendTabs(tabs).Append("templateId = MemoryMarshal.AsRef<").Append(Name).AppendLine(">(buffer).TemplateId;"); + sb.AppendLine("return true;", tabs); + sb.AppendLine("}", --tabs); + + sb.AppendLine("", tabs); + sb.AppendLine("/// ", tabs); + sb.AppendLine("/// Reads the four standard header fields (blockLength, templateId, schemaId, version)", tabs); + sb.AppendLine("/// in a single call. Returns false if the buffer is smaller than the header.", tabs); + sb.AppendLine("/// ", tabs); + sb.AppendLine("[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]", tabs); + sb.AppendLine("public static bool TryReadHeader(ReadOnlySpan buffer, out ushort blockLength, out ushort templateId, out ushort schemaId, out ushort version)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("if (buffer.Length < MESSAGE_SIZE)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("blockLength = 0; templateId = 0; schemaId = 0; version = 0;", tabs); + sb.AppendLine("return false;", tabs); + sb.AppendLine("}", --tabs); + sb.AppendTabs(tabs).Append("ref readonly var h = ref MemoryMarshal.AsRef<").Append(Name).AppendLine(">(buffer);"); + sb.AppendLine("blockLength = h.BlockLength;", tabs); + sb.AppendLine("templateId = h.TemplateId;", tabs); + sb.AppendLine("schemaId = h.SchemaId;", tabs); + sb.AppendLine("version = h.Version;", tabs); + sb.AppendLine("return true;", tabs); + sb.AppendLine("}", --tabs); + } } } diff --git a/src/SbeCodeGenerator/Generators/Types/DecimalHelperDefinition.cs b/src/SbeCodeGenerator/Generators/Types/DecimalHelperDefinition.cs index 79511a1..558aee1 100644 --- a/src/SbeCodeGenerator/Generators/Types/DecimalHelperDefinition.cs +++ b/src/SbeCodeGenerator/Generators/Types/DecimalHelperDefinition.cs @@ -13,11 +13,14 @@ public record DecimalHelperDefinition(string Namespace, string Name, string Desc public void AppendFileContent(StringBuilder sb, int tabs = 0) { sb.AppendTabs(tabs).Append("namespace ").Append(Namespace).AppendLine(";"); - sb.AppendTabs(tabs).Append("public partial struct ").Append(Name).AppendLine(); + sb.AppendTabs(tabs).Append("public partial struct ").Append(Name).Append(" : ISpanFormattable").AppendLine(); sb.AppendLine("{", tabs++); var exponentStr = Exponent.ToString(CultureInfo.InvariantCulture); var multiplier = $"1e{exponentStr}m"; + int decimals = Exponent < 0 ? -Exponent : 0; + // Default format string (e.g. "F4") used when caller passes empty format. + var defaultFormat = $"\"F{decimals.ToString(CultureInfo.InvariantCulture)}\""; // Issue #145: emit derived constants — single source of truth for decimal places. AppendDerivedConstants(sb, tabs); @@ -29,6 +32,18 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0) sb.AppendLine("/// Returns null if the mantissa is null (optional field).", tabs); sb.AppendLine("/// ", tabs); sb.AppendTabs(tabs).Append("public readonly decimal? ToDecimal() => Mantissa.HasValue ? Mantissa.Value * ").Append(multiplier).AppendLine(" : null;"); + + // Issue #153: ISpanFormattable — allocation-free decimal formatting using the schema's known decimal count. + sb.AppendLine("/// Allocation-free formatting; defaults to F" + decimals.ToString(CultureInfo.InvariantCulture) + ". Empty output for null mantissa.", tabs); + sb.AppendLine("public readonly bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("var v = ToDecimal();", tabs); + sb.AppendLine("if (!v.HasValue) { charsWritten = 0; return true; }", tabs); + sb.AppendTabs(tabs).Append("return v.Value.TryFormat(destination, out charsWritten, format.IsEmpty ? ").Append(defaultFormat).AppendLine(" : format, provider);"); + sb.AppendLine("}", --tabs); + + sb.AppendLine("/// IFormattable implementation; uses F" + decimals.ToString(CultureInfo.InvariantCulture) + " when format is null. Returns empty string for null mantissa.", tabs); + sb.AppendTabs(tabs).Append("public readonly string ToString(string? format, IFormatProvider? provider) => ToDecimal() is decimal v ? v.ToString(format ?? ").Append(defaultFormat).AppendLine(", provider) : string.Empty;"); } else { @@ -36,6 +51,16 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0) sb.AppendTabs(tabs).Append("/// Converts the mantissa to a decimal using the constant exponent (10^").Append(exponentStr).AppendLine(")."); sb.AppendLine("/// ", tabs); sb.AppendTabs(tabs).Append("public readonly decimal ToDecimal() => Mantissa * ").Append(multiplier).AppendLine(";"); + + // Issue #153 + sb.AppendLine("/// Allocation-free formatting; defaults to F" + decimals.ToString(CultureInfo.InvariantCulture) + ".", tabs); + sb.AppendLine("public readonly bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendTabs(tabs).Append("return ToDecimal().TryFormat(destination, out charsWritten, format.IsEmpty ? ").Append(defaultFormat).AppendLine(" : format, provider);"); + sb.AppendLine("}", --tabs); + + sb.AppendLine("/// IFormattable implementation; uses F" + decimals.ToString(CultureInfo.InvariantCulture) + " when format is null.", tabs); + sb.AppendTabs(tabs).Append("public readonly string ToString(string? format, IFormatProvider? provider) => ToDecimal().ToString(format ?? ").Append(defaultFormat).AppendLine(", provider);"); } sb.AppendLine("}", --tabs); diff --git a/src/SbeCodeGenerator/Generators/Types/FixedSizeCharTypeDefinition.cs b/src/SbeCodeGenerator/Generators/Types/FixedSizeCharTypeDefinition.cs index 57206bc..a025e95 100644 --- a/src/SbeCodeGenerator/Generators/Types/FixedSizeCharTypeDefinition.cs +++ b/src/SbeCodeGenerator/Generators/Types/FixedSizeCharTypeDefinition.cs @@ -27,7 +27,7 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0) sb.AppendTabs(tabs).Append("namespace ").Append(Namespace).AppendLine(";"); sb.AppendSummary(Description, tabs); sb.AppendTabs(tabs).Append("[System.Runtime.CompilerServices.InlineArray(").Append(Length).AppendLine(")]"); - sb.AppendTabs(tabs).Append("public struct ").Append(Name).AppendLine(); + sb.AppendTabs(tabs).Append("public struct ").Append(Name).Append(" : ISpanFormattable").AppendLine(); sb.AppendLine("{", tabs++); sb.AppendLine("private byte value;", tabs); @@ -41,10 +41,37 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0) sb.AppendTabs(tabs).Append("return span.Slice(0, index == -1 ? ").Append(Length).AppendLine(" : index);"); sb.AppendLine("}", --tabs); + // Issue #155: AsTrimmedSpan() — additionally trims trailing space padding (FIX convention). + sb.AppendLine("/// ", tabs); + sb.AppendLine("/// Like but additionally trims trailing space (0x20) padding —", tabs); + sb.AppendLine("/// matches the behavior of ToString().Trim() without allocating.", tabs); + sb.AppendLine("/// ", tabs); + sb.AppendLine("[System.Diagnostics.CodeAnalysis.UnscopedRef]", tabs); + sb.AppendLine("public readonly ReadOnlySpan AsTrimmedSpan()", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("var span = AsSpan();", tabs); + sb.AppendLine("while (span.Length > 0 && span[span.Length - 1] == (byte)' ') span = span.Slice(0, span.Length - 1);", tabs); + sb.AppendLine("return span;", tabs); + sb.AppendLine("}", --tabs); + // Equals(ReadOnlySpan) — zero-allocation comparison sb.AppendLine("/// Compares the content to a byte span without allocation. Useful with UTF-8 string literals: symbol.Equals(\"PETR4\"u8)", tabs); sb.AppendLine("public readonly bool Equals(ReadOnlySpan other) => AsSpan().SequenceEqual(other);", tabs); + // Issue #153: ISpanFormattable — allocation-free formatting for hot-path logging. + sb.AppendLine("/// Allocation-free formatting into a destination span. Returns the trimmed character content.", tabs); + sb.AppendLine("public readonly bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null)", tabs); + sb.AppendLine("{", tabs++); + sb.AppendLine("var src = AsTrimmedSpan();", tabs); + sb.AppendTabs(tabs).Append("int needed = System.Text.").Append(ResolvedEncoding).AppendLine(".GetCharCount(src);"); + sb.AppendLine("if (destination.Length < needed) { charsWritten = 0; return false; }", tabs); + sb.AppendTabs(tabs).Append("charsWritten = System.Text.").Append(ResolvedEncoding).AppendLine(".GetChars(src, destination);"); + sb.AppendLine("return true;", tabs); + sb.AppendLine("}", --tabs); + + sb.AppendLine("/// IFormattable implementation; delegates to . Format and provider are ignored.", tabs); + sb.AppendLine("public readonly string ToString(string? format, IFormatProvider? formatProvider) => ToString();", tabs); + // ToString() — delegates to AsSpan() sb.AppendTabs(tabs).Append("public readonly override string ToString() => System.Text.").Append(ResolvedEncoding).AppendLine(".GetString(AsSpan());"); sb.AppendLine("}", --tabs); diff --git a/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs b/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs index 1a9f3a1..441ec69 100644 --- a/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs +++ b/src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs @@ -408,7 +408,8 @@ internal class TypesCodeGenerator : ICodeGenerator compositeDto.Description, compositeDto.SemanticType, fields, - context.EndianConversion + context.EndianConversion, + IsMessageHeader: string.Equals(compositeDto.Name, context.HeaderType, System.StringComparison.OrdinalIgnoreCase) ); if (generator.Fields.All(f => f is IBlittable)) context.CustomTypeLengths[compositeDto.Name] = generator.Fields.SumFieldLength(); diff --git a/src/SbeCodeGenerator/SbeSourceGenerator.csproj b/src/SbeCodeGenerator/SbeSourceGenerator.csproj index 38513dc..0b5f0e8 100644 --- a/src/SbeCodeGenerator/SbeSourceGenerator.csproj +++ b/src/SbeCodeGenerator/SbeSourceGenerator.csproj @@ -11,7 +11,7 @@ false SbeSourceGenerator SBE Source Generator - 1.3.0 + 1.4.0 Pedro Sakuma Pedro Sakuma SBE Source Generator diff --git a/tests/SbeCodeGenerator.IntegrationTests/DevExFeatureTests.cs b/tests/SbeCodeGenerator.IntegrationTests/DevExFeatureTests.cs new file mode 100644 index 0000000..c2cfa36 --- /dev/null +++ b/tests/SbeCodeGenerator.IntegrationTests/DevExFeatureTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using Edge.Cases.Test.V0; +using Xunit; + +namespace SbeCodeGenerator.IntegrationTests +{ + /// + /// DevEx P0 features: AsTrimmedSpan (#155), ISpanFormattable (#153), + /// MessageHeader static read helpers (#156). + /// + public class DevExFeatureTests + { + // ---------- #155: AsTrimmedSpan on InlineArray char types ---------- + + [Fact] + public void AsTrimmedSpan_StripsTrailingNullsAndSpaces() + { + // IsinCode is an InlineArray char type in the edge-cases schema. + var isin = default(IsinCode); + ReadOnlySpan source = "PETR4 "u8; // trailing space-padded (FIX convention) + source.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref isin), source.Length)); + + // AsSpan only trims trailing \0 — would still include the spaces. + Assert.Equal("PETR4 "u8.ToArray(), isin.AsSpan().ToArray()); + + // AsTrimmedSpan additionally trims trailing spaces. + Assert.Equal("PETR4"u8.ToArray(), isin.AsTrimmedSpan().ToArray()); + } + + [Fact] + public void AsTrimmedSpan_HandlesAllNullPadding() + { + var isin = default(IsinCode); + ReadOnlySpan source = "BBDC4"u8; + source.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref isin), source.Length)); + + Assert.Equal("BBDC4"u8.ToArray(), isin.AsTrimmedSpan().ToArray()); + } + + // ---------- #153: ISpanFormattable on InlineArray char types ---------- + + [Fact] + public void CharType_TryFormat_WritesTrimmedContent_ZeroAlloc() + { + var isin = default(IsinCode); + ReadOnlySpan source = "VALE3 "u8; + source.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref isin), source.Length)); + + Span dest = stackalloc char[16]; + Assert.True(isin.TryFormat(dest, out int written, default, null)); + Assert.Equal(5, written); + Assert.Equal("VALE3", new string(dest.Slice(0, written))); + } + + [Fact] + public void CharType_TryFormat_ReturnsFalse_WhenDestinationTooSmall() + { + var isin = default(IsinCode); + "ITUB4"u8.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref isin), 5)); + + Span small = stackalloc char[2]; + Assert.False(isin.TryFormat(small, out int written, default, null)); + Assert.Equal(0, written); + } + + // ---------- #153: ISpanFormattable on decimal composites ---------- + + [Fact] + public void DecimalComposite_TryFormat_UsesDecimalsConstant() + { + // DecimalEncoding has Decimals=7 in the edge-cases schema. + var price = new DecimalEncoding { Mantissa = 12_345_678 }; + Span dest = stackalloc char[32]; + + Assert.True(price.TryFormat(dest, out int written, default, null)); + // 12345678 * 1e-7 = 1.2345678 → "F7" → "1.2345678" + Assert.Equal("1.2345678", new string(dest.Slice(0, written))); + } + + [Fact] + public void DecimalComposite_TryFormat_RespectsExplicitFormat() + { + var price = new DecimalEncoding { Mantissa = 12_345_678 }; + Span dest = stackalloc char[32]; + + Assert.True(price.TryFormat(dest, out int written, "F2", null)); + Assert.Equal("1.23", new string(dest.Slice(0, written))); + } + + [Fact] + public void DecimalComposite_ToString_FormatProvider_Works() + { + var price = new DecimalEncoding { Mantissa = 12_345_678 }; + string s = price.ToString(null, System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal("1.2345678", s); + } + + // ---------- #156: MessageHeader static helpers ---------- + + [Fact] + public void MessageHeader_TryReadTemplateId_RoundTripsAfterWriteHeader() + { + Span buffer = stackalloc byte[MessageHeader.MESSAGE_SIZE + TradeData.MESSAGE_SIZE]; + int offset = TradeData.WriteHeader(buffer); + Assert.Equal(MessageHeader.MESSAGE_SIZE, offset); + + Assert.True(MessageHeader.TryReadTemplateId(buffer, out ushort templateId)); + Assert.Equal((ushort)TradeData.MESSAGE_ID, templateId); + } + + [Fact] + public void MessageHeader_TryReadHeader_PopulatesAllFields() + { + Span buffer = stackalloc byte[MessageHeader.MESSAGE_SIZE + TradeData.MESSAGE_SIZE]; + TradeData.WriteHeader(buffer); + + Assert.True(MessageHeader.TryReadHeader(buffer, out ushort blockLength, out ushort templateId, out ushort schemaId, out ushort version)); + Assert.Equal((ushort)TradeData.MESSAGE_SIZE, blockLength); + Assert.Equal((ushort)TradeData.MESSAGE_ID, templateId); + // schemaId/version are non-negative ushorts; just ensure the call succeeded. + Assert.True(schemaId >= 0); + Assert.True(version >= 0); + } + + [Fact] + public void MessageHeader_TryReadHeader_ReturnsFalse_OnShortBuffer() + { + Span tiny = stackalloc byte[MessageHeader.MESSAGE_SIZE - 1]; + Assert.False(MessageHeader.TryReadTemplateId(tiny, out ushort tid)); + Assert.Equal(0, tid); + Assert.False(MessageHeader.TryReadHeader(tiny, out _, out _, out _, out _)); + } + } + + file static class Unsafe + { + public static ref TTo As(ref TFrom source) where TFrom : struct where TTo : struct + => ref System.Runtime.CompilerServices.Unsafe.As(ref source); + } +} diff --git a/tests/SbeCodeGenerator.Tests/Snapshots/TypesCodeGenerator.Composite.MessageHeader.verified.txt b/tests/SbeCodeGenerator.Tests/Snapshots/TypesCodeGenerator.Composite.MessageHeader.verified.txt index 68e73cc..57a5130 100644 --- a/tests/SbeCodeGenerator.Tests/Snapshots/TypesCodeGenerator.Composite.MessageHeader.verified.txt +++ b/tests/SbeCodeGenerator.Tests/Snapshots/TypesCodeGenerator.Composite.MessageHeader.verified.txt @@ -41,4 +41,35 @@ public partial struct MessageHeader public ushort Version { readonly get => version; set => version = value; } /// Returns a string representation of this struct for debugging. public readonly override string ToString() => $"MessageHeader {{ BlockLength={BlockLength}, TemplateId={TemplateId}, SchemaId={SchemaId}, Version={Version} }}"; + + /// + /// Reads the templateId from a buffer that starts with this header. Returns false if the buffer is too small. + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public static bool TryReadTemplateId(ReadOnlySpan buffer, out ushort templateId) + { + if (buffer.Length < MESSAGE_SIZE) { templateId = 0; return false; } + templateId = MemoryMarshal.AsRef(buffer).TemplateId; + return true; + } + + /// + /// Reads the four standard header fields (blockLength, templateId, schemaId, version) + /// in a single call. Returns false if the buffer is smaller than the header. + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public static bool TryReadHeader(ReadOnlySpan buffer, out ushort blockLength, out ushort templateId, out ushort schemaId, out ushort version) + { + if (buffer.Length < MESSAGE_SIZE) + { + blockLength = 0; templateId = 0; schemaId = 0; version = 0; + return false; + } + ref readonly var h = ref MemoryMarshal.AsRef(buffer); + blockLength = h.BlockLength; + templateId = h.TemplateId; + schemaId = h.SchemaId; + version = h.Version; + return true; + } }