Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<char>, out int, ReadOnlySpan<char>, 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<byte>, out ushort)` and `TryReadHeader(ReadOnlySpan<byte>, 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion src/SbeCodeGenerator/Generators/Types/CompositeDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
namespace SbeSourceGenerator
{
public record CompositeDefinition(string Namespace, string Name, string Description, string SemanticType,
List<IFileContentGenerator> Fields, EndianConversion EndianConversion = EndianConversion.None) : IFileContentGenerator
List<IFileContentGenerator> Fields, EndianConversion EndianConversion = EndianConversion.None,
bool IsMessageHeader = false) : IFileContentGenerator
{
public void AppendFileContent(StringBuilder sb, int tabs = 0)
{
Expand Down Expand Up @@ -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<ValueFieldDefinition>().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("/// <summary>", 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("/// </summary>", tabs);
sb.AppendLine("[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]", tabs);
sb.AppendLine("public static bool TryReadTemplateId(ReadOnlySpan<byte> 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("/// <summary>", 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("/// </summary>", tabs);
sb.AppendLine("[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]", tabs);
sb.AppendLine("public static bool TryReadHeader(ReadOnlySpan<byte> 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);
}
}
}
27 changes: 26 additions & 1 deletion src/SbeCodeGenerator/Generators/Types/DecimalHelperDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -29,13 +32,35 @@ public void AppendFileContent(StringBuilder sb, int tabs = 0)
sb.AppendLine("/// Returns null if the mantissa is null (optional field).", tabs);
sb.AppendLine("/// </summary>", 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("/// <summary>Allocation-free formatting; defaults to <c>F" + decimals.ToString(CultureInfo.InvariantCulture) + "</c>. Empty output for null mantissa.</summary>", tabs);
sb.AppendLine("public readonly bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> 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("/// <summary>IFormattable implementation; uses <c>F" + decimals.ToString(CultureInfo.InvariantCulture) + "</c> when format is null. Returns empty string for null mantissa.</summary>", 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
{
sb.AppendLine("/// <summary>", tabs);
sb.AppendTabs(tabs).Append("/// Converts the mantissa to a decimal using the constant exponent (10^").Append(exponentStr).AppendLine(").");
sb.AppendLine("/// </summary>", tabs);
sb.AppendTabs(tabs).Append("public readonly decimal ToDecimal() => Mantissa * ").Append(multiplier).AppendLine(";");

// Issue #153
sb.AppendLine("/// <summary>Allocation-free formatting; defaults to <c>F" + decimals.ToString(CultureInfo.InvariantCulture) + "</c>.</summary>", tabs);
sb.AppendLine("public readonly bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> 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("/// <summary>IFormattable implementation; uses <c>F" + decimals.ToString(CultureInfo.InvariantCulture) + "</c> when format is null.</summary>", tabs);
sb.AppendTabs(tabs).Append("public readonly string ToString(string? format, IFormatProvider? provider) => ToDecimal().ToString(format ?? ").Append(defaultFormat).AppendLine(", provider);");
}

sb.AppendLine("}", --tabs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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("/// <summary>", tabs);
sb.AppendLine("/// Like <see cref=\"AsSpan\"/> but additionally trims trailing space (0x20) padding —", tabs);
sb.AppendLine("/// matches the behavior of <c>ToString().Trim()</c> without allocating.", tabs);
sb.AppendLine("/// </summary>", tabs);
sb.AppendLine("[System.Diagnostics.CodeAnalysis.UnscopedRef]", tabs);
sb.AppendLine("public readonly ReadOnlySpan<byte> 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<byte>) — zero-allocation comparison
sb.AppendLine("/// <summary>Compares the content to a byte span without allocation. Useful with UTF-8 string literals: symbol.Equals(\"PETR4\"u8)</summary>", tabs);
sb.AppendLine("public readonly bool Equals(ReadOnlySpan<byte> other) => AsSpan().SequenceEqual(other);", tabs);

// Issue #153: ISpanFormattable — allocation-free formatting for hot-path logging.
sb.AppendLine("/// <summary>Allocation-free formatting into a destination span. Returns the trimmed character content.</summary>", tabs);
sb.AppendLine("public readonly bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> 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("/// <summary>IFormattable implementation; delegates to <see cref=\"ToString()\"/>. Format and provider are ignored.</summary>", 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);
Expand Down
3 changes: 2 additions & 1 deletion src/SbeCodeGenerator/Generators/TypesCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/SbeCodeGenerator/SbeSourceGenerator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageId>SbeSourceGenerator</PackageId>
<Title>SBE Source Generator</Title>
<Version>1.3.0</Version>
<Version>1.4.0</Version>
<Authors>Pedro Sakuma</Authors>
<Company>Pedro Sakuma</Company>
<Product>SBE Source Generator</Product>
Expand Down
Loading
Loading