Pipeline Overview
This document describes the internal data flow of Huml.Net from raw input string to typed .NET object and back. It is aimed at contributors who need to understand how the components interact before modifying internal code.
Pipeline Diagram
Input string / ReadOnlySpan<char>
└─► Lexer (Lexer/Lexer.cs) — pull-based ref struct tokeniser
└─► HumlParser (Parser/HumlParser.cs) — recursive-descent ref struct, produces AST
└─► HumlDocument (AST root)
├─► HumlDeserializer — AST → .NET objects
└─► HumlSerializer — .NET objects → HUML text
The public entry point is the static HumlSerializer facade (src/Huml.Net/HumlSerializer.cs). All pipeline classes
are internal sealed — consumers never interact with them directly.
Lexer
Class: internal ref struct Lexer
File: src/Huml.Net/Lexer/Lexer.cs
The Lexer is pull-based: the caller calls NextToken() each time it needs the next token. The
Parser drives the Lexer by calling NextToken() whenever it consumes a token and advances the
lookahead.
Key implementation details:
- Single-pass over a
ReadOnlySpan<char>. CRLF sequences (\r\n) and bare carriage returns (\r) are normalised inline during lexing — thePeekCurrentChar()method returns\nfor both, so the rest of the implementation only handles\n. No upfrontstring.Replacepass is performed; the input span is consumed directly. - Position tracking:
_line,_col, and_lineIndentfields are updated on each character advance. These feed intoHumlParseExceptionline/column diagnostics. EffectiveSpecVersionproperty: Initialised fromHumlOptions.SpecVersion. After the Parser reads a%HUMLheader token, it updates_lexer.EffectiveSpecVersionso that version-gated lexer rules (e.g. backtick triple-quoted strings) activate for the remainder of the document.- Key responsibilities: indentation measurement (
MeasureIndent), comment stripping, quoted and triple-quoted string parsing, numeric literal parsing, version directive scanning, and the main token-type dispatch inNextToken().
Entry point: new Lexer(source, options) stack-allocated ref struct constructor,
then repeated NextToken() calls. Because Lexer is a ref struct, it cannot be
stored on the heap or captured in closures — it lives entirely on the call stack for
the duration of a parse.
Parser
Class: internal ref struct HumlParser
File: src/Huml.Net/Parser/HumlParser.cs
The Parser is a recursive-descent parser with a single token of lookahead. The _lookahead field
is primed in the constructor by calling NextToken() once before any production method runs.
Key implementation details:
- Recursion depth guard: A
_depthcounter is incremented on each recursive call. AHumlParseExceptionis thrown when_depth >= _maxDepth. The defaultMaxRecursionDepthis 64 (range [1, 1024]) — matching theSystem.Text.Jsonconvention. - Version header: The Parser checks for an optional
%HUML vX.Y.Zversion token at the start of the document. If found, it applies the detected version to both_effectiveSpecVersion(used by Parser production methods) and_lexer.EffectiveSpecVersion(used by Lexer rules for the remainder of the document).
Entry point: new HumlParser(source, options) constructor + Parse() returns HumlDocument.
AST
All AST node types live in src/Huml.Net/Parser/ as public sealed record types. All extend
the abstract base public abstract record HumlNode. Records provide structural equality and
immutability by default.
| Type | Role |
|---|---|
HumlDocument |
Root node / mapping block — holds IReadOnlyList<HumlNode> entries |
HumlMapping |
Single key-value pair (Key: string, Value: HumlNode) |
HumlScalar |
Leaf value (Kind: ScalarKind, Value: object?) |
HumlSequence |
Ordered list of HumlNode items |
HumlInlineMapping |
Inline {} mapping block — holds IReadOnlyList<HumlNode> entries |
HumlDocument is used for both the document root and nested multiline mapping blocks.
HumlInlineMapping is used specifically for inline {key: value} syntax and is distinct from
HumlDocument so deserialiser dispatch can tell them apart.
Serialiser
Class: internal static class HumlSerializer
File: src/Huml.Net/Serialization/HumlSerializer.cs
The Serialiser converts a .NET object graph to a HUML text document.
Key implementation details:
- Property enumeration: Uses
PropertyDescriptor.GetDescriptors(type)for a cached, declaration-order list of public readable properties. Base-class properties precede derived-class properties. - Version header: Always emits
%HUML vX.Y.Zas the first line of the output. - Type dispatch order in
SerializeValue:stringis checked first (before theIEnumerablebranch to preventstringbeing treated asIEnumerable<char>), thenbool, integers, floats, decimals, NaN/Inf,null,IEnumerable,Dictionary, and finally the POCO fallback. - Key emission:
AppendKey(sb, key)callsNeedsQuoting()to decide between bare and quoted key syntax.AppendEscapedString(sb, value)handles escaping for string values. - Converter dispatch: Before built-in type dispatch,
SerializeValue()checks for a property-level[HumlConverter]attribute, then a type-level[HumlConverter], thenHumlOptions.Converters. The first matching converter'sWrite(HumlWriterContext, value)method is called. - Enum serialisation: Enum values are serialised as quoted strings. The name is resolved via
EnumNameCache— honouring[HumlEnumValue]overrides andHumlOptions.PropertyNamingPolicytransforms.
Deserialiser
Class: internal static class HumlDeserializer
File: src/Huml.Net/Serialization/HumlDeserializer.cs
The Deserialiser maps a HumlDocument AST to a typed .NET object.
Key implementation details:
- Dispatch:
DeserializeNode()is the central dispatch method; it pattern-matches on the concreteHumlNodesubtype and delegates to type-specific helpers. - POCO mapping: Uses
PropertyDescriptor.GetLookup(type, policy)for O(1) dictionary key lookup keyed by the HUML key (after naming policy transform). The ordering array (GetDescriptors) is used only by the serialiser. - Naming policy:
HumlOptions.PropertyNamingPolicytransforms .NET property names at descriptor build time. The resulting HUML key is used for both the serialised output and the deserialise lookup dictionary key, ensuring round-trip symmetry. - Converter dispatch: Before built-in type dispatch,
DeserializeNode()checks for a property-level[HumlConverter]attribute, then a type-level[HumlConverter]attribute, thenHumlOptions.Converters. The first matching converter'sRead(HumlNode)method is called. - Collection dispatch: Handles
T[],List<T>,IEnumerable<T>, andDictionary<string,T>. - Populate path:
HumlSerializer.Populate<T>()reusesPopulateMappingEntries()— the same property-assignment logic asDeserialize<T>()but targeting an existing instance rather than a freshly constructed one. Only properties present in the HUML document are assigned. init-only properties: Detected via theIsInitOnlyflag onPropertyDescriptor.PropertyInfo.SetValueis used to assign the value after the object is constructed —initsemantics are enforced by the C# compiler at compile time, but CLR reflection can write to init-only backing fields after construction.HumlSerializer.Populate<T>()also writes init-only properties on the supplied instance.
Where Things Live
| Component | File |
|---|---|
| Lexer | src/Huml.Net/Lexer/Lexer.cs |
| Token types | src/Huml.Net/Lexer/TokenType.cs |
| Token struct | src/Huml.Net/Lexer/Token.cs |
| Parser | src/Huml.Net/Parser/HumlParser.cs |
| AST nodes | src/Huml.Net/Parser/Huml*.cs |
| Serialiser | src/Huml.Net/Serialization/HumlSerializer.cs |
| Deserialiser | src/Huml.Net/Serialization/HumlDeserializer.cs |
| Property cache | src/Huml.Net/Serialization/PropertyDescriptor.cs |
| Public facade | src/Huml.Net/HumlSerializer.cs |
| Options | src/Huml.Net/Versioning/HumlOptions.cs |